diff --git a/apps/sim/.env.example b/apps/sim/.env.example index c8b8583d103..f5d05f0ac23 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -214,6 +214,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # FORKING_ENABLED= # Workspace forks # CREDENTIAL_GROUPS= # Enterprise managed OAuth collections # TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup +# PERMISSION_ACCESS_REQUESTS_ENABLED= # Global access-request rollout; organizations may opt out # KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval # ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only diff --git a/apps/sim/app/access-requests/layout.tsx b/apps/sim/app/access-requests/layout.tsx new file mode 100644 index 00000000000..f4b12888e24 --- /dev/null +++ b/apps/sim/app/access-requests/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from 'react' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' + +interface AccessRequestsLayoutProps { + children: ReactNode +} + +export default function AccessRequestsLayout({ children }: AccessRequestsLayoutProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/apps/sim/app/access-requests/loading.tsx b/apps/sim/app/access-requests/loading.tsx new file mode 100644 index 00000000000..4eb54fb68e8 --- /dev/null +++ b/apps/sim/app/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' + +export default function Loading() { + return +} diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx new file mode 100644 index 00000000000..87b5d7b55e8 --- /dev/null +++ b/apps/sim/app/access-requests/page.tsx @@ -0,0 +1,70 @@ +import { Suspense } from 'react' +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { createSearchParamsCache } from 'nuqs/server' +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' +import { MyAccessRequests } from '@/components/access-requests/my-access-requests' +import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' +import { accessRequestEntrySearchParams } from '@/components/access-requests/search-params' +import { EmptyState } from '@/components/empty-state/empty-state' +import { getSession } from '@/lib/auth' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' + +export const metadata: Metadata = { + title: 'Access requests', + robots: { index: false, follow: false }, +} + +interface AccessRequestsPageProps { + searchParams: Promise> +} + +const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams) + +/** Session-only entry so access requests remain reachable outside the organization Search rollout. */ +export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) { + const [rawParams, session] = await Promise.all([searchParams, getSession()]) + const params = entrySearchParams.parse(rawParams) + const query = new URLSearchParams() + if (params.organizationId) query.set('organizationId', params.organizationId) + if (params.view !== 'requests') query.set('view', params.view) + if (params.requestId) query.set('requestId', params.requestId) + if (!session?.user) { + redirect( + buildAuthCrossLink('/login', { + callbackUrl: `/access-requests?${query}`, + isInviteFlow: false, + }) + ) + } + + if (!params.organizationId) { + return ( + Your workspaces} + /> + ) + } + + return ( + }> + {params.view === 'admin' ? ( +
+
+
+

Access requests

