From 46453a27ef70928ae9017992d8aab3f08e497409 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 17:01:39 -0700 Subject: [PATCH 1/3] feat(access-requests): request and review permission access --- apps/sim/.env.example | 1 + apps/sim/app/access-requests/loading.tsx | 5 + apps/sim/app/access-requests/page.tsx | 70 + .../[requestId]/cancel/route.ts | 23 + .../api/access-requests/discovery/route.ts | 22 + apps/sim/app/api/access-requests/route.ts | 47 + .../[requestId]/preview/route.ts | 22 + .../[requestId]/resolve/route.ts | 27 + .../[id]/access-requests/route.ts | 22 + .../[id]/access-requests/settings/route.ts | 41 + .../app/api/webhooks/outbox/process/route.ts | 2 + .../[workspaceId]/access-requests/loading.tsx | 5 + .../[workspaceId]/access-requests/page.tsx | 19 + .../workspace/[workspaceId]/files/files.tsx | 9 + .../components/special-tags/special-tags.tsx | 10 +- .../app/workspace/[workspaceId]/home/home.tsx | 11 +- .../integrations/integrations.tsx | 9 + .../knowledge/[id]/[documentId]/page.tsx | 15 +- .../[workspaceId]/knowledge/[id]/page.tsx | 5 +- .../[workspaceId]/knowledge/knowledge.tsx | 9 + .../settings/[section]/page.test.tsx | 19 + .../[workspaceId]/settings/[section]/page.tsx | 8 + .../settings/[section]/settings.tsx | 14 +- .../[workspaceId]/tables/[tableId]/page.tsx | 5 +- .../workspace/[workspaceId]/tables/tables.tsx | 9 + .../panel/components/toolbar/toolbar.test.tsx | 207 + .../panel/components/toolbar/toolbar.tsx | 137 +- .../w/[workflowId]/components/panel/panel.tsx | 34 +- .../components/search-modal/search-modal.tsx | 12 +- .../settings-sidebar/settings-sidebar.tsx | 38 +- .../sidebar-nav-chip/sidebar-nav-chip.tsx | 4 + .../workspace-header/workspace-header.tsx | 27 +- .../w/components/sidebar/sidebar.tsx | 37 +- .../access-request-review.test.tsx | 181 + .../access-requests/access-request-review.tsx | 248 + .../access-requests-loading.tsx | 12 + .../member-limit-request-action.tsx | 29 + .../my-access-request-details.tsx | 111 + .../my-access-requests.test.tsx | 108 + .../access-requests/my-access-requests.tsx | 204 + .../organization-access-requests.tsx | 153 + .../permission-access-boundary.test.tsx | 139 + .../permission-access-boundary.tsx | 131 + .../access-requests/policy-changes.test.ts | 110 + .../access-requests/policy-changes.tsx | 111 + .../request-access-action.test.tsx | 60 + .../access-requests/request-access-action.tsx | 163 + .../access-requests/search-params.test.ts | 24 + .../access-requests/search-params.ts | 59 + apps/sim/components/access-requests/status.ts | 9 + .../components/emails/notifications/index.ts | 1 + .../permission-access-request-email.tsx | 39 + .../emails/render-notifications.test.ts | 18 + apps/sim/components/emails/render.ts | 8 + apps/sim/components/emails/subjects.ts | 6 + apps/sim/components/settings/navigation.ts | 7 +- .../components/access-control.test.tsx | 9 +- .../components/access-control.tsx | 35 +- .../hooks/queries/access-requests.test.tsx | 255 + apps/sim/hooks/queries/access-requests.ts | 230 + apps/sim/hooks/use-permission-config.test.tsx | 35 +- apps/sim/hooks/use-permission-config.ts | 34 + .../lib/api/contracts/access-requests.test.ts | 133 + apps/sim/lib/api/contracts/access-requests.ts | 317 + .../organization-authorization.test.ts | 43 + .../application/organization-authorization.ts | 33 +- apps/sim/lib/core/config/env.ts | 1 + .../sim/lib/core/config/feature-flags.test.ts | 26 +- apps/sim/lib/core/config/feature-flags.ts | 5 + .../lib/permission-access-requests/README.md | 32 + .../application/authorization.test.ts | 235 + .../application/authorization.ts | 152 + .../application/authorized-use-case.test.ts | 219 + .../application/authorized-use-case.ts | 113 + .../application/operations.ts | 66 + .../application/prepare.ts | 37 + .../application/requests.test.ts | 493 + .../application/requests.ts | 501 + .../application/review.test.ts | 471 + .../application/review.ts | 397 + .../catalog-registry.ts | 152 + .../catalog.test.ts | 229 + .../lib/permission-access-requests/catalog.ts | 82 + .../permission-access-requests/constants.ts | 4 + .../impact.postgres.test.ts | 103 + .../lib/permission-access-requests/impact.ts | 136 + .../notification-events.ts | 6 + .../notifications.test.ts | 388 + .../notifications.ts | 210 + .../lib/permission-access-requests/policy.ts | 122 + .../permission-access-requests/repository.ts | 90 + .../lib/permission-access-requests/schemas.ts | 57 + .../settings.test.ts | 70 + .../permission-access-requests/settings.ts | 25 + .../lib/permission-access-requests/types.ts | 82 + .../access-requests/targets.test.ts | 333 + .../access-requests/targets.ts | 446 + .../lib/permission-groups/resolve.server.ts | 9 +- .../workspace-section-access.test.ts | 56 + .../application/workspace-section-access.ts | 44 +- packages/audit/src/types.ts | 7 + .../0349_permission_access_requests.sql | 39 + .../db/migrations/meta/0349_snapshot.json | 27032 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + ...access-requests-migration.postgres.test.ts | 108 + packages/db/schema.ts | 64 + packages/testing/src/mocks/audit.mock.ts | 7 + packages/testing/src/mocks/schema.mock.ts | 35 + ...check-tool-registry-boundary.baseline.json | 118 +- 109 files changed, 36648 insertions(+), 141 deletions(-) create mode 100644 apps/sim/app/access-requests/loading.tsx create mode 100644 apps/sim/app/access-requests/page.tsx create mode 100644 apps/sim/app/api/access-requests/[requestId]/cancel/route.ts create mode 100644 apps/sim/app/api/access-requests/discovery/route.ts create mode 100644 apps/sim/app/api/access-requests/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/access-requests/[requestId]/preview/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/access-requests/[requestId]/resolve/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/access-requests/route.ts create mode 100644 apps/sim/app/api/organizations/[id]/access-requests/settings/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/access-requests/loading.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.test.tsx create mode 100644 apps/sim/components/access-requests/access-request-review.test.tsx create mode 100644 apps/sim/components/access-requests/access-request-review.tsx create mode 100644 apps/sim/components/access-requests/access-requests-loading.tsx create mode 100644 apps/sim/components/access-requests/member-limit-request-action.tsx create mode 100644 apps/sim/components/access-requests/my-access-request-details.tsx create mode 100644 apps/sim/components/access-requests/my-access-requests.test.tsx create mode 100644 apps/sim/components/access-requests/my-access-requests.tsx create mode 100644 apps/sim/components/access-requests/organization-access-requests.tsx create mode 100644 apps/sim/components/access-requests/permission-access-boundary.test.tsx create mode 100644 apps/sim/components/access-requests/permission-access-boundary.tsx create mode 100644 apps/sim/components/access-requests/policy-changes.test.ts create mode 100644 apps/sim/components/access-requests/policy-changes.tsx create mode 100644 apps/sim/components/access-requests/request-access-action.test.tsx create mode 100644 apps/sim/components/access-requests/request-access-action.tsx create mode 100644 apps/sim/components/access-requests/search-params.test.ts create mode 100644 apps/sim/components/access-requests/search-params.ts create mode 100644 apps/sim/components/access-requests/status.ts create mode 100644 apps/sim/components/emails/notifications/permission-access-request-email.tsx create mode 100644 apps/sim/hooks/queries/access-requests.test.tsx create mode 100644 apps/sim/hooks/queries/access-requests.ts create mode 100644 apps/sim/lib/api/contracts/access-requests.test.ts create mode 100644 apps/sim/lib/api/contracts/access-requests.ts create mode 100644 apps/sim/lib/permission-access-requests/README.md create mode 100644 apps/sim/lib/permission-access-requests/application/authorization.test.ts create mode 100644 apps/sim/lib/permission-access-requests/application/authorization.ts create mode 100644 apps/sim/lib/permission-access-requests/application/authorized-use-case.test.ts create mode 100644 apps/sim/lib/permission-access-requests/application/authorized-use-case.ts create mode 100644 apps/sim/lib/permission-access-requests/application/operations.ts create mode 100644 apps/sim/lib/permission-access-requests/application/prepare.ts create mode 100644 apps/sim/lib/permission-access-requests/application/requests.test.ts create mode 100644 apps/sim/lib/permission-access-requests/application/requests.ts create mode 100644 apps/sim/lib/permission-access-requests/application/review.test.ts create mode 100644 apps/sim/lib/permission-access-requests/application/review.ts create mode 100644 apps/sim/lib/permission-access-requests/catalog-registry.ts create mode 100644 apps/sim/lib/permission-access-requests/catalog.test.ts create mode 100644 apps/sim/lib/permission-access-requests/catalog.ts create mode 100644 apps/sim/lib/permission-access-requests/constants.ts create mode 100644 apps/sim/lib/permission-access-requests/impact.postgres.test.ts create mode 100644 apps/sim/lib/permission-access-requests/impact.ts create mode 100644 apps/sim/lib/permission-access-requests/notification-events.ts create mode 100644 apps/sim/lib/permission-access-requests/notifications.test.ts create mode 100644 apps/sim/lib/permission-access-requests/notifications.ts create mode 100644 apps/sim/lib/permission-access-requests/policy.ts create mode 100644 apps/sim/lib/permission-access-requests/repository.ts create mode 100644 apps/sim/lib/permission-access-requests/schemas.ts create mode 100644 apps/sim/lib/permission-access-requests/settings.test.ts create mode 100644 apps/sim/lib/permission-access-requests/settings.ts create mode 100644 apps/sim/lib/permission-access-requests/types.ts create mode 100644 apps/sim/lib/permission-groups/access-requests/targets.test.ts create mode 100644 apps/sim/lib/permission-groups/access-requests/targets.ts create mode 100644 packages/db/migrations/0349_permission_access_requests.sql create mode 100644 packages/db/migrations/meta/0349_snapshot.json create mode 100644 packages/db/permission-access-requests-migration.postgres.test.ts 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/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..059679fd8d3 --- /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]/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]/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..6742581ee63 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' @@ -3225,7 +3226,14 @@ function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) { {hosted ? : } ) : ( -

{unavailableMessage}

+
+

{unavailableMessage}

+ {data.action === 'increase_limit' && ( + + )} +
)} ) 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/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.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]/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..f6093d2d912 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -2,6 +2,7 @@ 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 { getOrganizationSettingsHref, UNIFIED_TO_ORGANIZATION_SECTION, @@ -54,6 +55,13 @@ 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/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..e98d1135449 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,14 @@ 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.data?.enabled + ? memberLimitRequest.data.entries.find((entry) => entry.state === 'requestable') + : undefined // Workflow execution hook const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() @@ -243,10 +253,14 @@ 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) { + setShowLimitRequest(true) + return + } const action = getWorkspaceUsageLimitAction(hostContext, session?.user?.id, { message: usageLimitMessage, scope: usageLimitScope, @@ -259,15 +273,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 +714,14 @@ export const Panel = memo(function Panel() { return ( <> + {showLimitRequest && memberLimitTarget && ( + setShowLimitRequest(false)} + /> + )}