diff --git a/apps/sim/app/access-requests/page.test.tsx b/apps/sim/app/access-requests/page.test.tsx new file mode 100644 index 00000000000..f1f50b8cae5 --- /dev/null +++ b/apps/sim/app/access-requests/page.test.tsx @@ -0,0 +1,68 @@ +/** @vitest-environment node */ +import { authMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() })) +vi.mock('next/navigation', () => ({ redirect })) +vi.mock('@/components/access-requests/my-access-requests', () => ({ MyAccessRequests: () => null })) +vi.mock('@/components/access-requests/organization-access-requests', () => ({ + OrganizationAccessRequests: () => null, +})) + +import AccessRequestsPage from '@/app/access-requests/page' + +describe('access request sign-in redirect', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue(null) + redirect.mockImplementation(() => { + throw new Error('Redirect') + }) + }) + + it.each([ + { + organizationId: 'organization', + view: 'catalog', + requestId: 'request', + search: 'Slack & Notion', + page: '3', + }, + { + organizationId: 'organization', + view: 'admin', + requestId: 'request', + 'request-status': 'declined', + 'request-page': '2', + }, + ])('preserves the supported $view state through sign-in', async (params) => { + await expect(AccessRequestsPage({ searchParams: Promise.resolve(params) })).rejects.toThrow( + 'Redirect' + ) + const loginUrl = new URL(redirect.mock.calls[0][0], 'https://example.com') + expect(loginUrl.pathname).toBe('/login') + const callback = new URL(loginUrl.searchParams.get('callbackUrl')!, loginUrl.origin) + expect(callback.pathname).toBe('/access-requests') + expect(Object.fromEntries(callback.searchParams)).toEqual(params) + }) + + it('drops invalid and unsupported state instead of forwarding raw query parameters', async () => { + await expect( + AccessRequestsPage({ + searchParams: Promise.resolve({ + organizationId: 'organization', + view: 'invalid', + page: '40001', + search: 'x'.repeat(201), + requestId: 'x'.repeat(129), + 'request-page': '-1', + 'request-status': 'invalid', + callbackUrl: 'https://example.com/untrusted', + }), + }) + ).rejects.toThrow('Redirect') + expect(redirect).toHaveBeenCalledWith( + `/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}` + ) + }) +}) diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx index 87b5d7b55e8..a3c90a5ec65 100644 --- a/apps/sim/app/access-requests/page.tsx +++ b/apps/sim/app/access-requests/page.tsx @@ -2,7 +2,7 @@ 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 { createSearchParamsCache, createSerializer } 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' @@ -22,19 +22,16 @@ interface AccessRequestsPageProps { } const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams) +const serializeEntrySearchParams = createSerializer(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}`, + callbackUrl: serializeEntrySearchParams('/access-requests', params), isInviteFlow: false, }) ) diff --git a/apps/sim/app/api/permission-groups/user/route.test.ts b/apps/sim/app/api/permission-groups/user/route.test.ts index 688b2c47a21..38455087aea 100644 --- a/apps/sim/app/api/permission-groups/user/route.test.ts +++ b/apps/sim/app/api/permission-groups/user/route.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ session: vi.fn(), @@ -22,7 +22,10 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwne vi.mock('@/lib/billing/core/subscription', () => ({ isOrganizationOnEnterprisePlan: mocks.enterprise, })) -vi.mock('@/lib/permission-groups/resolve.server', () => ({ resolveWorkspaceGroup: mocks.group })) +vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({ + ...(await importOriginal()), + resolveWorkspaceGroup: mocks.group, +})) import { userPermissionConfigSchema } from '@/lib/api/contracts/permission-groups' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -53,6 +56,7 @@ function get(query = '?workspaceId=workspace') { beforeEach(() => { vi.clearAllMocks() + setEnvFlags({ isHosted: true, isAccessControlEnabled: true }) mocks.session.mockResolvedValue({ user: { id: 'viewer' }, session: { id: 'session', activeOrganizationId: 'unrelated-org' }, @@ -64,7 +68,40 @@ beforeEach(() => { mocks.group.mockResolvedValue(null) }) +afterEach(resetEnvFlagsMock) + describe('user permission policy shared read', () => { + it.each([ + { hosted: false, accessControl: false, entitled: false }, + { hosted: false, accessControl: true, entitled: true }, + { hosted: true, accessControl: false, entitled: true }, + ])( + 'matches the active permission regime ($hosted, $accessControl)', + async ({ hosted, accessControl, entitled }) => { + setEnvFlags({ + isHosted: hosted, + isAccessControlEnabled: accessControl, + isBillingEnabled: false, + }) + mocks.admin.mockResolvedValue(true) + const group = { + permissionGroupId: 'group', + groupName: 'Restricted', + config: { ...DEFAULT_PERMISSION_GROUP_CONFIG, hideCopilot: true }, + } + mocks.group.mockResolvedValue(group) + const expected = { ...unrestricted, ...(entitled ? group : {}), entitled, isOrgAdmin: true } + expect(await (await get()).json()).toEqual(expected) + expect( + await readUserPermissionConfig.execute({ principal, input: { workspaceId: 'workspace' } }) + ).toEqual(expected) + if (!entitled) { + expect(mocks.group).not.toHaveBeenCalled() + expect(mocks.enterprise).not.toHaveBeenCalled() + } + } + ) + it('authenticates before parsing or protected lookups', async () => { mocks.session.mockResolvedValue(null) expect((await get('')).status).toBe(401) 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 index 506d756270e..9d5ae83298f 100644 --- 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 @@ -204,4 +204,21 @@ describe('toolbar access requests', () => { expect(container.textContent).not.toContain('Access required') expect(container.textContent).not.toContain('Locked') }) + + it('does not reopen a request after requests are disabled and re-enabled', () => { + act(() => root.render()) + act(() => + container + .querySelector('[aria-label="Request access to Locked tool"]') + ?.click() + ) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + discovery.mockReturnValue({ data: { enabled: false } }) + act(() => root.render()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + discovery.mockReturnValue({ data: { enabled: true } }) + act(() => root.render()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(container.querySelector('[aria-label="Request access to Locked tool"]')).not.toBeNull() + }) }) 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 a8f926f9d7b..e4323e2c711 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 @@ -500,6 +500,13 @@ export const Toolbar = memo( allTools.find((item) => item.type === requestedBlockType)) : undefined + if ( + requestedBlockType !== null && + (!requestedBlock || !workspaceId || !accessRequestsEnabled) + ) { + setRequestedBlockType(null) + } + // Published custom blocks are their own section. Exclude disabled blocks (still // resolvable so placed instances survive, but not offered for new placement) and // the block bound to the CURRENT workflow — adding a workflow's own block recurses. 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 726bfbb02a9..8cdecd5834c 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 @@ -217,16 +217,21 @@ export const Panel = memo(function Panel() { scope: usageLimitScope, isLoading: isUsageGateLoading, } = useUsageLimits({ workspaceId }) + const isMemberLimitExceeded = usageExceeded && usageLimitScope === 'member' const memberLimitRequest = useDiscoverAccessRequests( { kind: 'workspace', workspaceId, targetKind: 'usage_limit', limit: 1, offset: 0 }, - usageExceeded && usageLimitScope === 'member' + isMemberLimitExceeded ) const [showLimitRequest, setShowLimitRequest] = useState(false) const memberLimitTarget = - memberLimitRequest.isSuccess && memberLimitRequest.data.enabled + isMemberLimitExceeded && memberLimitRequest.isSuccess && memberLimitRequest.data.enabled ? memberLimitRequest.data.entries.find((entry) => entry.state === 'requestable') : undefined + if (showLimitRequest && !memberLimitTarget) { + setShowLimitRequest(false) + } + // Workflow execution hook const { handleRunWorkflow, handleCancelExecution, isExecuting } = useWorkflowExecution() diff --git a/apps/sim/components/access-requests/access-request-review.tsx b/apps/sim/components/access-requests/access-request-review.tsx index 4e66df59260..db68369704f 100644 --- a/apps/sim/components/access-requests/access-request-review.tsx +++ b/apps/sim/components/access-requests/access-request-review.tsx @@ -118,12 +118,6 @@ export function AccessRequestReview({

{data.group?.name ?? 'No longer available'}

-

- May affect up to {data.impact.memberCount}{' '} - {data.impact.memberCount === 1 ? 'person' : 'people'} across{' '} - {data.impact.workspaceCount}{' '} - {data.impact.workspaceCount === 1 ? 'workspace' : 'workspaces'}. -

)} {pending && alreadyAvailable && ( @@ -171,7 +165,7 @@ export function AccessRequestReview({ value={newLimit} onChange={setNewLimit} required - hint='Applies to this member. Enter a whole number above their current limit.' + hint='Enter a whole number above the current limit.' /> )} diff --git a/apps/sim/components/access-requests/my-access-requests.test.tsx b/apps/sim/components/access-requests/my-access-requests.test.tsx index f60d8be370c..2913b635dd8 100644 --- a/apps/sim/components/access-requests/my-access-requests.test.tsx +++ b/apps/sim/components/access-requests/my-access-requests.test.tsx @@ -101,8 +101,28 @@ describe('compact requester history', () => { expect(mocks.mine).toHaveBeenCalledWith(scope, 50, undefined, false) expect(mocks.mine).toHaveBeenCalledWith(scope, 0, 'request') expect(mocks.discovery).toHaveBeenCalledWith( - expect.objectContaining({ search: 'slack', offset: 50 }), + expect.objectContaining({ search: 'slack', offset: 50, state: 'requestable' }), true ) }) + + it('exposes the selected view and resets pagination when switching views through nuqs', async () => { + render('?view=catalog&search=slack&page=2') + const views = container.querySelector('[role="radiogroup"][aria-label="Access request views"]') + expect(views?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe( + 'Browse access' + ) + const history = views?.querySelector('[role="radio"][value="requests"]') + expect(history).not.toBeNull() + await act(async () => history?.click()) + expect(views?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe( + 'My requests' + ) + await vi.waitFor(() => + expect(mocks.url).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?search=slack' }) + ) + ) + expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) + }) }) diff --git a/apps/sim/components/access-requests/my-access-requests.tsx b/apps/sim/components/access-requests/my-access-requests.tsx index 0e4233b5c9b..3504065a114 100644 --- a/apps/sim/components/access-requests/my-access-requests.tsx +++ b/apps/sim/components/access-requests/my-access-requests.tsx @@ -1,6 +1,6 @@ 'use client' -import { Chip, ChipInput, ChipLink, ChipTag } from '@sim/emcn' +import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn' import { Lock, Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import { MyAccessRequestDetails } from '@/components/access-requests/my-access-request-details' @@ -66,20 +66,15 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) { Your workspaces )} -
- void setParams({ view: 'requests', page: 0, requestId: null })} - > - My requests - - void setParams({ view: 'catalog', page: 0, requestId: null })} - > - Browse access - -
+ void setParams({ view: value, page: 0, requestId: null })} + /> {view === 'catalog' && ( ) : (
@@ -148,9 +143,6 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) { icon={entry.state === 'allowed' ? undefined : } iconVariant='plain' title={entry.label} - description={ - entry.reason ?? (entry.state === 'allowed' ? 'Available to you' : undefined) - } badge={ entry.state === 'allowed' ? ( Available diff --git a/apps/sim/components/access-requests/organization-access-requests.test.tsx b/apps/sim/components/access-requests/organization-access-requests.test.tsx new file mode 100644 index 00000000000..216b03924ed --- /dev/null +++ b/apps/sim/components/access-requests/organization-access-requests.test.tsx @@ -0,0 +1,145 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + settings: vi.fn(), + requests: vi.fn(), + update: vi.fn(), + mutate: vi.fn(), + refetch: vi.fn(), +})) +vi.mock('@/hooks/queries/access-requests', () => ({ + ACCESS_REQUEST_PAGE_SIZE: 25, + useAccessRequestSettings: mocks.settings, + useOrganizationAccessRequests: mocks.requests, + useUpdateAccessRequestSettings: mocks.update, +})) +vi.mock('@/components/access-requests/access-request-review', () => ({ + AccessRequestReview: () => null, +})) + +import { OrganizationAccessRequests } from '@/components/access-requests/organization-access-requests' + +describe('organization access request settings', () => { + 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) + mocks.settings.mockReturnValue({ + data: { allowRequests: true }, + isSuccess: true, + isPending: false, + isError: false, + }) + mocks.update.mockReturnValue({ mutate: mocks.mutate, isPending: false }) + mocks.requests.mockReturnValue({ + isPending: false, + isError: false, + data: { + requests: [ + { + id: 'request', + targetLabel: 'Slack', + status: 'pending', + requester: { name: 'Member' }, + createdAt: '2026-09-15T12:00:00Z', + }, + ], + hasMore: false, + }, + }) + }) + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + const render = () => + act(() => + root.render( + + + + ) + ) + + it('keeps history available without claiming requests are enabled while settings load', () => { + mocks.settings.mockReturnValue({ isPending: true }) + render() + expect(container.textContent).toContain('Loading request settings...') + expect(container.textContent).toContain('Slack') + expect(container.textContent).not.toContain('Members can ask administrators') + expect(container.querySelector('[aria-label="Allow users to request permissions"]')).toBeNull() + }) + + it('allows settings failures to be retried independently of the request history', () => { + const failed = { + isPending: false, + isError: true, + error: new Error('Settings unavailable'), + refetch: mocks.refetch, + } + mocks.settings.mockReturnValue({ ...failed, isFetching: false }) + render() + expect(container.querySelector('[role="alert"]')?.textContent).toBe('Settings unavailable') + expect(container.textContent).toContain('Slack') + expect(container.querySelector('[aria-label="Allow users to request permissions"]')).toBeNull() + const retry = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + act(() => retry?.click()) + expect(mocks.refetch).toHaveBeenCalledOnce() + mocks.settings.mockReturnValue({ ...failed, isFetching: true }) + render() + expect( + Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Retrying…' + )?.disabled + ).toBe(true) + }) + + it('uses the shared switch to pause requests and blocks repeat changes while saving', () => { + render() + const setting = container.querySelector( + '[role="radiogroup"][aria-label="Allow users to request permissions"]' + ) + expect(setting?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe( + 'Enabled' + ) + const paused = setting?.querySelector('[role="radio"][value="paused"]') + expect(paused).not.toBeNull() + act(() => paused?.click()) + expect(mocks.mutate).toHaveBeenCalledWith( + false, + expect.objectContaining({ onError: expect.any(Function) }) + ) + mocks.update.mockReturnValue({ mutate: mocks.mutate, isPending: true }) + render() + expect( + Array.from(setting!.querySelectorAll('[role="radio"]')).every( + (button) => button.disabled + ) + ).toBe(true) + act(() => paused?.click()) + expect(mocks.mutate).toHaveBeenCalledOnce() + mocks.update.mockReturnValue({ mutate: mocks.mutate, isPending: false }) + mocks.settings.mockReturnValue({ + data: { allowRequests: false }, + isPending: false, + isError: false, + }) + render() + expect(setting?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe( + 'Paused' + ) + expect(container.textContent).toContain('New requests and approvals are paused.') + }) +}) diff --git a/apps/sim/components/access-requests/organization-access-requests.tsx b/apps/sim/components/access-requests/organization-access-requests.tsx index 6e32bfc480e..a37570b024d 100644 --- a/apps/sim/components/access-requests/organization-access-requests.tsx +++ b/apps/sim/components/access-requests/organization-access-requests.tsx @@ -1,6 +1,6 @@ 'use client' -import { Chip, ChipDropdown, ChipTag, Switch, toast } from '@sim/emcn' +import { Chip, ChipDropdown, ChipSwitch, ChipTag, toast } from '@sim/emcn' import { useQueryStates } from 'nuqs' import { AccessRequestReview } from '@/components/access-requests/access-request-review' import { @@ -9,6 +9,10 @@ import { } from '@/components/access-requests/search-params' import { ACCESS_REQUEST_STATUS_LABELS } from '@/components/access-requests/status' import { EmptyState } from '@/components/empty-state/empty-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { RESOURCE_LIST_STACK, SettingsResourceRow, @@ -44,30 +48,42 @@ export function OrganizationAccessRequests({ return (
- Loading request settings... + ) : settings.isError ? ( + void settings.refetch()} + /> + ) : ( + - updateSettings.mutate(checked, { + size='compact' + options={[ + { value: 'enabled', label: 'Enabled' }, + { value: 'paused', label: 'Paused' }, + ]} + value={settings.data.allowRequests ? 'enabled' : 'paused'} + onChange={(value) => + updateSettings.mutate(value === 'enabled', { onError: (error) => toast.error(error.message), }) } disabled={updateSettings.isPending} /> - ) : undefined - } - /> - {settings.isError && ( -

{settings.error.message}

+ } + /> )}

Requests

diff --git a/apps/sim/components/access-requests/permission-access-boundary.test.tsx b/apps/sim/components/access-requests/permission-access-boundary.test.tsx index 269c079a692..6dc658227b3 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.test.tsx +++ b/apps/sim/components/access-requests/permission-access-boundary.test.tsx @@ -158,4 +158,26 @@ describe('PermissionAccessBoundary', () => { expect(refresh).toHaveBeenCalledOnce() expect(protectedMount).not.toHaveBeenCalled() }) + + it('only reports a permissions refresh while the policy query is fetching', () => { + const refetch = vi.fn() + const blockedPolicy = { data: { config: { hideTablesTab: true } }, isPending: false, refetch } + policy.mockReturnValue({ ...blockedPolicy, isFetching: false }) + discovery.mockReturnValue({ + isPending: false, + data: { + enabled: true, + entries: [{ target: { kind: 'feature', configKey: 'hideTablesTab' }, state: 'allowed' }], + }, + }) + render() + expect(container.textContent).toContain('Refresh to load your latest permissions.') + expect(container.textContent).not.toContain('Refreshing your permissions...') + act(() => container.querySelector('button')?.click()) + expect(refetch).toHaveBeenCalledOnce() + policy.mockReturnValue({ ...blockedPolicy, isFetching: true }) + render() + expect(container.textContent).toContain('Refreshing your permissions...') + expect(protectedMount).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/components/access-requests/permission-access-boundary.tsx b/apps/sim/components/access-requests/permission-access-boundary.tsx index 2b11bc50a1d..bc111f3e694 100644 --- a/apps/sim/components/access-requests/permission-access-boundary.tsx +++ b/apps/sim/components/access-requests/permission-access-boundary.tsx @@ -93,7 +93,11 @@ export function PermissionAccessBoundary({ configKey, children }: PermissionAcce return ( void policy.refetch()}>Refresh access} /> ) diff --git a/apps/sim/components/access-requests/policy-changes.test.ts b/apps/sim/components/access-requests/policy-changes.test.ts index 61369e8f2a2..ace9e0ae6c6 100644 --- a/apps/sim/components/access-requests/policy-changes.test.ts +++ b/apps/sim/components/access-requests/policy-changes.test.ts @@ -2,7 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { describePolicyChange } from '@/components/access-requests/policy-changes' +import { + describePolicyChange, + describePolicyValue, +} from '@/components/access-requests/policy-changes' const target = { kind: 'integration', id: 'slack_v2' } as const @@ -68,7 +71,7 @@ describe('permission change summaries', () => { target, 'Slack' ) - ).toBe('Allow Slack; Remove github_v2') + ).toBe('Allow Slack; Remove GitHub') }) it('distinguishes unrestricted and empty allowlists', () => { expect( @@ -108,3 +111,93 @@ describe('permission change summaries', () => { ).toBe('Restricted → Allowed') }) }) + +describe('permission change details', () => { + it('uses canonical names for every integration and preserves the original snapshot', () => { + const values = ['github_v2', 'notion_v2', 'slack_v2', 'loop', 'parallel'] + const before = structuredClone(values) + expect(describePolicyValue(values, 'allowedIntegrations', target, 'Slack')).toBe( + 'GitHub, Notion, Slack, Loop, Parallel' + ) + expect(values).toEqual(before) + }) + + it('collapses only equivalent integration aliases in both lists and change summaries', () => { + expect( + describePolicyValue( + ['GitHub', 'github_v2', 'notion', 'notion_v2'], + 'allowedIntegrations', + target, + 'Slack' + ) + ).toBe('GitHub, Notion') + expect( + describePolicyChange( + { + configKey: 'allowedIntegrations', + label: 'Integrations', + before: ['github', 'notion'], + after: ['github_v2', 'notion_v2', 'slack_v2'], + }, + target, + 'Slack' + ) + ).toBe('Allow Slack') + expect( + describePolicyChange( + { + configKey: 'allowedIntegrations', + label: 'Integrations', + before: ['slack'], + after: ['slack_v2'], + }, + target, + 'Slack' + ) + ).toBe('No membership change') + }) + + it('retains unknown IDs exactly and does not read inherited object properties', () => { + expect( + describePolicyValue( + ['Unknown_Integration_v2', 'constructor', 'toString', '__proto__'], + 'allowedIntegrations', + target, + 'Slack' + ) + ).toBe('Unknown_Integration_v2, constructor, toString, __proto__') + }) + + it('uses the request label for an integration outside the built-in registry', () => { + expect( + describePolicyValue( + ['Custom_Integration'], + 'allowedIntegrations', + { kind: 'integration', id: 'custom_integration' }, + 'Custom integration' + ) + ).toBe('Custom integration') + }) + + it('keeps meaningful model and tool versions distinct', () => { + expect(describePolicyValue(['model_v1', 'model_v2'], 'deniedModels', target, 'Slack')).toBe( + 'model_v1, model_v2' + ) + expect(describePolicyValue(['tool_v1', 'tool_v2'], 'deniedTools', target, 'Slack')).toBe( + 'tool_v1, tool_v2' + ) + }) + + it('preserves the difference between unrestricted, empty, and boolean values', () => { + expect(describePolicyValue(null, 'allowedIntegrations', target, 'Slack')).toBe('All allowed') + expect(describePolicyValue([], 'allowedIntegrations', target, 'Slack')).toBe('None') + expect( + describePolicyValue( + true, + 'hideTablesTab', + { kind: 'feature', configKey: 'hideTablesTab' }, + 'Tables' + ) + ).toBe('Restricted') + }) +}) diff --git a/apps/sim/components/access-requests/policy-changes.tsx b/apps/sim/components/access-requests/policy-changes.tsx index be4cd439dae..ac7955c8f14 100644 --- a/apps/sim/components/access-requests/policy-changes.tsx +++ b/apps/sim/components/access-requests/policy-changes.tsx @@ -7,7 +7,9 @@ import type { AccessRequestPreviewResponse, AccessRequestTarget, } from '@/lib/api/contracts/access-requests' +import { BLOCK_NAMES } from '@/lib/permission-groups/block-names.generated' import { PERMISSION_GROUP_FIELDS } from '@/lib/permission-groups/fields' +import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist' interface PolicyChangesProps { changes: AccessRequestPolicyChange[] @@ -16,10 +18,44 @@ interface PolicyChangesProps { targetLabel: string } -function describePolicyValue(value: AccessRequestPolicyChange['before']): string { +function describePolicyItems( + values: string[], + configKey: AccessRequestPolicyChange['configKey'], + target: AccessRequestTarget, + targetLabel: string +): ReadonlyMap { + const items = new Map() + const integrationTarget = + target.kind === 'integration' + ? resolveAccessControlBlockType(target.id.toLowerCase()).toLowerCase() + : null + for (const value of values) { + if (configKey === 'allowedIntegrations') { + const canonical = resolveAccessControlBlockType(value.toLowerCase()).toLowerCase() + const label = Object.hasOwn(BLOCK_NAMES, canonical) + ? BLOCK_NAMES[canonical]! + : canonical === integrationTarget + ? targetLabel + : value + items.set(canonical, label) + } else { + items.set(value, target.kind !== 'feature' && value === target.id ? targetLabel : value) + } + } + return items +} + +export function describePolicyValue( + value: AccessRequestPolicyChange['before'], + configKey: AccessRequestPolicyChange['configKey'], + target: AccessRequestTarget, + targetLabel: string +): string { if (value === null) return 'All allowed' if (typeof value === 'boolean') return value ? 'Restricted' : 'Allowed' - return value.length ? value.join(', ') : 'None' + return value.length + ? [...describePolicyItems(value, configKey, target, targetLabel).values()].join(', ') + : 'None' } export function describePolicyChange( @@ -28,24 +64,22 @@ export function describePolicyChange( targetLabel: string ): string { const { before, after } = change - if (typeof after === 'boolean') - return `${describePolicyValue(before)} → ${describePolicyValue(after)}` + const describe = (value: AccessRequestPolicyChange['before']) => + describePolicyValue(value, change.configKey, target, targetLabel) + if (typeof after === 'boolean') return `${describe(before)} → ${describe(after)}` if (after === null) return 'Allow all' - const label = (value: string) => - target.kind !== 'feature' && value === target.id ? targetLabel : value + const next = describePolicyItems(after, change.configKey, target, targetLabel) if (before === null) - return after.length ? `Allow only ${after.map(label).join(', ')}` : 'Allow none' - if (!Array.isArray(before)) - return `${describePolicyValue(before)} → ${describePolicyValue(after)}` - const previous = new Set(before) - const next = new Set(after) - const added = after.filter((value) => !previous.has(value)) - const removed = before.filter((value) => !next.has(value)) + return next.size ? `Allow only ${[...next.values()].join(', ')}` : 'Allow none' + if (!Array.isArray(before)) return `${describe(before)} → ${describe(after)}` + const previous = describePolicyItems(before, change.configKey, target, targetLabel) + const added = [...next].filter(([id]) => !previous.has(id)).map(([, label]) => label) + const removed = [...previous].filter(([id]) => !next.has(id)).map(([, label]) => label) const denylist = PERMISSION_GROUP_FIELDS[change.configKey].kind === 'denylist' return ( [ - added.length ? `${denylist ? 'Block' : 'Allow'} ${added.map(label).join(', ')}` : '', - removed.length ? `${denylist ? 'Unblock' : 'Remove'} ${removed.map(label).join(', ')}` : '', + added.length ? `${denylist ? 'Block' : 'Allow'} ${added.join(', ')}` : '', + removed.length ? `${denylist ? 'Unblock' : 'Remove'} ${removed.join(', ')}` : '', ] .filter(Boolean) .join('; ') || 'No membership change' @@ -70,42 +104,44 @@ export function PolicyChanges({ changes, impact, target, targetLabel }: PolicyCh } > -
    - {changes.map((change) => ( -
  • - {change.label}: - {describePolicyChange(change, target, targetLabel)} -
  • - ))} -
- - -
- {impact.workspaceNames.length > 0 && ( - -

- {impact.workspaceNames.join(', ')} - {impact.truncated ? ' and more' : ''} -

-
- )} - {changes.map((change) => ( - -
-
Before
-
- {describePolicyValue(change.before)} -
-
After
-
- {describePolicyValue(change.after)} -
-
-
- ))} -
-
-
+
+
    + {changes.map((change) => ( +
  • + {change.label}: + {describePolicyChange(change, target, targetLabel)} +
  • + ))} +
+ + +
+ {impact.workspaceNames.length > 0 && ( + +

+ {impact.workspaceNames.join(', ')} + {impact.truncated ? ' and more' : ''} +

+
+ )} + {changes.map((change) => ( + +
+
Before
+
+ {describePolicyValue(change.before, change.configKey, target, targetLabel)} +
+
After
+
+ {describePolicyValue(change.after, change.configKey, target, targetLabel)} +
+
+
+ ))} +
+
+
+
) } diff --git a/apps/sim/components/access-requests/request-access-action.test.tsx b/apps/sim/components/access-requests/request-access-action.test.tsx index c3cd76eb3af..8a4263d68ff 100644 --- a/apps/sim/components/access-requests/request-access-action.test.tsx +++ b/apps/sim/components/access-requests/request-access-action.test.tsx @@ -7,8 +7,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { RequestAccessAction } from '@/components/access-requests/request-access-action' import type { AccessRequestTarget } from '@/lib/api/contracts/access-requests' +const mocks = vi.hoisted(() => ({ + discovery: vi.fn(), + create: vi.fn(), + push: vi.fn(), + refetch: vi.fn(), +})) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + usePathname: () => '/workspace/workspace', +})) vi.mock('@/hooks/queries/access-requests', () => ({ - useCreateAccessRequest: () => ({ mutate: vi.fn(), isPending: false, error: null }), + useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }), + useDiscoverAccessRequests: mocks.discovery, })) const scope = { kind: 'workspace', workspaceId: 'workspace' } as const @@ -18,6 +29,16 @@ describe('request form lifecycle', () => { let container: HTMLDivElement let root: Root beforeEach(() => { + vi.clearAllMocks() + mocks.discovery.mockReturnValue({ + isSuccess: true, + isPending: false, + isError: false, + data: { + enabled: true, + entries: [{ target: tables, state: 'requestable', pendingRequestId: null }], + }, + }) ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) @@ -57,4 +78,165 @@ describe('request form lifecycle', () => { render(undefined, { kind: 'feature', configKey: 'hideKnowledgeBaseTab' }) expect(document.querySelector('[role="dialog"]')).toBeNull() }) + const clickDialogButton = (label: string) => { + const button = Array.from( + document.querySelectorAll('[role="dialog"] button') + ).find((button) => button.textContent === label) + expect(button).toBeDefined() + act(() => button!.click()) + } + + it('does no target discovery until opened and looks up only the chosen target', () => { + render() + expect(mocks.discovery).not.toHaveBeenCalled() + open() + expect(mocks.discovery).toHaveBeenCalledWith({ + ...scope, + targetKind: 'feature', + targetKey: 'feature:hideTablesTab', + limit: 1, + offset: 0, + }) + expect(document.querySelector('textarea')).not.toBeNull() + clickDialogButton('Send request') + expect(mocks.create).toHaveBeenCalledWith( + { scope, target: tables, reason: '' }, + expect.any(Object) + ) + }) + + it('opens the existing pending request without allowing another reason or submission', () => { + mocks.discovery.mockReturnValue({ + isSuccess: true, + data: { + enabled: true, + entries: [{ state: 'requestable', pendingRequestId: 'pending/request' }], + }, + }) + render() + open() + expect(document.querySelector('textarea')).toBeNull() + clickDialogButton('View request') + expect(mocks.push).toHaveBeenCalledWith( + '/workspace/workspace/access-requests?requestId=pending%2Frequest' + ) + expect(mocks.create).not.toHaveBeenCalled() + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('routes a pending member limit request in its organization scope', () => { + const organizationScope = { kind: 'organization', organizationId: 'organization' } as const + const limit = { kind: 'usage_limit', id: 'member' } as const + mocks.discovery.mockReturnValue({ + isSuccess: true, + data: { + enabled: true, + entries: [{ state: 'requestable', pendingRequestId: 'limit-request' }], + }, + }) + act(() => + root.render( + + ) + ) + open() + expect(mocks.discovery).toHaveBeenCalledWith({ + ...organizationScope, + targetKind: 'usage_limit', + targetKey: 'usage_limit:member', + limit: 1, + offset: 0, + }) + clickDialogButton('View request') + expect(mocks.push).toHaveBeenCalledWith( + '/access-requests?requestId=limit-request&organizationId=organization' + ) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('blocks submission until discovery completes', () => { + mocks.discovery.mockReturnValue({ isPending: true }) + render() + open() + clickDialogButton('Loading...') + expect(document.querySelector('textarea')).toBeNull() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('offers retry instead of using cached eligibility after a failed refresh', () => { + mocks.discovery.mockReturnValue({ + isSuccess: false, + isError: true, + error: new Error('Access check failed'), + refetch: mocks.refetch, + data: { enabled: true, entries: [{ state: 'requestable', pendingRequestId: null }] }, + }) + render() + open() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Access check failed') + expect(document.querySelector('textarea')).toBeNull() + clickDialogButton('Retry') + expect(mocks.refetch).toHaveBeenCalledOnce() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it.each([ + { enabled: false, entries: [] }, + { enabled: true, entries: [] }, + { + enabled: true, + entries: [ + { state: 'allowed', reason: 'Access is already available.', pendingRequestId: null }, + ], + }, + { + enabled: true, + entries: [{ state: 'unavailable', reason: 'Access is unavailable.', pendingRequestId: null }], + }, + ])('does not submit when the target is not requestable: %j', (data) => { + mocks.discovery.mockReturnValue({ isSuccess: true, data }) + render() + open() + clickDialogButton('Send request') + expect(document.querySelector('textarea')).toBeNull() + expect(mocks.create).not.toHaveBeenCalled() + }) + it('preserves the caller navigation handler for a newly discovered pending request', () => { + const onViewRequest = vi.fn() + mocks.discovery.mockReturnValue({ + isSuccess: true, + data: { enabled: true, entries: [{ state: 'requestable', pendingRequestId: 'existing' }] }, + }) + act(() => + root.render( + + ) + ) + open() + clickDialogButton('View request') + expect(onViewRequest).toHaveBeenCalledWith('existing') + expect(mocks.push).not.toHaveBeenCalled() + }) + + it('presents newly granted access as a neutral result', () => { + mocks.discovery.mockReturnValue({ + isSuccess: true, + data: { + enabled: true, + entries: [{ state: 'allowed', reason: null, pendingRequestId: null }], + }, + }) + render() + open() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Access is already available.' + ) + expect(document.querySelector('[role="alert"]')).toBeNull() + expect(document.querySelector('textarea')).toBeNull() + }) }) diff --git a/apps/sim/components/access-requests/request-access-action.tsx b/apps/sim/components/access-requests/request-access-action.tsx index 162e6cbe72a..34e5703d77f 100644 --- a/apps/sim/components/access-requests/request-access-action.tsx +++ b/apps/sim/components/access-requests/request-access-action.tsx @@ -6,6 +6,7 @@ import { ChipLink, ChipModal, ChipModalBody, + ChipModalDescription, ChipModalError, ChipModalField, ChipModalFooter, @@ -14,8 +15,10 @@ import { toast, } from '@sim/emcn' import { Lock } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import { useCreateAccessRequest } from '@/hooks/queries/access-requests' +import { getAccessRequestTargetKey } from '@/lib/permission-groups/access-requests/targets' +import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' interface RequestAccessActionProps { scope: AccessRequestScope @@ -26,6 +29,16 @@ interface RequestAccessActionProps { variant?: ChipProps['variant'] } +function accessRequestHref(scope: AccessRequestScope, requestId: string): string { + const params = new URLSearchParams({ requestId }) + if (scope.kind === 'organization') params.set('organizationId', scope.organizationId) + const pathname = + scope.kind === 'workspace' + ? `/workspace/${encodeURIComponent(scope.workspaceId)}/access-requests` + : '/access-requests' + return `${pathname}?${params}` +} + export function RequestAccessAction({ scope, target, @@ -47,16 +60,10 @@ export function RequestAccessAction({ ) } - const params = new URLSearchParams({ requestId: pendingRequestId }) - if (scope.kind === 'organization') params.set('organizationId', scope.organizationId) - const pathname = - scope.kind === 'workspace' - ? `/workspace/${encodeURIComponent(scope.workspaceId)}/access-requests` - : '/access-requests' return ( @@ -71,12 +78,19 @@ export function RequestAccessAction({ scope={scope} target={target} label={label} + onViewRequest={onViewRequest} variant={variant} /> ) } -function RequestableAccessAction({ scope, target, label, variant }: RequestAccessActionProps) { +function RequestableAccessAction({ + scope, + target, + label, + variant, + onViewRequest, +}: RequestAccessActionProps) { const [open, setOpen] = useState(false) return ( @@ -98,6 +112,7 @@ function RequestableAccessAction({ scope, target, label, variant }: RequestAcces scope={scope} target={target} label={label} + onViewRequest={onViewRequest} onClose={() => setOpen(false)} /> )} @@ -106,17 +121,40 @@ function RequestableAccessAction({ scope, target, label, variant }: RequestAcces } interface RequestAccessModalProps - extends Pick { + extends Pick { onClose: () => void } -export function RequestAccessModal({ scope, target, label, onClose }: RequestAccessModalProps) { +export function RequestAccessModal({ + scope, + target, + label, + onViewRequest, + onClose, +}: RequestAccessModalProps) { + const router = useRouter() + const discovery = useDiscoverAccessRequests({ + ...scope, + targetKind: target.kind, + targetKey: getAccessRequestTargetKey(target), + limit: 1, + offset: 0, + }) const [reason, setReason] = useState('') const createRequest = useCreateAccessRequest() const usageLimitRequest = target.kind === 'usage_limit' const title = usageLimitRequest ? 'Request a higher credit limit' : 'Request access' + const entry = discovery.isSuccess ? discovery.data.entries[0] : undefined + const pendingRequestId = entry?.pendingRequestId + const canRequest = discovery.data?.enabled && entry?.state === 'requestable' && !pendingRequestId + const alreadyAllowed = entry?.state === 'allowed' + const unavailableReason = + discovery.isSuccess && !pendingRequestId && !canRequest && !alreadyAllowed + ? (entry?.reason ?? 'Access requests are unavailable.') + : null const submit = () => { + if (!canRequest || createRequest.isPending) return createRequest.mutate( { scope, target, reason: reason.trim() }, { @@ -147,23 +185,51 @@ export function RequestAccessModal({ scope, target, label, onClose }: RequestAcc

{label}

- - {createRequest.error?.message} + {alreadyAllowed && ( + Access is already available. + )} + {canRequest && ( + + )} + + {discovery.error?.message ?? unavailableReason ?? createRequest.error?.message} + { + void discovery.refetch() + } + : pendingRequestId + ? () => { + if (onViewRequest) onViewRequest(pendingRequestId) + else router.push(accessRequestHref(scope, pendingRequestId)) + onClose() + } + : submit, + disabled: + createRequest.isPending || + discovery.isPending || + (!discovery.isError && !pendingRequestId && !canRequest), }} /> diff --git a/apps/sim/components/access-requests/search-params.ts b/apps/sim/components/access-requests/search-params.ts index c7399f75154..afc9c44f46a 100644 --- a/apps/sim/components/access-requests/search-params.ts +++ b/apps/sim/components/access-requests/search-params.ts @@ -53,7 +53,9 @@ export const accessReviewSearchParams = { export const accessRequestUrlOptions = { history: 'replace', clearOnDefault: true } as const export const accessRequestEntrySearchParams = { + ...accessRequestSearchParams, organizationId: accessRequestIdParser, view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'), - requestId: accessRequestIdParser, + 'request-page': accessReviewSearchParams['request-page'], + 'request-status': accessReviewSearchParams['request-status'], } as const diff --git a/apps/sim/hooks/queries/access-requests.test.tsx b/apps/sim/hooks/queries/access-requests.test.tsx index 12c27aca857..21d97bf12b7 100644 --- a/apps/sim/hooks/queries/access-requests.test.tsx +++ b/apps/sim/hooks/queries/access-requests.test.tsx @@ -20,6 +20,7 @@ import { useResolveAccessRequest, } from '@/hooks/queries/access-requests' import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' describe('access request query lifecycle', () => { @@ -207,10 +208,19 @@ describe('access request query lifecycle', () => { let resolve: ReturnType const creditKey = workspaceUsageKeys.creditAvailability('workspace-1') const gateKey = workspaceUsageKeys.gate('workspace-1') + const memberLimitKey = organizationKeys.memberUsageLimit('org-1', 'member-1') + const otherMemberLimitKey = organizationKeys.memberUsageLimit('org-1', 'member-2') client.setQueryData(creditKey, { remainingDollars: 0 }) client.setQueryData(gateKey, { isExceeded: true }) + client.setQueryData(memberLimitKey, { usageLimit: 10 }) + client.setQueryData(otherMemberLimitKey, { usageLimit: 20 }) requestJson.mockResolvedValue({ - request: { status: 'fulfilled', target: { kind: 'usage_limit', id: 'member' } }, + request: { + status: 'fulfilled', + target: { kind: 'usage_limit', id: 'member' }, + organizationId: 'org-1', + requester: { id: 'member-1' }, + }, }) function Probe() { resolve = useResolveAccessRequest() @@ -232,6 +242,8 @@ describe('access request query lifecycle', () => { }) expect(client.getQueryState(creditKey)?.isInvalidated).toBe(true) expect(client.getQueryState(gateKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(memberLimitKey)?.isInvalidated).toBe(true) + expect(client.getQueryState(otherMemberLimitKey)?.isInvalidated).toBe(false) }) it('does not fetch history while its view is inactive', async () => { diff --git a/apps/sim/hooks/queries/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts index 4f9339037ad..2c1b6d3a38a 100644 --- a/apps/sim/hooks/queries/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -28,6 +28,7 @@ import { accessRequestKeys, } from '@/hooks/queries/utils/access-request-keys' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' +import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' @@ -190,6 +191,9 @@ export function useResolveAccessRequest() { if (request.status !== 'fulfilled') return if (request.target.kind === 'usage_limit') { void invalidateWorkspaceUsage(queryClient) + void queryClient.invalidateQueries({ + queryKey: organizationKeys.memberUsageLimit(request.organizationId, request.requester.id), + }) } else { void queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all }) } diff --git a/apps/sim/lib/api/contracts/access-requests.test.ts b/apps/sim/lib/api/contracts/access-requests.test.ts index 089922ee042..22c27d5fedc 100644 --- a/apps/sim/lib/api/contracts/access-requests.test.ts +++ b/apps/sim/lib/api/contracts/access-requests.test.ts @@ -65,6 +65,8 @@ describe('access request contracts', () => { { limit: 0 }, { offset: -1 }, { search: 'a'.repeat(201) }, + { targetKey: '' }, + { targetKey: 'a'.repeat(2049) }, ]) { expect(discoverAccessRequestsQuerySchema.safeParse({ ...scope, ...override }).success).toBe( false diff --git a/apps/sim/lib/api/contracts/access-requests.ts b/apps/sim/lib/api/contracts/access-requests.ts index ee9b7b6cdc2..c9294e73dea 100644 --- a/apps/sim/lib/api/contracts/access-requests.ts +++ b/apps/sim/lib/api/contracts/access-requests.ts @@ -64,6 +64,7 @@ const discoveryShape = { ...paginationShape, search: z.string().trim().max(ACCESS_REQUEST_MAX_SEARCH_LENGTH).optional(), targetKind: z.enum(ACCESS_REQUEST_TARGET_KINDS).optional(), + targetKey: z.string().min(1, 'Target key cannot be empty').max(2048).optional(), state: z.enum(['allowed', 'requestable', 'unavailable']).optional(), } diff --git a/apps/sim/lib/permission-access-requests/application/requests.test.ts b/apps/sim/lib/permission-access-requests/application/requests.test.ts index f764e7bad9a..616c518ca57 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.test.ts +++ b/apps/sim/lib/permission-access-requests/application/requests.test.ts @@ -418,6 +418,54 @@ describe('create access requests', () => { }) describe('discovery and request history', () => { + it('finds an exact target beyond the first catalog page', async () => { + const integrations = Array.from({ length: 125 }, (_, index) => ({ + id: `integration-${index}`, + label: `Integration ${index}`, + })) + mocks.catalog.mockResolvedValue( + createAccessRequestCatalog({ + integrations, + providers: [], + models: [], + tools: [], + knowledgeConnectors: [], + }) + ) + mocks.targets.mockReturnValue(integrations.map(({ id }) => ({ kind: 'integration', id }))) + mocks.group.mockResolvedValue({ + ...group, + config: { ...group.config, allowedIntegrations: [] }, + }) + const selected = { kind: 'integration', id: 'integration-124' } as const + queueTableRows(permissionAccessRequest, [ + stored({ target: selected, targetKey: 'integration:integration-124' }), + ]) + const result = await discoverAccessRequests.execute({ + principal, + input: { + ...scope, + targetKind: 'integration', + targetKey: 'integration:integration-124', + limit: 1, + offset: 0, + }, + }) + expect(result).toMatchObject({ + total: 1, + hasMore: false, + entries: [{ target: selected, state: 'requestable', pendingRequestId: 'request' }], + }) + }) + + it('does not substitute another target when an exact target is unavailable', async () => { + const result = await discoverAccessRequests.execute({ + principal, + input: { ...scope, targetKey: 'integration:missing', limit: 1, offset: 0 }, + }) + expect(result).toMatchObject({ total: 0, hasMore: false, entries: [] }) + }) + it('filters requestable state before pagination and reports pending request IDs', async () => { mocks.targets.mockReturnValue([ { kind: 'feature', configKey: 'hideKnowledgeBaseTab' }, diff --git a/apps/sim/lib/permission-access-requests/application/requests.ts b/apps/sim/lib/permission-access-requests/application/requests.ts index efaaf7622fc..e3e023492b3 100644 --- a/apps/sim/lib/permission-access-requests/application/requests.ts +++ b/apps/sim/lib/permission-access-requests/application/requests.ts @@ -131,6 +131,7 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({ description && (description.scope === 'workspace-or-organization' || description.scope === input.kind) && (!input.targetKind || input.targetKind === target.kind) && + (!input.targetKey || input.targetKey === getAccessRequestTargetKey(target)) && (!search || description.label.toLowerCase().includes(search)) ) }) @@ -153,7 +154,8 @@ export const discoverAccessRequests = defineAuthorizedAccessRequestUseCase({ eq(permissionAccessRequest.scopeKey, accessRequestScopeKey(input)), eq(permissionAccessRequest.scopeKey, memberLimitScopeKey(organizationId)) ), - eq(permissionAccessRequest.status, 'pending') + eq(permissionAccessRequest.status, 'pending'), + input.targetKey ? eq(permissionAccessRequest.targetKey, input.targetKey) : undefined ) ) .limit(ACCESS_REQUEST_MAX_PENDING) diff --git a/apps/sim/lib/permission-access-requests/catalog.test.ts b/apps/sim/lib/permission-access-requests/catalog.test.ts index cb156e9300e..ad85beb35d3 100644 --- a/apps/sim/lib/permission-access-requests/catalog.test.ts +++ b/apps/sim/lib/permission-access-requests/catalog.test.ts @@ -99,7 +99,12 @@ import { listAccessRequestTargets, loadAccessRequestCatalog, } from '@/lib/permission-access-requests/catalog' +import { + buildAccessRequestPolicyDelta, + validateAccessRequestTarget, +} from '@/lib/permission-groups/access-requests/targets' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' const context = { userId: 'viewer', organizationId: 'org', workspaceId: 'ws' } @@ -197,6 +202,22 @@ describe('access request catalog deployment ceilings', () => { expect((await loadAccessRequestCatalog(context)).integrations.size).toBe(0) }) + it('enforces deployment ceilings without removing unrelated stored grants from an approval', async () => { + mocks.allowedIntegrations.mockReturnValue(['slack']) + const catalog = await loadAccessRequestCatalog(context, 'integration') + expect( + validateAccessRequestTarget({ kind: 'integration', id: 'github_v2' }, catalog) + ).toBeNull() + const config = { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: ['github_v2'] } + const delta = buildAccessRequestPolicyDelta( + { kind: 'integration', id: 'slack_v2' }, + config, + catalog + ) + expect(delta.config.allowedIntegrations).toEqual(['github_v2', 'slack_v2']) + expect(config.allowedIntegrations).toEqual(['github_v2']) + }) + it('omits blacklisted/retired models, unconfigured endpoints, and private dynamic names', async () => { const catalog = await loadAccessRequestCatalog(context) expect([...catalog.providers.keys()]).toEqual(['openai', 'openrouter', 'fireworks']) diff --git a/apps/sim/lib/permission-access-requests/schemas.test.ts b/apps/sim/lib/permission-access-requests/schemas.test.ts new file mode 100644 index 00000000000..d98cf87162a --- /dev/null +++ b/apps/sim/lib/permission-access-requests/schemas.test.ts @@ -0,0 +1,66 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { storedAccessRequestPolicyChangeSchema } from '@/lib/permission-access-requests/schemas' +import { + DEFAULT_PERMISSION_GROUP_CONFIG, + PERMISSION_GROUP_FIELDS, +} from '@/lib/permission-groups/fields' + +describe('stored access request policy changes', () => { + it('accepts unchanged canonical values for every field', () => { + for (const configKey of Object.keys( + PERMISSION_GROUP_FIELDS + ) as (keyof typeof PERMISSION_GROUP_FIELDS)[]) { + const value = DEFAULT_PERMISSION_GROUP_CONFIG[configKey] + expect( + storedAccessRequestPolicyChangeSchema.parse({ + configKey, + label: configKey, + before: value, + after: value, + }) + ).toEqual({ configKey, label: configKey, before: value, after: value }) + } + }) + + it.each([ + { configKey: 'hideCopilot', before: true, after: false }, + { configKey: 'allowedIntegrations', before: ['github_v2'], after: ['github_v2', 'slack_v2'] }, + { + configKey: 'allowedFileShareAuthTypes', + before: null, + after: ['public', 'password', 'email', 'sso'], + }, + { configKey: 'allowedChatDeployAuthTypes', before: [], after: null }, + { configKey: 'deniedTools', before: ['tool'], after: [] }, + ])('preserves valid snapshots for $configKey', (change) => { + expect(storedAccessRequestPolicyChangeSchema.parse({ ...change, label: 'Access' })).toEqual({ + ...change, + label: 'Access', + }) + }) + + it.each([ + { configKey: 'hideCopilot', valid: false, invalid: ['public'] }, + { configKey: 'allowedIntegrations', valid: null, invalid: false }, + { configKey: 'allowedFileShareAuthTypes', valid: ['sso'], invalid: ['invented'] }, + { configKey: 'allowedChatDeployAuthTypes', valid: null, invalid: ['invented'] }, + { configKey: 'deniedTools', valid: [], invalid: null }, + { configKey: 'deniedModels', valid: [], invalid: true }, + ])('rejects invalid before and after values for $configKey', ({ configKey, valid, invalid }) => { + for (const side of ['before', 'after'] as const) { + const result = storedAccessRequestPolicyChangeSchema.safeParse({ + configKey, + label: 'Access', + before: valid, + after: valid, + [side]: invalid, + }) + expect(result.success).toBe(false) + if (!result.success) + expect(result.error.issues).toEqual([expect.objectContaining({ path: [side] })]) + } + }) +}) diff --git a/apps/sim/lib/permission-access-requests/schemas.ts b/apps/sim/lib/permission-access-requests/schemas.ts index b3dcc467c7d..5e17319cd38 100644 --- a/apps/sim/lib/permission-access-requests/schemas.ts +++ b/apps/sim/lib/permission-access-requests/schemas.ts @@ -28,14 +28,27 @@ export const storedAccessRequestPolicyValueSchema = z.union([ PERMISSION_GROUP_FIELDS.allowedIntegrations.readSchema, ]) -export const storedAccessRequestPolicyChangeSchema = z.object({ - configKey: z.enum( - Object.keys(PERMISSION_GROUP_FIELDS) as (keyof typeof PERMISSION_GROUP_FIELDS)[] - ), - label: z.string().min(1).max(512), - before: storedAccessRequestPolicyValueSchema, - after: storedAccessRequestPolicyValueSchema, -}) +export const storedAccessRequestPolicyChangeSchema = z + .object({ + configKey: z.enum( + Object.keys(PERMISSION_GROUP_FIELDS) as (keyof typeof PERMISSION_GROUP_FIELDS)[] + ), + label: z.string().min(1).max(512), + before: storedAccessRequestPolicyValueSchema, + after: storedAccessRequestPolicyValueSchema, + }) + .superRefine((change, context) => { + const schema = PERMISSION_GROUP_FIELDS[change.configKey].readSchema + for (const side of ['before', 'after'] as const) { + if (!schema.safeParse(change[side]).success) { + context.addIssue({ + code: 'custom', + path: [side], + message: `Invalid ${side} value for ${change.configKey}`, + }) + } + } + }) export const storedAccessRequestDecisionSchema = z.object({ resolutionKind: z.enum(['permission', 'usage_limit']), diff --git a/apps/sim/lib/permission-access-requests/types.ts b/apps/sim/lib/permission-access-requests/types.ts index d8324a42c52..9acb355d778 100644 --- a/apps/sim/lib/permission-access-requests/types.ts +++ b/apps/sim/lib/permission-access-requests/types.ts @@ -42,6 +42,7 @@ export type DiscoverAccessRequestsInput = AccessRequestScope & { offset: number search?: string targetKind?: AccessRequestTarget['kind'] + targetKey?: string state?: 'allowed' | 'requestable' | 'unavailable' } diff --git a/apps/sim/lib/permission-groups/application/read-user-config.ts b/apps/sim/lib/permission-groups/application/read-user-config.ts index 4ceca105cfd..b2a7776aebd 100644 --- a/apps/sim/lib/permission-groups/application/read-user-config.ts +++ b/apps/sim/lib/permission-groups/application/read-user-config.ts @@ -1,7 +1,9 @@ -import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' -import { resolveWorkspaceGroup } from '@/lib/permission-groups/resolve.server' +import { + isOrganizationPermissionRegimeActive, + resolveWorkspaceGroup, +} from '@/lib/permission-groups/resolve.server' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' @@ -26,7 +28,7 @@ export const readUserPermissionConfig = defineAuthorizedWorkspaceUseCase({ const [isOrgAdmin, entitled] = organizationId ? await Promise.all([ isOrganizationAdminOrOwner(principal.userId, organizationId), - isOrganizationOnEnterprisePlan(organizationId, 'throw'), + isOrganizationPermissionRegimeActive(organizationId), ]) : [false, false] const resolved = diff --git a/apps/sim/lib/permission-groups/block-names.generated.ts b/apps/sim/lib/permission-groups/block-names.generated.ts new file mode 100644 index 00000000000..4e788fe2f7f --- /dev/null +++ b/apps/sim/lib/permission-groups/block-names.generated.ts @@ -0,0 +1,364 @@ +/** + * Generated by `bun run generate:block-successors` from the block registry. + * Display-only metadata; keeps block implementations out of permission previews. + */ +export const BLOCK_NAMES: Readonly> = { + a2a: 'A2A', + affinity: 'Affinity', + agent: 'Agent', + agentmail: 'AgentMail', + agentphone: 'AgentPhone', + agiloft: 'Agiloft', + ahrefs: 'Ahrefs', + airtable: 'Airtable', + airweave: 'Airweave', + algolia: 'Algolia', + amplitude: 'Amplitude', + api: 'API', + api_trigger: 'API (Legacy)', + apify: 'Apify', + apollo: 'Apollo', + appconfig: 'AWS AppConfig', + arxiv: 'ArXiv', + asana: 'Asana', + ashby: 'Ashby', + athena: 'Athena', + attio: 'Attio', + azure_data_explorer: 'Azure Data Explorer', + azure_devops: 'Azure DevOps', + bitbucket: 'Bitbucket', + box: 'Box (Legacy)', + box_v2: 'Box', + brandfetch: 'Brandfetch', + brex: 'Brex', + brightdata: 'Bright Data', + browser_use: 'Browser Use', + buffer: 'Buffer', + calcom: 'Cal.com', + calendly: 'Calendly', + cbinsights: 'CB Insights', + chat_trigger: 'Chat', + circleback: 'Circleback', + clay: 'Clay', + clerk: 'Clerk', + clickhouse: 'ClickHouse', + clickup: 'ClickUp', + cloudflare: 'Cloudflare', + cloudformation: 'CloudFormation', + cloudtrail: 'CloudTrail', + cloudwatch: 'CloudWatch', + codepipeline: 'CodePipeline', + condition: 'Condition', + confluence: 'Confluence (Legacy)', + confluence_v2: 'Confluence', + context_dev: 'Context.dev', + convex: 'Convex', + credential: 'Credential', + credential_group: 'Connected Accounts (Legacy)', + crowdstrike: 'CrowdStrike', + crunchbase: 'Crunchbase', + cursor: 'Cursor (Legacy)', + cursor_v2: 'Cursor', + dagster: 'Dagster', + databricks: 'Databricks', + datadog: 'Datadog', + datagma: 'Datagma', + daytona: 'Daytona', + deployments: 'Deployments', + devin: 'Devin', + discord: 'Discord', + docusign: 'DocuSign', + downdetector: 'Downdetector', + dropbox: 'Dropbox (Legacy)', + dropbox_v2: 'Dropbox', + dropcontact: 'Dropcontact', + dspy: 'DSPy', + dub: 'Dub (Legacy)', + dub_v2: 'Dub', + duckduckgo: 'DuckDuckGo', + dynamodb: 'Amazon DynamoDB', + dynatrace: 'Dynatrace', + elasticsearch: 'Elasticsearch', + elevenlabs: 'ElevenLabs', + emailbison: 'Email Bison', + embeddings: 'Embeddings', + enrich: 'Enrich', + enrichment: 'Data Enrichment', + enrow: 'Enrow', + evaluator: 'Evaluator', + exa: 'Exa', + extend: 'Extend', + extend_v2: 'Extend', + fathom: 'Fathom', + file: 'File (Legacy)', + file_v2: 'File (Legacy)', + file_v3: 'File', + file_v4: 'File (Legacy)', + file_v5: 'File', + findymail: 'Findymail', + firecrawl: 'Firecrawl', + fireflies: 'Fireflies (Legacy)', + fireflies_v2: 'Fireflies', + flint: 'Flint', + function: 'Function', + gamma: 'Gamma', + generic_webhook: 'Webhook Trigger', + github: 'GitHub (Legacy)', + github_v2: 'GitHub', + gitlab: 'GitLab', + gmail: 'Gmail (Legacy)', + gmail_v2: 'Gmail', + gong: 'Gong', + google_ads: 'Google Ads', + google_appsheet: 'Google AppSheet', + google_bigquery: 'Google BigQuery', + google_books: 'Google Books', + google_calendar: 'Google Calendar (Legacy)', + google_calendar_v2: 'Google Calendar', + google_contacts: 'Google Contacts', + google_docs: 'Google Docs', + google_drive: 'Google Drive', + google_forms: 'Google Forms', + google_groups: 'Google Groups', + google_maps: 'Google Maps', + google_meet: 'Google Meet', + google_pagespeed: 'Google PageSpeed', + google_search: 'Google Search', + google_sheets: 'Google Sheets (Legacy)', + google_sheets_v2: 'Google Sheets', + google_slides: 'Google Slides (Legacy)', + google_slides_v2: 'Google Slides', + google_tasks: 'Google Tasks', + google_translate: 'Google Translate', + google_vault: 'Google Vault', + grafana: 'Grafana', + grain: 'Grain', + grain_v2: 'Grain', + granola: 'Granola', + greenhouse: 'Greenhouse', + greptile: 'Greptile', + guardrails: 'Guardrails', + harmonic: 'Harmonic', + hex: 'Hex', + hubspot: 'HubSpot', + huggingface: 'Hugging Face', + human_in_the_loop: 'Human', + human_in_the_loop_v2: 'Human', + hunter: 'Hunter.io', + iam: 'AWS IAM', + icypeas: 'Icypeas', + identity_center: 'AWS Identity Center', + image_generator: 'Image Generator', + image_generator_v2: 'Image Generator', + imap: 'IMAP Email', + incidentio: 'incident.io', + infisical: 'Infisical', + input_trigger: 'Input Form (Legacy)', + instagram: 'Instagram', + instantly: 'Instantly', + intercom: 'Intercom (Legacy)', + intercom_v2: 'Intercom', + jina: 'Jina', + jira: 'Jira', + jira_service_management: 'Jira Service Management', + jotform: 'Jotform', + jupyter: 'Jupyter (Legacy)', + jupyter_v2: 'Jupyter', + kalshi: 'Kalshi (Legacy)', + kalshi_v2: 'Kalshi', + ketch: 'Ketch', + knowledge: 'Knowledge', + lambda: 'Lambda', + langsmith: 'LangSmith', + latex: 'LaTeX', + launchdarkly: 'LaunchDarkly', + leadmagic: 'LeadMagic', + lemlist: 'Lemlist', + linear: 'Linear (Legacy)', + linear_v2: 'Linear', + linkedin: 'LinkedIn', + linkup: 'Linkup', + linq: 'Linq', + logfire: 'Logfire', + logrocket: 'LogRocket', + logs: 'Logs', + logs_v2: 'Logs', + loop: 'Loop', + loops: 'Loops', + luma: 'Luma', + mailchimp: 'Mailchimp', + mailgun: 'Mailgun', + managed_agent: 'Claude Managed Agents', + manageengine_sdp: 'ManageEngine ServiceDesk Plus', + manual_trigger: 'Manual (Legacy)', + mcp: 'MCP', + mem0: 'Mem0', + memory: 'Memory', + microsoft_ad: 'Azure AD', + microsoft_dataverse: 'Microsoft Dataverse (Legacy)', + microsoft_dataverse_v2: 'Microsoft Dataverse', + microsoft_dynamics_365: 'Microsoft Dynamics 365 CRM', + microsoft_excel: 'Microsoft Excel (Legacy)', + microsoft_excel_v2: 'Microsoft Excel', + microsoft_planner: 'Microsoft Planner', + microsoft_teams: 'Microsoft Teams', + microsoft_word: 'Microsoft Word', + millionverifier: 'MillionVerifier', + mintlify: 'Mintlify', + mistral_parse: 'Mistral Parser (Legacy)', + mistral_parse_v2: 'Mistral Parser', + mistral_parse_v3: 'Mistral Parser', + modal: 'Modal', + monday: 'Monday', + mongodb: 'MongoDB', + mothership: 'Sim Chat', + mssql: 'Microsoft SQL Server', + mysql: 'MySQL', + neo4j: 'Neo4j', + netsuite: 'Oracle NetSuite', + neverbounce: 'NeverBounce', + new_relic: 'New Relic', + note: 'Note', + notion: 'Notion (Legacy)', + notion_v2: 'Notion', + obsidian: 'Obsidian', + okta: 'Okta', + onedrive: 'OneDrive', + onepassword: '1Password', + openai: 'Embeddings', + outlook: 'Outlook', + pagerduty: 'PagerDuty', + parallel: 'Parallel', + parallel_ai: 'Parallel AI', + peopledatalabs: 'People Data Labs', + perplexity: 'Perplexity', + persona: 'Persona', + pi: 'Pi Coding Agent', + pinecone: 'Pinecone', + pipedrive: 'Pipedrive', + pitchbook: 'PitchBook', + polymarket: 'Polymarket', + postgresql: 'PostgreSQL', + posthog: 'PostHog', + profound: 'Profound', + prospeo: 'Prospeo', + pulse: 'Pulse', + pulse_v2: 'Pulse', + qdrant: 'Qdrant', + quartr: 'Quartr', + quickbooks: 'QuickBooks', + quiver: 'Quiver (Legacy)', + quiver_v2: 'Quiver', + rabbitmq: 'RabbitMQ', + railway: 'Railway', + rb2b: 'RB2B', + rds: 'Amazon RDS', + reddit: 'Reddit', + redis: 'Redis', + reducto: 'Reducto', + reducto_v2: 'Reducto', + resend: 'Resend', + response: 'Response', + revenuecat: 'RevenueCat', + rippling: 'Rippling', + rocketlane: 'Rocketlane', + rootly: 'Rootly', + router: 'Router (Legacy)', + router_v2: 'Router', + rss: 'RSS Feed', + s3: 'S3', + sailpoint: 'SailPoint', + salesforce: 'Salesforce', + sap_concur: 'SAP Concur', + sap_s4hana: 'SAP S4HANA', + schedule: 'Schedule', + search: 'Search', + secrets_manager: 'AWS Secrets Manager', + semrush: 'Semrush', + sendblue: 'Sendblue', + sendgrid: 'SendGrid', + sentry: 'Sentry', + serper: 'Serper', + servicenow: 'ServiceNow (Legacy)', + servicenow_v2: 'ServiceNow', + ses: 'AWS SES', + sftp: 'SFTP (Legacy)', + sftp_v2: 'SFTP', + sharepoint: 'Sharepoint', + sharepoint_v2: 'SharePoint', + shopify: 'Shopify', + sim_workspace_event: 'Sim Workspace Events', + similarweb: 'Similarweb', + sixtyfour: 'Sixtyfour AI', + slack: 'Slack', + slack_v2: 'Slack', + smartlead: 'Smartlead', + smtp: 'SMTP', + snowflake: 'Snowflake', + splunk: 'Splunk', + sportmonks: 'Sportmonks', + spotify: 'Spotify', + sqs: 'Amazon SQS', + square: 'Square', + ssh: 'SSH (Legacy)', + ssh_v2: 'SSH', + ssm: 'AWS Systems Manager', + stagehand: 'Stagehand', + start_trigger: 'Start', + starter: 'Starter', + stripe: 'Stripe', + sts: 'AWS STS', + stt: 'Speech-to-Text', + stt_v2: 'Speech-to-Text', + supabase: 'Supabase', + table: 'Table', + table_v2: 'Table', + tailscale: 'Tailscale', + tavily: 'Tavily', + telegram: 'Telegram', + temporal: 'Temporal', + textract: 'AWS Textract', + textract_v2: 'AWS Textract', + thinking: 'Thinking', + thrive: 'Thrive', + tiktok: 'TikTok', + tinybird: 'Tinybird', + tinyfish: 'TinyFish', + translate: 'Translate', + trello: 'Trello', + trigger_dev: 'Trigger.dev', + tts: 'Text-to-Speech', + twilio_sms: 'Twilio SMS', + twilio_voice: 'Twilio Voice', + typeform: 'Typeform', + upstash: 'Upstash', + uptimerobot: 'UptimeRobot', + vanta: 'Vanta', + variables: 'Variables', + vercel: 'Vercel', + video_generator: 'Video Generator (Legacy)', + video_generator_v2: 'Video Generator', + video_generator_v3: 'Video Generator', + vision: 'Vision (Legacy)', + vision_v2: 'Vision', + wait: 'Wait', + wealthbox: 'Wealthbox', + webflow: 'Webflow', + webhook_request: 'Webhook', + whatsapp: 'WhatsApp', + wikipedia: 'Wikipedia', + windchill: 'Windchill', + wiza: 'Wiza', + wordpress: 'WordPress', + workday: 'Workday', + workflow: 'Workflow', + workflow_input: 'Workflow', + x: 'X', + youtube: 'YouTube', + zendesk: 'Zendesk', + zep: 'Zep', + zerobounce: 'ZeroBounce', + zoho_desk: 'Zoho Desk', + zoom: 'Zoom', + zoominfo: 'ZoomInfo', +} diff --git a/scripts/generate-block-successors.ts b/scripts/generate-block-successors.ts index 5a60ddaa047..04217e4f355 100644 --- a/scripts/generate-block-successors.ts +++ b/scripts/generate-block-successors.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * Generates the access-control successor map from the block registry. + * Generates access-control successors and display names from the block registry. * * The map answers one question — "which block type is an allowlist decision * about this id really made against?" — and it has to be answerable from @@ -27,9 +27,11 @@ import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/permission-groups/block-successors.generated.ts') +const NAMES_OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/permission-groups/block-names.generated.ts') const CHECK_MODE = process.argv.includes('--check') interface SunsetBlock { + name: string sunset?: { status: string; replacedBy?: string } } @@ -43,7 +45,7 @@ interface SunsetBlock { */ async function loadRegistry(): Promise> { const { BLOCK_REGISTRY } = await import('../apps/sim/blocks/registry-maps') - return BLOCK_REGISTRY as unknown as Record + return BLOCK_REGISTRY } /** @@ -137,22 +139,40 @@ async function main(): Promise { } } - const generated = formatGeneratedSource(render(successors), OUTPUT_PATH, ROOT) - - if (CHECK_MODE) { - const current = await readFile(OUTPUT_PATH, 'utf8').catch(() => '') - if (current !== generated) { - console.error( - 'Block successor map is stale. Run `bun run generate:block-successors` and commit the result.' - ) - process.exit(1) + const registry = await loadRegistry() + const names: Array<[string, string]> = [ + ['loop', 'Loop'], + ['parallel', 'Parallel'], + ...Object.entries(registry).map(([type, block]): [string, string] => [type, block.name]), + ] + names.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + const artifacts = [ + { path: OUTPUT_PATH, source: render(successors) }, + { + path: NAMES_OUTPUT_PATH, + source: `/** + * Generated by \`bun run generate:block-successors\` from the block registry. + * Display-only metadata; keeps block implementations out of permission previews. + */ +export const BLOCK_NAMES: Readonly> = ${JSON.stringify(Object.fromEntries(names), null, 2)}\n`, + }, + ] + + for (const artifact of artifacts) { + const generated = formatGeneratedSource(artifact.source, artifact.path, ROOT) + if (CHECK_MODE) { + const current = await readFile(artifact.path, 'utf8').catch(() => '') + if (current !== generated) { + throw new Error( + `${artifact.path} is stale. Run \`bun run generate:block-successors\` and commit the result.` + ) + } + } else { + await writeFile(artifact.path, generated) + process.stdout.write(`Generated ${artifact.path}\n`) } - process.stdout.write('Block successor map is current.\n') - return } - - await writeFile(OUTPUT_PATH, generated) - process.stdout.write(`Generated ${OUTPUT_PATH}\n`) + if (CHECK_MODE) process.stdout.write('Block access metadata is current.\n') } if (import.meta.main) await main()