+ Your workspaces +
+ +
+
+ ) : ( + + )} +
+ ) +} diff --git a/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts b/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts new file mode 100644 index 00000000000..c887c12d619 --- /dev/null +++ b/apps/sim/app/api/access-requests/[requestId]/cancel/route.ts @@ -0,0 +1,23 @@ +import { cancelAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { cancelAccessRequest } from '@/lib/permission-access-requests/application/requests' + +export const POST = defineInternalJsonRoute({ + contract: cancelAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.cancel, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ requestId: params.requestId, scope: body.scope }), + useCase: cancelAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/access-requests/discovery/route.ts b/apps/sim/app/api/access-requests/discovery/route.ts new file mode 100644 index 00000000000..4e0e645fc41 --- /dev/null +++ b/apps/sim/app/api/access-requests/discovery/route.ts @@ -0,0 +1,22 @@ +import { discoverAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { discoverAccessRequests } from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: discoverAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.discover, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: discoverAccessRequests, +}) diff --git a/apps/sim/app/api/access-requests/route.ts b/apps/sim/app/api/access-requests/route.ts new file mode 100644 index 00000000000..74e2bd961c8 --- /dev/null +++ b/apps/sim/app/api/access-requests/route.ts @@ -0,0 +1,47 @@ +import { + createAccessRequestContract, + listMyAccessRequestsContract, +} from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { + createAccessRequest, + listMyAccessRequests, +} from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: listMyAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.listMine, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => ({ + scope: query, + limit: query.limit, + offset: query.offset, + requestId: query.requestId, + }), + useCase: listMyAccessRequests, +}) + +export const POST = defineInternalJsonRoute({ + contract: createAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.create, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: createAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts new file mode 100644 index 00000000000..c4ba646b057 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts @@ -0,0 +1,22 @@ +import { previewAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { previewAccessRequest } from '@/lib/permission-access-requests/application/review' + +export const GET = defineInternalJsonRoute({ + contract: previewAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.preview, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, requestId: params.requestId }), + useCase: previewAccessRequest, +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts new file mode 100644 index 00000000000..0bc389ec7e9 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts @@ -0,0 +1,27 @@ +import { resolveAccessRequestContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { resolveAccessRequest } from '@/lib/permission-access-requests/application/review' + +export const POST = defineInternalJsonRoute({ + contract: resolveAccessRequestContract, + auth: internalSessionAuth, + operation: accessRequestOperations.resolve, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + requestId: params.requestId, + decision: body, + }), + useCase: resolveAccessRequest, + present: ({ request }) => ({ request }), +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/route.ts new file mode 100644 index 00000000000..f68a80694bb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/route.ts @@ -0,0 +1,22 @@ +import { listOrganizationAccessRequestsContract } from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { listOrganizationAccessRequests } from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAccessRequestsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.listOrganization, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }), + useCase: listOrganizationAccessRequests, +}) diff --git a/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts b/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts new file mode 100644 index 00000000000..6f4ceec0c09 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts @@ -0,0 +1,41 @@ +import { + getAccessRequestSettingsContract, + updateAccessRequestSettingsContract, +} from '@/lib/api/contracts/access-requests' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { accessRequestOperations } from '@/lib/permission-access-requests/application/operations' +import { + getAccessRequestSettings, + updateAccessRequestSettings, +} from '@/lib/permission-access-requests/application/requests' + +export const GET = defineInternalJsonRoute({ + contract: getAccessRequestSettingsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.getSettings, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:read', + config: { maxTokens: 120, refillRate: 60, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getAccessRequestSettings, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: updateAccessRequestSettingsContract, + auth: internalSessionAuth, + operation: accessRequestOperations.updateSettings, + rateLimit: internalRateLimits.user({ + bucketName: 'access-requests:write', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateAccessRequestSettings, +}) diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index f79d06a4e28..fe9b2c3a4f0 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -19,6 +19,7 @@ import { getConnectorFailureDiagnostic } from '@/lib/knowledge/connectors/connec import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' import { recoverKnowledgeDocumentProcessing } from '@/lib/knowledge/documents/processing-recovery' import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' +import { permissionAccessRequestOutboxHandlers } from '@/lib/permission-access-requests/notifications' import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' @@ -44,6 +45,7 @@ const handlers = { ...directGrantOutboxHandlers, ...knowledgeDocumentProcessingOutboxHandlers, ...organizationResourceCleanupOutboxHandlers, + ...permissionAccessRequestOutboxHandlers, ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, ...workflowDeploymentOutboxHandlers, diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx new file mode 100644 index 00000000000..4eb54fb68e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' + +export default function Loading() { + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx new file mode 100644 index 00000000000..b3d49e88885 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import type { Metadata } from 'next' +import { AccessRequestsLoading } from '@/components/access-requests/access-requests-loading' +import { MyAccessRequests } from '@/components/access-requests/my-access-requests' + +export const metadata: Metadata = { title: 'My access requests' } + +interface AccessRequestsPageProps { + params: Promise<{ workspaceId: string }> +} + +export default async function AccessRequestsPage({ params }: AccessRequestsPageProps) { + const { workspaceId } = await params + return ( + }> + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx index 4fb2d63be38..a3d2e8df7e1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -1,6 +1,6 @@ import { Chip, cn } from '@sim/emcn' import { Upload } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' @@ -64,25 +64,39 @@ function FilesGraphic() { ) } -interface FilesEmptyStateProps { +interface UploadFilesEmptyStateProps { /** Opens the file picker — the same action the header's upload chip runs. */ onUpload: () => void /** Mirrors the header chip's disabled state: no edit rights, or an upload in flight. */ uploadDisabled?: boolean } -/** Empty state for the files list when the workspace has none. */ -export function FilesEmptyState({ onUpload, uploadDisabled = false }: FilesEmptyStateProps) { +type FilesEmptyStateProps = UploadFilesEmptyStateProps | Omit + +/** Shared file illustration and actions for empty or unavailable files. */ +export function FilesEmptyState(props: FilesEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Files' - description='Upload files to share them across your team and every agent.' + title={content?.title ?? 'Files'} + description={ + content?.description ?? 'Upload files to share them across your team and every agent.' + } action={ <> - - Upload - + {'onUpload' in props ? ( + + Upload + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx index dcfdc7a3664..0ac6e3a0d42 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx @@ -1,33 +1,44 @@ import { Chip } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { KnowledgeIsoMark } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso' const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' -interface KnowledgeEmptyStateProps { +interface CreateKnowledgeEmptyStateProps { /** Opens the create-base modal — the same action the header's primary chip runs. */ onCreate: () => void /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ createDisabled?: boolean } -/** Empty state for the knowledge bases list when the workspace has none. */ -export function KnowledgeEmptyState({ - onCreate, - createDisabled = false, -}: KnowledgeEmptyStateProps) { +type KnowledgeEmptyStateProps = CreateKnowledgeEmptyStateProps | Omit + +/** Shared knowledge illustration and actions for empty or unavailable bases. */ +export function KnowledgeEmptyState(props: KnowledgeEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Knowledge bases' - description='Upload documents to give your agents a memory they can search.' + title={content?.title ?? 'Knowledge bases'} + description={ + content?.description ?? 'Upload documents to give your agents a memory they can search.' + } action={ <> - - New base - + {'onCreate' in props ? ( + + New base + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx index 1026d13ed3d..1f3cb12b3b4 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -1,6 +1,6 @@ import { Chip, cn } from '@sim/emcn' import { Plus } from '@sim/emcn/icons' -import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyState, type EmptyStateProps } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' @@ -105,25 +105,40 @@ function TablesGraphic() { const TABLES_DOCS_URL = 'https://docs.sim.ai/tables' -interface TablesEmptyStateProps { +interface CreateTableEmptyStateProps { /** Creates a table — the same action the header's primary chip runs. */ onCreate: () => void /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */ createDisabled?: boolean } -/** Empty state for the tables list when the workspace has none. */ -export function TablesEmptyState({ onCreate, createDisabled = false }: TablesEmptyStateProps) { +type TablesEmptyStateProps = CreateTableEmptyStateProps | Omit + +/** Shared table illustration and actions for empty or unavailable tables. */ +export function TablesEmptyState(props: TablesEmptyStateProps) { + const content = 'title' in props ? props : undefined return ( } - title='Tables' - description='Create a table to store structured data your agents can read and write.' + title={content?.title ?? 'Tables'} + description={ + content?.description ?? + 'Create a table to store structured data your agents can read and write.' + } action={ <> - - New table - + {'onCreate' in props ? ( + + New table + + ) : ( + content?.action + )} } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f18841c0821..610456cd742 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -24,6 +24,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { getDocumentIcon } from '@/components/icons/document-icons' import { useLimitUpgradeToast } from '@/lib/billing/client' import { captureEvent } from '@/lib/posthog/client' @@ -264,6 +265,14 @@ function formatFileType(storedType: string | null, filename: string): string { } export function Files() { + return ( + + + + ) +} + +function FilesContent() { const fileInputRef = useRef(null) const saveRef = useRef<(() => Promise) | null>(null) const downloadSourceRef = useRef(null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index dd08f5fc925..9a6772782d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -1,7 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' import { listWorkspaceFileFoldersContract } from '@/lib/api/contracts/workspace-file-folders' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { seedWorkspaceFiles } from '@/app/workspace/[workspaceId]/lib/seed-workspace-files' import { @@ -35,6 +37,7 @@ export async function prefetchFilesBrowser( if (!userId) return const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) if (!hostContext) return + if (!(await authorizeResourcePrefetch(listAllWorkspaceFiles, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 73ab07b3539..f68a9e8bcfb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -5,6 +5,7 @@ import { cn, Expandable, ExpandableContent, SecretReveal, Tooltip, toast } from import { ArrowRight, Check, ChevronDown, SquareArrowUpRight, TerminalWindow } from '@sim/emcn/icons' import { isRecordLike } from '@sim/utils/object' import { useParams } from 'next/navigation' +import { MemberLimitRequestAction } from '@/components/access-requests/member-limit-request-action' import { useSession } from '@/lib/auth/auth-client' import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons' import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' @@ -76,6 +77,7 @@ import { useTablesList } from '@/hooks/queries/tables' import { findWorkspaceFileByPath } from '@/hooks/queries/utils/find-workspace-file-by-src' import { useWorkflows } from '@/hooks/queries/workflows' import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' +import { useWorkspaceUsageGate } from '@/hooks/queries/workspace-usage' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' export interface OptionsItemData { @@ -3184,6 +3186,9 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { ? buildHostedUpgradeUrl() : HOSTED_BILLING_SETTINGS_URL const canManageBilling = !hosted || canManageWorkspaceBilling(hostContext, session?.user?.id) + const usageGate = useWorkspaceUsageGate( + data.action === 'increase_limit' && !canManageBilling ? hostContext.workspace.id : undefined + ) const unavailableMessage = hostContext.hostOrganizationId ? 'Contact an organization admin to manage this workspace’s usage limits.' : 'Only the workspace owner can manage this workspace’s usage limits.' @@ -3225,7 +3230,16 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {hosted ? : } ) : ( -

{unavailableMessage}

+
+

{unavailableMessage}

+ {usageGate.isSuccess && + usageGate.data.isExceeded && + usageGate.data.scope === 'member' && ( + + )} +
)} ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx new file mode 100644 index 00000000000..77a2ea45758 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { describe, expect, it, vi } from 'vitest' + +const { usageGate } = vi.hoisted(() => ({ usageGate: vi.fn() })) +vi.mock('@/hooks/queries/workspace-usage', () => ({ useWorkspaceUsageGate: usageGate })) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'member' } } }), +})) +vi.mock('@/lib/core/config/deployment-shape', async (importOriginal) => ({ + ...(await importOriginal()), + useDeploymentShape: () => ({ hosted: true }), +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useWorkspaceHostContext: () => ({ + workspace: { id: 'workspace', billedAccountUserId: 'owner' }, + hostOrganizationId: 'organization', + viewer: { isHostOrganizationAdmin: false }, + }), +})) +vi.mock('@/hooks/use-settings-navigation', () => ({ + useSettingsNavigation: () => ({ getSettingsHref: () => '/settings/billing' }), +})) +vi.mock('@/components/access-requests/member-limit-request-action', () => ({ + MemberLimitRequestAction: () => , +})) + +import { SpecialTags } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' + +describe('usage-limit request action', () => { + it.each([ + { scope: 'payer', isExceeded: true, isSuccess: true, visible: false }, + { scope: 'member', isExceeded: true, isSuccess: true, visible: true }, + { scope: 'member', isExceeded: false, isSuccess: true, visible: false }, + { scope: 'member', isExceeded: true, isSuccess: false, visible: false }, + ])( + 'offers the remedy for the current cap ($scope, exceeded $isExceeded, loaded $isSuccess)', + ({ scope, isExceeded, isSuccess, visible }) => { + usageGate.mockReturnValue({ isSuccess, data: { scope, isExceeded } }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + try { + act(() => + root.render( + + ) + ) + expect(container.textContent?.includes('Request increase')).toBe(visible) + } finally { + act(() => root.unmount()) + } + } + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 05e78e0d2f1..ff7515d1711 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -19,6 +19,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { requestJson } from '@/lib/api/client/request' import { createWorkflowContract } from '@/lib/api/contracts' import { @@ -91,7 +92,15 @@ interface HomeProps { userId?: string } -export function Home({ chatId, userName, userId }: HomeProps) { +export function Home(props: HomeProps) { + return ( + + + + ) +} + +function HomeContent({ chatId, userName, userId }: HomeProps) { useOAuthReturnRouter() const { workspaceId } = useParams<{ workspaceId: string }>() const router = useRouter() diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx index d7765df9eb5..51890cea304 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import type { Metadata } from 'next' import { notFound } from 'next/navigation' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { INTEGRATIONS } from '@/lib/integrations' import { IntegrationBlockDetail } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail' import { IntegrationBlockDetailFallback } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail-fallback' @@ -27,8 +28,10 @@ export default async function IntegrationBlockPage({ if (!integration) notFound() return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx index 88003c84119..4267bc40e60 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { ConnectedCredentialDetail } from '@/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail' export const metadata: Metadata = { @@ -11,5 +12,9 @@ export default async function ConnectedCredentialPage({ params: Promise<{ workspaceId: string; credentialId: string }> }) { const { workspaceId, credentialId } = await params - return + return ( + + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 3fc4ab345da..a1b1ea26755 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -13,6 +13,7 @@ import { } from '@sim/emcn' import { useParams } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { blockTypeToIconMap, formatIntegrationType, @@ -138,6 +139,14 @@ function ConnectedItem({ href, blockType, name, description, icon: Icon }: Conne } export function Integrations() { + return ( + + + + ) +} + +function IntegrationsContent() { const scrollContainerRef = useRef(null) const params = useParams() const workspaceId = (params?.workspaceId as string) || '' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx index 0753a1df61d..a49bbd1ee6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document' import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading' @@ -26,12 +27,14 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum return ( }> - + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx index 36e58dffe07..a441f86dfd8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base' import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading' @@ -22,7 +23,9 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr return ( }> - + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx index 6496a088c2e..a03d3348d72 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.test.tsx @@ -34,6 +34,12 @@ vi.mock('nuqs', () => ({ useQueryStates: () => [{ search: '', connector: [], content: [], owner: [] }, vi.fn()], })) vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({ config: {} }) })) +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + useUserPermissionConfig: () => ({ data: { config: {} }, isPending: false }), +})) +vi.mock('@/hooks/queries/access-requests', () => ({ + useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), +})) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ useUserPermissionsContext: () => mocks.permissions, })) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 530208ed9ce..2bcfc892f11 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -194,6 +195,14 @@ function connectorCell(connectorTypes?: string[]): ResourceCell { } export function Knowledge() { + return ( + + + + ) +} + +function KnowledgeContent() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 7aad80a5bca..f5080114a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -2,7 +2,11 @@ import type { QueryClient } from '@tanstack/react-query' import { listKnowledgeBasesContract } from '@/lib/api/contracts/knowledge' import { internalSessionAuth } from '@/lib/api/server/routes' import { internalKnowledgePresenters } from '@/lib/knowledge/api/internal-route' -import { listInternalKnowledgeBases } from '@/lib/knowledge/application/knowledge-bases' +import { + listInternalKnowledgeBases, + listKnowledgeBases, +} from '@/lib/knowledge/application/knowledge-bases' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' @@ -37,6 +41,7 @@ export async function prefetchKnowledgeBases( userId: string | undefined ): Promise { if (!userId) return + if (!(await authorizeResourcePrefetch(listKnowledgeBases, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts new file mode 100644 index 00000000000..ae63bece1a9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts @@ -0,0 +1,28 @@ +import { internalSessionAuth } from '@/lib/api/server/routes' +import { InternalUnauthenticatedError } from '@/lib/api/server/routes/internal-json-route' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import type { ApplicationOperation } from '@/lib/core/application/operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Prove module access through its application operation before seeding resource data or chrome. */ +export async function authorizeResourcePrefetch( + useCase: Pick< + AuthorizingUseCase, + 'authorize' + >, + workspaceId: string +): Promise { + try { + const principal = await internalSessionAuth.authenticate() + await useCase.authorize({ principal, input: { workspaceId } }) + return true + } catch (error) { + if (error instanceof InternalUnauthenticatedError) return false + if ( + error instanceof OrchestrationError && + (error.code === 'forbidden' || error.code === 'not_found' || error.code === 'unauthorized') + ) + return false + throw error + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 6bb77574bb4..fada5c1bb1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -1,10 +1,14 @@ /** * @vitest-environment node */ + import { QueryClient } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { InternalUnauthenticatedError } from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { + mockAuthorizeResource, mockAuthenticate, mockGetWorkspaceHostContextForViewer, mockGetWorkspaceMemberProfiles, @@ -21,6 +25,7 @@ const { mockListWorkspaceFileFolders, mockListWorkspaceFilesWithShares, } = vi.hoisted(() => ({ + mockAuthorizeResource: vi.fn(), mockAuthenticate: vi.fn(), mockGetWorkspaceHostContextForViewer: vi.fn(), mockGetWorkspaceMemberProfiles: vi.fn(), @@ -69,6 +74,12 @@ vi.mock('@/lib/users/queries', () => ({ vi.mock('@/lib/copilot/chat/list-mothership-chats', () => ({ listMothershipChats: mockListMothershipChats, })) +vi.mock('@/lib/table/application/tables', () => ({ + listTableDefinitionsUseCase: { authorize: mockAuthorizeResource }, +})) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { authorize: mockAuthorizeResource }, +})) vi.mock('@/lib/table/service', () => ({ listTables: mockListTables, })) @@ -85,7 +96,10 @@ vi.mock('@/lib/api/server/routes', () => ({ internalSessionAuth: { authenticate: mockAuthenticate }, })) vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - listInternalKnowledgeBases: { execute: mockListInternalKnowledgeBases }, + listKnowledgeBases: { authorize: mockAuthorizeResource }, + listInternalKnowledgeBases: { + execute: mockListInternalKnowledgeBases, + }, })) vi.mock('@/lib/knowledge/api/internal-route', () => ({ internalKnowledgePresenters: { list: mockKnowledgePresenterList }, @@ -117,6 +131,7 @@ function makeClient() { describe('workspace list prefetches', () => { beforeEach(() => { vi.clearAllMocks() + mockAuthorizeResource.mockResolvedValue(undefined) mockGetWorkspaceHostContextForViewer.mockResolvedValue({ viewer: { permission: 'admin' } }) mockListFoldersForWorkspace.mockResolvedValue([]) mockListWorkspaceFilesWithShares.mockResolvedValue([]) @@ -195,6 +210,24 @@ describe('workspace list prefetches', () => { }) }) + it.each([prefetchTables, prefetchKnowledgeBases, prefetchFilesBrowser])( + 'seeds no protected data or chrome when the module operation refuses access', + async (prefetch) => { + mockAuthorizeResource.mockRejectedValue( + new OrchestrationError('forbidden', 'Module withheld') + ) + const client = makeClient() + await prefetch(client, WORKSPACE_ID, USER_ID) + expect(client.getQueryCache().getAll()).toHaveLength(0) + expect(mockListTables).not.toHaveBeenCalled() + expect(mockListInternalKnowledgeBases).not.toHaveBeenCalled() + expect(mockListWorkspaceFilesWithShares).not.toHaveBeenCalled() + expect(mockListFoldersForWorkspace).not.toHaveBeenCalled() + expect(mockListWorkspaceFileFolders).not.toHaveBeenCalled() + expect(mockListPinnedItemsForUser).not.toHaveBeenCalled() + } + ) + describe('prefetchKnowledgeBases', () => { /** * The bases list is a protected read behind an application operation, so the prefetch runs @@ -215,7 +248,7 @@ describe('workspace list prefetches', () => { }) it('caches nothing when the session principal cannot be built', async () => { - mockAuthenticate.mockRejectedValue(new Error('Unauthorized')) + mockAuthenticate.mockRejectedValue(new InternalUnauthenticatedError()) const client = makeClient() await prefetchKnowledgeBases(client, WORKSPACE_ID, USER_ID) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx index bfd7f13b9f7..04e42904940 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -28,6 +28,9 @@ const { vi.mock('next/navigation', () => ({ notFound: mockNotFound, redirect: mockRedirect })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/components/access-requests/permission-access-boundary', () => ({ + PermissionAccessBoundary: vi.fn(() => null), +})) vi.mock('@/lib/settings/application/workspace-section-access', () => ({ authorizeWorkspaceSettingsSection: mockAuthorizeSection, })) @@ -182,6 +185,22 @@ describe('WorkspaceSettingsSectionPage', () => { expect(mockGetQueryClient).not.toHaveBeenCalled() }) + it('renders a request-only boundary without protected children or section prefetches', async () => { + mockAuthorizeSection.mockResolvedValue({ + allowed: false, + disposition: 'request-access', + configKey: 'hideApiKeysTab', + }) + + const element = await WorkspaceSettingsSectionPage(pageProps('billing')) + + expect(element.props.children.props).toEqual({ configKey: 'hideApiKeysTab' }) + expect(mockSectionPrefetch).not.toHaveBeenCalled() + expect(mockGetQueryClient).not.toHaveBeenCalled() + expect(mockGetHostContext).not.toHaveBeenCalled() + expect(mockRedirect).not.toHaveBeenCalled() + }) + it('redirects unavailable visible-catalog sections to General', async () => { mockAuthorizeSection.mockResolvedValue({ allowed: false, disposition: 'redirect-general' }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index 04ba51f88e6..97cf21c2783 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,6 +2,8 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { EmptyState } from '@/components/empty-state/empty-state' import { getOrganizationSettingsHref, UNIFIED_TO_ORGANIZATION_SECTION, @@ -54,6 +56,20 @@ export default async function WorkspaceSettingsSectionPage({ }) if (!access.allowed) { if (access.disposition === 'not-found') notFound() + if (access.disposition === 'request-access') { + return ( + + } + > + + + ) + } redirectToGeneralSettings(workspaceId) } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 6fc7c8495aa..016de4cb3e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -3,6 +3,8 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' import { usePostHog } from 'posthog-js/react' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' +import { getSettingsPermissionConfigKey } from '@/components/settings/navigation' import { useSession } from '@/lib/auth/auth-client' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { captureEvent } from '@/lib/posthog/client' @@ -128,7 +130,17 @@ interface SettingsPageProps { section: SettingsSection } -export function SettingsPage({ section }: SettingsPageProps) { +export function SettingsPage(props: SettingsPageProps) { + const configKey = getSettingsPermissionConfigKey(props.section) + if (!configKey) return + return ( + + + + ) +} + +function SettingsPageContent({ section }: SettingsPageProps) { const { data: session, isPending: sessionLoading } = useSession() const hostContext = useWorkspaceHostContext() const { billingEnabled } = useDeploymentShape() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 9f3c382c3ff..152bd121970 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import type { Metadata } from 'next' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading' import { Table } from './table' @@ -15,7 +16,9 @@ export const metadata: Metadata = { export default function TablePage() { return ( }> - + +
+ ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index a937a26e753..c9cbbe31fb3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -1,7 +1,9 @@ import type { QueryClient } from '@tanstack/react-query' +import { listTableDefinitionsUseCase } from '@/lib/table/application/tables' import { listTables } from '@/lib/table/service' import { toTableListItem } from '@/lib/table/wire' import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' +import { authorizeResourcePrefetch } from '@/app/workspace/[workspaceId]/lib/authorize-resource-prefetch' import { prefetchResourceFolders } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-folders' import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' @@ -33,6 +35,7 @@ export async function prefetchTables( if (!userId) return const hostContext = await getWorkspaceHostContextForViewer(workspaceId, userId) if (!hostContext) return + if (!(await authorizeResourcePrefetch(listTableDefinitionsUseCase, workspaceId))) return await Promise.all([ queryClient.prefetchQuery({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index b7876cd65f2..4c68bbdb44c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { PermissionAccessBoundary } from '@/components/access-requests/permission-access-boundary' import type { TableDefinition } from '@/lib/table' import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -133,6 +134,14 @@ type TableResourceItem = | { kind: 'folder'; folder: WorkflowFolder } export function Tables() { + return ( + + + + ) +} + +function TablesContent() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx new file mode 100644 index 00000000000..506d756270e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx @@ -0,0 +1,207 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { addBlock, dragBlock, discovery, toolbarState } = vi.hoisted(() => ({ + addBlock: vi.fn(), + dragBlock: vi.fn(), + discovery: vi.fn(), + toolbarState: { + expandedSections: { triggers: true, blocks: true, customBlocks: true, tools: true }, + setSectionExpanded: vi.fn(), + }, +})) + +vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) })) +vi.mock('posthog-js/react', () => ({ usePostHog: () => null })) +vi.mock('@/lib/posthog/client', () => ({ captureEvent: vi.fn() })) +vi.mock('@sim/emcn', () => ({ + Button: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => ( + + ), + chipVariants: () => '', + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + Expandable: ({ children, expanded }: { children: ReactNode; expanded: boolean }) => + expanded ? children : null, + ExpandableContent: ({ children }: { children: ReactNode }) => children, + Info: () => null, + OverflowText: ({ label }: { label: string }) => {label}, + handleKeyboardActivation: (event: React.KeyboardEvent, callback: () => void) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + callback() + } + }, +})) +vi.mock('@sim/emcn/icons', () => ({ + ChevronDown: () => null, + Lock: () => null, + Search: () => null, +})) +vi.mock('@/blocks/block-tile', () => ({ BlockTile: () => null })) +vi.mock('@/blocks/custom/build-config', () => ({ + isCustomBlockType: () => false, + buildCustomBlockConfig: vi.fn(), +})) +vi.mock('@/blocks/custom/client-overlay', () => ({ useCustomBlockOverlayVersion: () => 1 })) +vi.mock('@/blocks/custom/custom-block-icon', () => ({ getCustomBlockTile: vi.fn() })) +vi.mock('@/blocks/registry', () => ({ + getCanonicalBlocksByCategory: (category: string) => + category === 'blocks' + ? [ + { name: 'Allowed core', type: 'allowed-core' }, + { name: 'Locked core', type: 'locked-core' }, + ] + : [ + { name: 'Allowed tool', type: 'allowed-tool' }, + { name: 'Locked tool', type: 'locked-tool' }, + ], +})) +vi.mock('@/lib/workflows/triggers/trigger-utils', () => ({ + getTriggersForSidebar: () => [ + { name: 'Allowed trigger', type: 'allowed-trigger' }, + { name: 'Locked trigger', type: 'locked-trigger' }, + ], + hasTriggerCapability: () => true, +})) +vi.mock('@/ee/whitelabeling/components/branding-provider', () => ({ + useOrgBrandConfig: () => ({}), +})) +vi.mock('@/hooks/queries/custom-blocks', () => ({ useCustomBlocks: () => ({ data: [] }) })) +vi.mock('@/hooks/use-sandbox-block-constraints', () => ({ useSandboxBlockConstraints: () => null })) +vi.mock('@/hooks/use-permission-config', () => ({ + usePermissionConfig: () => ({ + filterBlocks: (items: T[]) => + items.filter((item) => !item.type.startsWith('locked-')), + isBlockRequestable: (type: string) => type.startsWith('locked-'), + }), +})) +vi.mock('@/components/access-requests/permission-access-boundary', () => ({ + useWorkspaceAccessRequestFeatures: discovery, +})) +vi.mock('@/components/access-requests/request-access-action', () => ({ + RequestAccessModal: ({ label, onClose }: { label: string; onClose: () => void }) => ( +
+ Request {label} + +
+ ), +})) +vi.mock('@/stores/panel', () => ({ + useToolbarStore: (selector: (state: typeof toolbarState) => unknown) => selector(toolbarState), +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks', + () => ({ + useToolbarItemInteractions: () => ({ handleItemClick: addBlock, handleDragStart: dragBlock }), + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/components', + () => ({ ToolbarItemContextMenu: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config', + () => ({ LoopTool: { name: 'Loop', type: 'loop' } }) +) +vi.mock( + '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config', + () => ({ ParallelTool: { name: 'Parallel', type: 'parallel' } }) +) + +import { Toolbar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar' + +describe('toolbar access requests', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + discovery.mockReturnValue({ data: { enabled: true } }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('places every enabled category above one restricted section in trigger/core/integration order', () => { + act(() => root.render()) + const sections = Array.from(container.querySelectorAll('section')) + expect(sections).toHaveLength(4) + expect(sections.at(-1)?.getAttribute('aria-label')).toBe('Access required') + expect( + Array.from(sections.at(-1)!.querySelectorAll('[role="button"]')).map((row) => row.textContent) + ).toEqual(['Locked trigger', 'Locked core', 'Locked tool']) + expect(sections.slice(0, -1).every((section) => !section.textContent?.includes('Locked'))).toBe( + true + ) + }) + + it.each(['click', 'Enter', ' '])( + 'opens a request with %s without inserting or dragging a block', + (activation) => { + act(() => root.render()) + const row = container.querySelector( + '[aria-label="Request access to Locked tool"]' + )! + expect(row.draggable).toBe(false) + act(() => { + row.dispatchEvent(new Event('dragstart', { bubbles: true })) + row.dispatchEvent( + activation === 'click' + ? new MouseEvent('click', { bubbles: true }) + : new KeyboardEvent('keydown', { key: activation, bubbles: true }) + ) + }) + expect(container.querySelector('[role="dialog"]')?.textContent).toContain( + 'Request Locked tool' + ) + expect(addBlock).not.toHaveBeenCalled() + expect(dragBlock).not.toHaveBeenCalled() + } + ) + + it('moves keyboard focus from enabled rows through the restricted section', async () => { + act(() => root.render()) + act(() => + container.querySelector('[data-toolbar-root] > [role="button"]')!.click() + ) + const input = container.querySelector('input')! + act(() => input.focus()) + const rows = Array.from( + container.querySelectorAll( + '[aria-label^="Add "], [aria-label^="Request access to "]' + ) + ) + for (const row of rows) { + act(() => + document.activeElement!.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) + ) + ) + expect(document.activeElement).toBe(row) + } + expect(document.activeElement?.getAttribute('aria-label')).toBe('Request access to Locked tool') + expect(addBlock).not.toHaveBeenCalled() + }) + + it('restores existing hiding when requests are off', () => { + discovery.mockReturnValue({ data: { enabled: false } }) + act(() => root.render()) + expect(container.textContent).not.toContain('Access required') + expect(container.textContent).not.toContain('Locked') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx index 1dd0e4634b1..a8f926f9d7b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx @@ -21,9 +21,11 @@ import { Info, OverflowText, } from '@sim/emcn' -import { ChevronDown, Search } from '@sim/emcn/icons' +import { ChevronDown, Lock, Search } from '@sim/emcn/icons' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { useWorkspaceAccessRequestFeatures } from '@/components/access-requests/permission-access-boundary' +import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { captureEvent } from '@/lib/posthog/client' import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { @@ -53,6 +55,11 @@ interface BlockItem { icon?: ComponentType<{ className?: string }> bgColor?: string docsLink?: string + restricted?: boolean +} + +interface RestrictedBlockItem extends BlockItem { + section: 'triggers' | 'blocks' | 'tools' } interface ToolbarItemProps { @@ -116,15 +123,16 @@ const ToolbarItem = memo(function ToolbarItem({
@@ -135,6 +143,7 @@ const ToolbarItem = memo(function ToolbarItem({ data-toolbar-item-icon='' /> + {item.restricted && }
) }) @@ -379,11 +388,13 @@ export const Toolbar = memo( const blockItemRefs = useRef>([]) const customBlockItemRefs = useRef>([]) const toolItemRefs = useRef>([]) + const restrictedItemRefs = useRef>([]) const triggerRefCallbacks = useRef void>>({}) const blockRefCallbacks = useRef void>>({}) const customBlockRefCallbacks = useRef void>>({}) const toolRefCallbacks = useRef void>>({}) + const restrictedRefCallbacks = useRef void>>({}) const getTriggerRefCallback = useCallback((index: number) => { if (!triggerRefCallbacks.current[index]) { @@ -421,8 +432,20 @@ export const Toolbar = memo( return toolRefCallbacks.current[index] }, []) + const getRestrictedRefCallback = (index: number) => { + if (!restrictedRefCallbacks.current[index]) { + restrictedRefCallbacks.current[index] = (el) => { + restrictedItemRefs.current[index] = el + } + } + return restrictedRefCallbacks.current[index] + } + const posthog = usePostHog() - const { filterBlocks } = usePermissionConfig() + const { filterBlocks, isBlockRequestable } = usePermissionConfig() + const accessRequests = useWorkspaceAccessRequestFeatures() + const accessRequestsEnabled = accessRequests.data?.enabled === true + const [requestedBlockType, setRequestedBlockType] = useState(null) const sandboxAllowedBlocks = useSandboxBlockConstraints() const expandedSections = useToolbarStore((state) => state.expandedSections) @@ -471,6 +494,11 @@ export const Toolbar = memo( const allTriggers = getTriggers(blockOverlayVersion) const allBlocks = getBlocks(blockOverlayVersion) const allTools = getTools(blockOverlayVersion) + const requestedBlock = requestedBlockType + ? (allTriggers.find((item) => item.type === requestedBlockType) ?? + allBlocks.find((item) => item.type === requestedBlockType) ?? + allTools.find((item) => item.type === requestedBlockType)) + : undefined // Published custom blocks are their own section. Exclude disabled blocks (still // resolvable so placed instances survive, but not offered for new placement) and @@ -502,6 +530,13 @@ export const Toolbar = memo( .sort((a, b) => a.name.localeCompare(b.name)) }, [customBlocksData, currentWorkflowId, fallbackIconUrl]) + const handleRequestItemClick = useCallback( + (type: string) => { + if (accessRequestsEnabled) setRequestedBlockType(type) + }, + [accessRequestsEnabled] + ) + const visibleTriggers = useMemo(() => { if (sandboxAllowedBlocks !== null) return [] return filterBlocks(allTriggers) @@ -525,6 +560,32 @@ export const Toolbar = memo( return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type)) }, [filterBlocks, allTools, sandboxAllowedBlocks]) + const restrictedItems = useMemo((): RestrictedBlockItem[] => { + if (!accessRequestsEnabled) return [] + const categories = [ + { section: 'triggers', items: sandboxAllowedBlocks === null ? allTriggers : [] }, + { section: 'blocks', items: allBlocks }, + { section: 'tools', items: allTools }, + ] as const + return categories.flatMap(({ section, items }) => + items + .filter( + (item) => + !isCustomBlockType(item.type) && + isBlockRequestable(item.type) && + (sandboxAllowedBlocks === null || sandboxAllowedBlocks.includes(item.type)) + ) + .map((item) => ({ ...item, restricted: true, section })) + ) + }, [ + accessRequestsEnabled, + allTriggers, + allBlocks, + allTools, + isBlockRequestable, + sandboxAllowedBlocks, + ]) + const normalizedQuery = searchQuery.trim().toLowerCase() const isSearching = normalizedQuery.length > 0 @@ -552,6 +613,11 @@ export const Toolbar = memo( return visibleTools.filter((tool) => tool.name.toLowerCase().includes(normalizedQuery)) }, [visibleTools, isSearching, normalizedQuery]) + const filteredRestrictedItems = useMemo(() => { + if (!isSearching) return restrictedItems + return restrictedItems.filter((item) => item.name.toLowerCase().includes(normalizedQuery)) + }, [restrictedItems, isSearching, normalizedQuery]) + /** * Trim ref arrays to current filtered length to prevent stale refs from * polluting keyboard navigation when items disappear (search, sandbox). @@ -560,6 +626,7 @@ export const Toolbar = memo( blockItemRefs.current.length = filteredBlocks.length customBlockItemRefs.current.length = filteredCustomBlocks.length toolItemRefs.current.length = filteredTools.length + restrictedItemRefs.current.length = filteredRestrictedItems.length /** * Section expansion is derived during search (force-expand sections with @@ -596,11 +663,11 @@ export const Toolbar = memo( * If there's a query, keep search mode active so ArrowUp/Down navigation continues * to work after focus moves into the section lists. */ - const handleSearchBlur = useCallback(() => { - if (!searchQuery.trim()) { + const handleSearchBlur = (event: React.FocusEvent) => { + if (!searchQuery.trim() && !rootRef.current?.contains(event.relatedTarget)) { setIsSearchActive(false) } - }, [searchQuery]) + } const handleItemContextMenu = useCallback( (e: React.MouseEvent, type: string, isTrigger: boolean, docsLink?: string) => { @@ -652,11 +719,11 @@ export const Toolbar = memo( }, [isContextMenuOpen, closeContextMenu]) /** - * Keyboard navigation across the three sections. + * Keyboard navigation follows visible section order, ending with access requests. * * - Active only when the toolbar tab is active and search mode is on. * - Skips collapsed or empty sections so focus only lands on visible items. - * - ArrowDown traverses search → triggers → blocks → tools. + * - ArrowDown traverses search → triggers → blocks → custom blocks → tools → access required. * - ArrowUp moves backward; from the first item of the first visible section * it wraps back to the search input. */ @@ -671,7 +738,7 @@ export const Toolbar = memo( if (!toolbarRoot || !activeEl || !toolbarRoot.contains(activeEl)) return type SectionList = { - key: ToolbarSectionKey + key: ToolbarSectionKey | 'restricted' items: HTMLDivElement[] } @@ -688,6 +755,12 @@ export const Toolbar = memo( ? blockItemRefs.current.filter((el): el is HTMLDivElement => el !== null) : [], }, + { + key: 'customBlocks', + items: sectionExpanded.customBlocks + ? customBlockItemRefs.current.filter((el): el is HTMLDivElement => el !== null) + : [], + }, { key: 'tools', items: sectionExpanded.tools @@ -695,6 +768,10 @@ export const Toolbar = memo( : [], }, ] + allSections.push({ + key: 'restricted', + items: restrictedItemRefs.current.filter((el): el is HTMLDivElement => el !== null), + }) const sections = allSections.filter((section) => section.items.length > 0) let sectionIndex = -1 @@ -769,6 +846,7 @@ export const Toolbar = memo( isSearchActive, sectionExpanded.triggers, sectionExpanded.blocks, + sectionExpanded.customBlocks, sectionExpanded.tools, ]) @@ -811,6 +889,16 @@ export const Toolbar = memo( + {requestedBlock && workspaceId && accessRequestsEnabled && ( + setRequestedBlockType(null)} + /> + )} + {/* Single scroll container with three collapsible sections */}
+ {filteredRestrictedItems.length > 0 && ( +
+
+ Access required + + Ask your organization admin to enable these blocks for your permission group. + +
+
+ {filteredRestrictedItems.map((item, index) => ( + + ))} +
+
+ )}
{/* Toolbar Item Context Menu */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 7b77fb5678b..f1790e469f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -31,6 +31,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' +import { RequestAccessModal } from '@/components/access-requests/request-access-action' import { VariableIcon } from '@/components/icons' import { ThinkingLoader } from '@/components/ui' import { requestJson } from '@/lib/api/client/request' @@ -71,6 +72,7 @@ import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId] import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { type CopilotChatListItem, @@ -219,6 +221,15 @@ export const Panel = memo(function Panel() { scope: usageLimitScope, isLoading: isUsageGateLoading, } = useUsageLimits({ workspaceId }) + const memberLimitRequest = useDiscoverAccessRequests( + { kind: 'workspace', workspaceId, targetKind: 'usage_limit', limit: 1, offset: 0 }, + usageExceeded && usageLimitScope === 'member' + ) + const [showLimitRequest, setShowLimitRequest] = useState(false) + const memberLimitTarget = + memberLimitRequest.isSuccess && memberLimitRequest.data.enabled + ? memberLimitRequest.data.entries.find((entry) => entry.state === 'requestable') + : undefined // Workflow execution hook const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() @@ -243,10 +254,19 @@ export const Panel = memo(function Panel() { /** * Runs the workflow with usage limit check */ - const runWorkflow = useCallback(async () => { + const runWorkflow = async () => { if (isUsageGateLoading) return if (usageExceeded) { + if (usageLimitScope === 'member' && memberLimitTarget) { + if (memberLimitTarget.pendingRequestId) { + const params = new URLSearchParams({ requestId: memberLimitTarget.pendingRequestId }) + router.push(`/workspace/${encodeURIComponent(workspaceId)}/access-requests?${params}`) + } else { + setShowLimitRequest(true) + } + return + } const action = getWorkspaceUsageLimitAction(hostContext, session?.user?.id, { message: usageLimitMessage, scope: usageLimitScope, @@ -259,15 +279,7 @@ export const Panel = memo(function Panel() { return } await handleRunWorkflow() - }, [ - usageExceeded, - usageLimitMessage, - usageLimitScope, - isUsageGateLoading, - hostContext, - session?.user?.id, - handleRunWorkflow, - ]) + } // Chat state const { isChatOpen, setIsChatOpen } = useChatStore( @@ -708,6 +720,14 @@ export const Panel = memo(function Panel() { return ( <> + {showLimitRequest && memberLimitTarget && ( + setShowLimitRequest(false)} + /> + )}