diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx index bca8ec035cd..b0d7accfba9 100644 --- a/apps/sim/app/access-requests/page.tsx +++ b/apps/sim/app/access-requests/page.tsx @@ -41,7 +41,7 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest return ( Your workspaces} /> ) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx index 0d1077648d7..57c3d94e6e1 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx @@ -110,12 +110,18 @@ async function selectSettings() { } describe('OrganizationFooter settings navigation', () => { - it('keeps only Settings and Sign out in the organization profile menu', async () => { + it('offers personal request history in the organization profile menu', async () => { await openProfileMenu() expect( [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent) - ).toEqual(['Settings', 'Sign out']) + ).toEqual(['Settings', 'My access requests', 'Sign out']) expect(document.querySelector('[role="separator"]')).toBeNull() + const requests = document.querySelector( + 'a[href="/access-requests?organizationId=org-1"]' + ) + expect(requests).not.toBeNull() + await act(async () => requests!.click()) + expect(mockPush).toHaveBeenCalledWith('/access-requests?organizationId=org-1') }) it('navigates immediately when settings are clean', async () => { diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx index 125ab4202af..4c8b101c3d6 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx @@ -1,10 +1,15 @@ 'use client' import type { ComponentProps } from 'react' +import { ListChecks } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' +import { createSerializer } from 'nuqs/server' import { organizationRoutes } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer' +import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params' + +const serializeAccessRequestParams = createSerializer(accessRequestEntrySearchParams) interface OrganizationFooterProps extends Omit< @@ -16,13 +21,23 @@ export function OrganizationFooter(props: OrganizationFooterProps) { const { organization } = useOrganizationContext() const router = useRouter() const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general') + const accessRequestsHref = serializeAccessRequestParams('/access-requests', { + organizationId: organization.id, + }) return ( router.push(accountSettingsHref)} - navigationLinks={[]} + navigationLinks={[ + { + label: 'My access requests', + icon: ListChecks, + href: accessRequestsHref, + onNavigate: () => router.push(accessRequestsHref), + }, + ]} /> ) } diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx new file mode 100644 index 00000000000..1df25578cfc --- /dev/null +++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.test.tsx @@ -0,0 +1,78 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), authorize: vi.fn() })) +vi.mock('next/navigation', () => ({ + redirect: (href: string) => { + throw new Error(`redirect:${href}`) + }, + notFound: () => { + throw new Error('not-found') + }, +})) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/settings/application/organization-section-access', () => ({ + authorizeOrganizationSettingsSection: mocks.authorize, +})) +vi.mock('@/components/settings/account-settings-renderer', () => ({ + AccountSettingsRenderer: () => null, +})) +vi.mock('@/components/settings/prefetch-standalone-general', () => ({ + prefetchStandaloneGeneral: vi.fn(), +})) +vi.mock('@/app/o/[organizationId]/settings/[section]/settings', () => ({ + OrganizationSettings: () => null, +})) + +import OrganizationSettingsSectionPage from '@/app/o/[organizationId]/settings/[section]/page' + +describe('organization request settings routing', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'viewer' } }) + mocks.authorize.mockResolvedValue(true) + }) + + it('renders the canonical request section only through the shared organization gate', async () => { + const page = await OrganizationSettingsSectionPage({ + params: Promise.resolve({ organizationId: 'organization', section: 'requests' }), + }) + expect(page.props.section).toBe('requests') + expect(mocks.authorize).toHaveBeenCalledWith({ + organizationId: 'organization', + userId: 'viewer', + section: 'requests', + }) + }) + + it('authorizes saved review tabs as Requests and preserves their selected request', async () => { + await expect( + OrganizationSettingsSectionPage({ + params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }), + searchParams: Promise.resolve({ + 'access-view': 'requests', + 'request-id': 'selected', + 'request-status': 'all', + 'group-id': 'old-group', + }), + }) + ).rejects.toThrow( + 'redirect:/o/organization/settings/requests?request-id=selected&request-status=all' + ) + expect(mocks.authorize).toHaveBeenCalledWith({ + organizationId: 'organization', + userId: 'viewer', + section: 'requests', + }) + }) + + it('conceals requests from viewers rejected by the organization gate', async () => { + mocks.authorize.mockResolvedValue(false) + await expect( + OrganizationSettingsSectionPage({ + params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }), + searchParams: Promise.resolve({ 'access-view': 'requests' }), + }) + ).rejects.toThrow('not-found') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx index 50d37a129be..9da86653b73 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/page.tsx @@ -4,6 +4,7 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer' import { + getOrganizationSettingsHref, getSettingsSectionMeta, ORGANIZATION_SETTINGS_ITEMS, } from '@/components/settings/navigation' @@ -16,9 +17,11 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings' import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation' +import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation' interface OrganizationSettingsSectionPageProps { params: Promise<{ organizationId: string; section: string }> + searchParams?: Promise> } export async function generateMetadata({ @@ -42,6 +45,7 @@ export async function generateMetadata({ */ export default async function OrganizationSettingsSectionPage({ params, + searchParams, }: OrganizationSettingsSectionPageProps) { const { organizationId, section } = await params const routes = organizationRoutes(organizationId) @@ -58,15 +62,23 @@ export default async function OrganizationSettingsSectionPage({ } if (resolved.plane === 'organization') { + const legacyRequestsQuery = getLegacyAccessRequestsQuery( + resolved.section, + (await searchParams) ?? {} + ) + const organizationSection = legacyRequestsQuery ? 'requests' : resolved.section if ( !(await authorizeOrganizationSettingsSection({ organizationId, userId: session.user.id, - section: resolved.section, + section: organizationSection, })) ) { notFound() } + if (legacyRequestsQuery) { + redirect(getOrganizationSettingsHref(organizationId, 'requests', legacyRequestsQuery)) + } return } diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx index 94221fc31fc..a436362048e 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx @@ -47,6 +47,11 @@ const Billing = dynamic(() => const AccessControl = dynamic(() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) ) +const OrganizationAccessRequests = dynamic(() => + import('@/ee/access-requests/components/organization-access-requests').then( + (m) => m.OrganizationAccessRequests + ) +) const AuditLogs = dynamic(() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs) ) @@ -100,8 +105,13 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) { )} {section === 'billing' && } {section === 'access-control' && ( - + )} + {section === 'requests' && } {section === 'audit-logs' && } {section === 'usage' && ( { { ...enterprise, hasEnterprisePlan: false, governanceActive: false }, available ).map(({ id }) => id) - ).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp']) + ).toEqual(['billing', 'members', 'recently-deleted', 'requests', 'search-mcp']) }) /** @@ -65,7 +65,14 @@ describe('organization settings navigation', () => { { ...enterprise, hasEnterprisePlan: false, governanceActive: true }, available ).map(({ id }) => id) - ).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp']) + ).toEqual([ + 'billing', + 'members', + 'recently-deleted', + 'requests', + 'access-control', + 'search-mcp', + ]) }) it('honors individual self-hosted feature flags and hides billing when disabled', () => { @@ -80,7 +87,15 @@ describe('organization settings navigation', () => { }, available ).map(({ id }) => id) - ).toEqual(['members', 'recently-deleted', 'sso', 'integrations', 'search-mcp', 'search-slack']) + ).toEqual([ + 'members', + 'recently-deleted', + 'requests', + 'sso', + 'integrations', + 'search-mcp', + 'search-slack', + ]) }) it('normalizes old section names and does not expose unsupported routes', () => { @@ -107,6 +122,7 @@ describe('organization settings navigation', () => { 'organization:usage', 'organization:whitelabeling', 'organization:recently-deleted', + 'organization:requests', 'governance:audit-logs', 'governance:access-control', 'governance:sso', 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 aeffeec2629..63a09c805aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -52,6 +52,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({ 'organization', 'usage', 'access-control', + 'requests', 'audit-logs', 'sso', 'security', @@ -134,6 +135,36 @@ describe('WorkspaceSettingsSectionPage', () => { } ) + it.each([ + { organizationSearch: false, destination: '/workspace/workspace-b/settings/requests' }, + { organizationSearch: true, destination: '/o/org-target/settings/requests' }, + ])( + 'moves saved request review tabs to the canonical destination with org rollout=$organizationSearch', + async ({ organizationSearch, destination }) => { + mockGetHostContext.mockResolvedValue({ + hostOrganizationId: 'org-target', + features: { organizationSearch }, + }) + await expect( + WorkspaceSettingsSectionPage({ + ...pageProps('access-control'), + searchParams: Promise.resolve({ + 'access-view': 'requests', + 'request-id': 'selected', + 'request-status': 'all', + 'group-id': 'old-group', + }), + }) + ).rejects.toThrow(`NEXT_REDIRECT:${destination}?request-id=selected&request-status=all`) + expect(mockAuthorizeSection).toHaveBeenCalledWith({ + workspaceId: 'workspace-b', + userId: 'viewer-a', + section: 'requests', + }) + expect(mockSectionPrefetch).not.toHaveBeenCalled() + } + ) + it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])( 'keeps settings in the workspace when older host context omits the org rollout', async (features) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx index df6f6c2d7b9..d845f3b6b41 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx @@ -13,6 +13,7 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary' +import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation' import { SECTION_PREFETCHERS } from './prefetch' import { SettingsPage } from './settings' @@ -47,7 +48,9 @@ export default async function WorkspaceSettingsSectionPage({ /** The layout already rejected an unknown segment; this narrows the type and fails safe. */ const resolved = resolveSettingsSection(section) if (!resolved) notFound() - const parsed = resolved.id + const queryParams = (await searchParams) ?? {} + const legacyRequestsQuery = getLegacyAccessRequestsQuery(resolved.id, queryParams) + const parsed = legacyRequestsQuery ? 'requests' : resolved.id const access = await authorizeWorkspaceSettingsSection({ workspaceId, @@ -77,8 +80,8 @@ export default async function WorkspaceSettingsSectionPage({ if (organizationSection) { const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id) if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) { - const query = new URLSearchParams() - for (const [key, value] of Object.entries((await searchParams) ?? {})) { + const query = legacyRequestsQuery ?? new URLSearchParams() + for (const [key, value] of Object.entries(legacyRequestsQuery ? {} : queryParams)) { for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) { query.append(key, entry) } @@ -89,6 +92,11 @@ export default async function WorkspaceSettingsSectionPage({ } } + if (legacyRequestsQuery) { + const query = legacyRequestsQuery.toString() + redirect(`/workspace/${workspaceId}/settings/requests${query ? `?${query}` : ''}`) + } + const queryClient = getQueryClient() /** * Protected section data starts only after the current server-side section gate succeeds. diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 997bf5af779..e85835b0dca 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -88,6 +88,11 @@ const WorkflowMcpServers = dynamic(() => const AccessControl = dynamic(() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl) ) +const OrganizationAccessRequests = dynamic(() => + import('@/ee/access-requests/components/organization-access-requests').then( + (m) => m.OrganizationAccessRequests + ) +) const CustomBlocks = dynamic(() => import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks) ) @@ -182,8 +187,12 @@ function SettingsPageContent({ section }: SettingsPageProps) { )} + {effectiveSection === 'requests' && organizationId && ( + + )} {effectiveSection === 'custom-blocks' && } {effectiveSection === 'audit-logs' && organizationId && ( diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 246a11b9cc9..4656a24a217 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -25,6 +25,7 @@ describe('unified settings navigation', () => { { id: 'desktop', label: 'Desktop', section: 'account' }, { id: 'browser', label: 'Browser', section: 'account' }, { id: 'terminal', label: 'Terminal', section: 'account' }, + { id: 'requests', label: 'Requests', section: 'organization' }, { id: 'access-control', label: 'Permission groups', section: 'organization' }, { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, { id: 'forks', label: 'Workspace forks', section: 'workspace' }, @@ -86,6 +87,7 @@ describe('unified settings navigation', () => { 'organization', 'usage', 'connected-accounts', + 'requests', 'access-control', 'audit-logs', 'whitelabeling', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx index 873838e6268..6d4f0060a19 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.test.tsx @@ -200,6 +200,27 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(workspaceLink('connected-accounts')).toBeNull() expect(workspaceLink('organization')).toBeNull() + expect(workspaceLink('requests')).toBeNull() + }) + + it.each(['member', 'external'] as const)( + 'does not expose organization review to a workspace admin who is an org %s', + (role) => { + hostContext = makeHostContext(role, false) + renderSidebar() + expect(workspaceLink('requests')).toBeNull() + } + ) + + it.each([true, false])('keeps Requests accessible to org admins with hosted=%s', (hosted) => { + hostContext.deployment = { + ...deployment, + hosted, + features: { ...deployment.features, accessControl: false }, + } + hostContext.ownerBilling = { ...hostContext.ownerBilling, plan: 'free', isEnterprise: false } + renderSidebar() + expect(workspaceLink('requests')).toHaveTextContent('Requests') }) it.each([false, undefined])( @@ -213,6 +234,7 @@ describe('workspace SettingsSidebar organization rollout', () => { expect(workspaceLink('usage')).toHaveTextContent('Insights') expect(workspaceLink('sso')).toHaveTextContent('Single sign-on') expect(workspaceLink('connected-accounts')).toHaveTextContent('Credential Groups') + expect(workspaceLink('requests')).toHaveTextContent('Requests') expect(container.querySelector('a[href^="/o/"]')).toBeNull() expectWorkspaceLinks() } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx index acf4cb1cf8f..d4576571666 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-sidebar/settings-sidebar.tsx @@ -166,6 +166,9 @@ export function SettingsSidebar({ hostContext.features?.credentialGroups ) } + if (item.id === 'requests') { + return Boolean(hostContext.hostOrganizationId && isOrgAdminOrOwner) + } if (item.id === 'organization') { return Boolean( hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationMember diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx index c000345672c..5d808f3dc3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.test.tsx @@ -185,13 +185,12 @@ afterEach(() => { describe('WorkspaceHeader workspace switcher highlight', () => { it.each([null, 'organization'])( - 'only offers request history for an organization workspace (%s)', + 'keeps access requests out of the workspace switcher (%s)', (organizationId) => { hostContext.hostOrganizationId = organizationId render() - expect(document.body.textContent?.includes('My access requests')).toBe( - Boolean(organizationId) - ) + expect(document.body).not.toHaveTextContent('My access requests') + expect(document.body).not.toHaveTextContent('Review access requests') } ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx index 5053a95637e..ca6e2eadbc7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/workspace-header.tsx @@ -24,16 +24,7 @@ import { toast, useScrollEdges, } from '@sim/emcn' -import { - ArrowLeft, - ListChecks, - MoreHorizontal, - PanelLeft, - Pin, - Plus, - Search, - Send, -} from '@sim/emcn/icons' +import { ArrowLeft, MoreHorizontal, PanelLeft, Pin, Plus, Search, Send } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' @@ -789,24 +780,6 @@ function WorkspaceHeaderImpl({ )} - {hostContext.hostOrganizationId && ( - - - - My access requests - - - )} - {hostContext.hostOrganizationId && hostContext.viewer.isHostOrganizationAdmin && ( - - - - Review access requests - - - )} { setIsWorkspaceMenuOpen(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index f79a8ee3582..02d119714c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -27,6 +27,7 @@ import { Database, Files, Integration, + ListChecks, MoreHorizontal, PanelLeft, Pin, @@ -849,6 +850,16 @@ export const Sidebar = memo(function Sidebar() { onNavigate: () => handleOpenSettings(id), })) + if (hostContext.hostOrganizationId) { + const accessRequestsHref = `/workspace/${workspaceId}/access-requests` + profileNavigationLinks.push({ + label: 'My access requests', + icon: ListChecks, + href: accessRequestsHref, + onNavigate: () => router.push(accessRequestsHref), + }) + } + const organizationHref = getWorkspaceOrganizationHref(hostContext) if (organizationHref) { profileNavigationLinks.push({ diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index eab988612ea..9725a2e55db 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -97,6 +97,7 @@ describe('settings navigation boundaries', () => { 'desktop', 'browser', 'terminal', + 'requests', 'access-control', 'audit-logs', 'forks', @@ -302,6 +303,7 @@ describe('settings navigation boundaries', () => { 'data-drains', 'data-retention', 'organization', + 'requests', 'security', 'sso', 'usage', @@ -318,6 +320,7 @@ describe('settings navigation boundaries', () => { billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', + requests: 'requests', 'audit-logs': 'audit-logs', sso: 'sso', security: 'security', @@ -500,6 +503,7 @@ describe('settings navigation boundaries', () => { expect(isOrganizationSettingsSectionAvailable('members', hostedFree)).toBe(true) expect(isOrganizationSettingsSectionAvailable('recently-deleted', hostedFree)).toBe(true) expect(isOrganizationSettingsSectionAvailable('billing', hostedFree)).toBe(true) + expect(isOrganizationSettingsSectionAvailable('requests', hostedFree)).toBe(true) expect(isOrganizationSettingsSectionAvailable('sso', hostedFree)).toBe(false) expect( isOrganizationSettingsSectionAvailable('sso', { @@ -509,6 +513,45 @@ describe('settings navigation boundaries', () => { ).toBe(true) }) + it('limits request settings to organization admins while preserving self-hosted history', () => { + expect( + resolveOrganizationSectionAccess({ + section: 'requests', + isTargetOrganizationMember: true, + isTargetOrganizationAdmin: false, + }) + ).toBe('unavailable') + expect( + resolveOrganizationSectionAccess({ + section: 'requests', + isTargetOrganizationMember: false, + isTargetOrganizationAdmin: true, + }) + ).toBe('unavailable') + expect( + resolveOrganizationSectionAccess({ + section: 'requests', + isTargetOrganizationMember: true, + isTargetOrganizationAdmin: true, + }) + ).toBe('manage') + expect( + isOrganizationSettingsSectionAvailable( + 'requests', + getOrganizationSettingsFeatures(false, SELF_HOSTED) + ) + ).toBe(true) + expect( + isOrganizationSettingsSectionAvailable( + 'requests', + getOrganizationSettingsFeatures(false, { + ...SELF_HOSTED, + features: { ...SELF_HOSTED.features, accessControl: true }, + }) + ) + ).toBe(true) + }) + it.each([ { permission: 'read' as const, diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 84f7c7acc0c..0a1c44c35d2 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -10,6 +10,7 @@ import { Integration, Key, KeySquare, + ListChecks, Lock, LogIn, Palette, @@ -52,6 +53,7 @@ export type OrganizationSettingsSection = | 'billing' | 'usage' | 'access-control' + | 'requests' | 'audit-logs' | 'sso' | 'security' @@ -97,6 +99,7 @@ export type UnifiedSettingsSection = | 'terminal' | 'secrets' | 'access-control' + | 'requests' | 'custom-blocks' | 'audit-logs' | 'apikeys' @@ -381,6 +384,17 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] requiresDesktopSurface: 'terminal', }, }, + { + label: 'Requests', + icon: ListChecks, + unified: { + id: 'requests', + description: 'Review requests across your organization.', + group: 'organization', + order: 3, + organizationSection: 'requests', + }, + }, { label: 'Permission groups', icon: ShieldCheck, @@ -886,6 +900,7 @@ const ORGANIZATION_SECTION_GROUPS: Record ({ groups: vi.fn(), requests: vi.fn() })) +const mocks = vi.hoisted(() => ({ groups: vi.fn() })) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace' }), usePathname: () => '/settings/access-control', })) vi.mock('@/ee/access-control/components/group-detail', () => ({ GroupDetail: () => null })) -vi.mock('@/ee/access-requests/components/access-request-review', () => ({ - AccessRequestReview: () => null, -})) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useCreatePermissionGroup: () => ({ isPending: false, mutateAsync: vi.fn() }), useOrganizationWorkspaces: () => ({ data: [], isPending: false }), @@ -23,12 +20,6 @@ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ vi.mock('@/hooks/queries/organization', () => ({ useOrganizationBilling: () => ({ data: undefined, isPending: false }), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ - ACCESS_REQUEST_PAGE_SIZE: 25, - useOrganizationAccessRequests: mocks.requests, - useAccessRequestSettings: () => ({ data: { allowRequests: true }, isPending: false }), - useUpdateAccessRequestSettings: () => ({ mutate: vi.fn(), isPending: false }), -})) import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { SettingsSectionProvider } from '@/components/settings/settings-panel' @@ -44,7 +35,6 @@ beforeEach(() => { document.body.appendChild(container) root = createRoot(container) mocks.groups.mockReturnValue({ data: [], isPending: false }) - mocks.requests.mockReturnValue({ data: { requests: [], hasMore: false }, isPending: false }) }) afterEach(() => { act(() => root.unmount()) @@ -65,7 +55,11 @@ function render(searchParams = '') { description: 'Manage permission groups across your organization.', }} > - + @@ -74,102 +68,33 @@ function render(searchParams = '') { ) } -function switchView(value: string) { - const button = container.querySelector( - `[aria-label="Access Control views"] [role="radio"][value="${value}"]` - ) - expect(button).not.toBeNull() - act(() => button!.click()) -} - describe('permission groups search layout', () => { - it('resets pagination and hides stale results while a new request search is debounced', async () => { - vi.useFakeTimers() - mocks.requests.mockReturnValue({ - data: { - requests: [ - { - id: 'request', - targetLabel: 'Previous result', - requester: { name: 'Member' }, - status: 'pending', - createdAt: '2026-09-01T00:00:00Z', - }, - ], - hasMore: true, - }, - isPending: false, - }) - render('?access-view=requests&request-page=2') - expect(mocks.requests).toHaveBeenLastCalledWith('organization', 50, 'pending', '') + it('links to the shared Requests destination without exposing a duplicate review tab', () => { + render('?search=Design') const input = container.querySelector('input')! - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( - input, - 'Tables' - ) - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - expect(container.textContent).toContain('Loading requests...') - expect(container.textContent).not.toContain('Previous result') - expect(container.textContent).not.toContain('Page 3') - await act(async () => vi.advanceTimersByTimeAsync(500)) - expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') - }) - it('keeps the same search input above the switch and restores each view’s search', () => { - render('?search=Design&request-search=Tables') - const input = container.querySelector('input')! - const viewSwitch = container.querySelector('[aria-label="Access Control views"]')! - expect(input.value).toBe('Design') - expect( - input.compareDocumentPosition(viewSwitch) & Node.DOCUMENT_POSITION_FOLLOWING - ).toBeTruthy() - - switchView('requests') - expect(container.querySelector('input')).toBe(input) - expect(input.placeholder).toBe('Search requests...') - expect(input.value).toBe('Tables') - expect(input.maxLength).toBe(200) - expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') - expect(container.textContent).toContain('No requests found matching "Tables"') - - switchView('groups') - expect(container.querySelector('input')).toBe(input) expect(input.value).toBe('Design') expect(input.placeholder).toBe('Search permission groups...') - expect(input.hasAttribute('maxlength')).toBe(false) + expect(container.querySelector('[aria-label="Access Control views"]')).toBeNull() + expect( + container.querySelector('a[href="/workspace/workspace/settings/requests"]')?.textContent + ).toBe('Review requests') }) - it('retains search when either list is loading or fails', () => { + it('retains permission group search while the list is loading or fails', () => { mocks.groups.mockReturnValue({ isPending: true }) - render() + render('?search=Design') const input = container.querySelector('input')! + expect(input.value).toBe('Design') expect(input.disabled).toBe(true) - mocks.requests.mockReturnValue({ isPending: true }) - switchView('requests') - expect(container.querySelector('input')).toBe(input) - expect(input.disabled).toBe(false) - expect(container.textContent).toContain('Loading requests...') - mocks.groups.mockReturnValue({ isPending: false, error: new Error('Groups unavailable'), isFetching: false, refetch: vi.fn(), }) - switchView('groups') + render('?search=Design') expect(container.querySelector('input')).toBe(input) + expect(input.value).toBe('Design') expect(container.textContent).toContain('Groups unavailable') - - mocks.requests.mockReturnValue({ - isPending: false, - isError: true, - error: new Error('Requests unavailable'), - isFetching: false, - refetch: vi.fn(), - }) - switchView('requests') - expect(container.querySelector('input')).toBe(input) - expect(container.textContent).toContain('Requests unavailable') }) }) diff --git a/apps/sim/ee/access-control/components/access-control.test.tsx b/apps/sim/ee/access-control/components/access-control.test.tsx index a3538511c08..10887262530 100644 --- a/apps/sim/ee/access-control/components/access-control.test.tsx +++ b/apps/sim/ee/access-control/components/access-control.test.tsx @@ -14,7 +14,7 @@ const { mockUseOrganizationBilling, mockUseUserPermissionConfig, mockUsePermissi vi.mock('@sim/emcn', () => ({ Checkbox: () => null, - ChipSwitch: () => null, + ChipLink: () => null, ChipModal: ({ children }: { children?: ReactNode }) => <>{children}, ChipModalBody: ({ children }: { children?: ReactNode }) => <>{children}, ChipModalError: () => null, @@ -30,10 +30,6 @@ vi.mock('next/navigation', () => ({ })) vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()], - useQueryStates: () => [{ 'access-view': 'groups' }, vi.fn()], -})) -vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ - OrganizationAccessRequests: () => null, })) vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-empty-state', () => ({ SettingsEmptyState: ({ children }: { children?: ReactNode }) =>
{children}
, @@ -104,14 +100,30 @@ describe('AccessControl entitlement states', () => { isFetching: false, refetch, }) - act(() => root.render()) + act(() => + root.render( + + ) + ) expect(container.textContent).toContain('Groups unavailable') expect(container.textContent).not.toContain('No permission groups yet') await act(async () => container.querySelector('button')?.click()) expect(refetch).toHaveBeenCalledOnce() mockUsePermissionGroups.mockReturnValue({ data: [], isPending: false, error: null }) - act(() => root.render()) + act(() => + root.render( + + ) + ) expect(container.textContent).toContain('No permission groups yet') expect(container.textContent).not.toContain('Groups unavailable') }) @@ -127,7 +139,15 @@ describe('AccessControl entitlement states', () => { isPending: false, }) - act(() => root.render()) + act(() => + root.render( + + ) + ) expect(container.textContent).toContain('Access Control billing failed') expect(container.textContent).not.toContain('Only organization admins on Enterprise plans') @@ -145,7 +165,15 @@ describe('AccessControl entitlement states', () => { isPending: false, }) - act(() => root.render()) + act(() => + root.render( + + ) + ) expect(container.textContent).toContain('Only organization admins on Enterprise plans') }) diff --git a/apps/sim/ee/access-control/components/access-control.tsx b/apps/sim/ee/access-control/components/access-control.tsx index e013167a580..04ce03102d8 100644 --- a/apps/sim/ee/access-control/components/access-control.tsx +++ b/apps/sim/ee/access-control/components/access-control.tsx @@ -3,13 +3,13 @@ import { useCallback, useMemo, useState } from 'react' import { Checkbox, + ChipLink, ChipModal, ChipModalBody, ChipModalError, ChipModalField, ChipModalFooter, ChipModalHeader, - ChipSwitch, ChipTag, Label, } from '@sim/emcn' @@ -17,7 +17,7 @@ import { Plus } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' -import { useQueryState, useQueryStates } from 'nuqs' +import { useQueryState } from 'nuqs' import { isEnterprise } from '@/lib/billing/plan-helpers' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { @@ -49,11 +49,6 @@ import { usePermissionGroups, useUserPermissionConfig, } from '@/ee/access-control/hooks/permission-groups' -import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' -import { - accessRequestUrlOptions, - accessReviewSearchParams, -} from '@/ee/access-requests/components/search-params' import { useOrganizationBilling } from '@/hooks/queries/organization' const logger = createLogger('AccessControl') @@ -61,32 +56,14 @@ const logger = createLogger('AccessControl') interface AccessControlProps { isOrganizationAdmin: boolean organizationId: string + requestsHref: string } -export function AccessControl(props: AccessControlProps) { - const [params, setParams] = useQueryStates(accessReviewSearchParams, accessRequestUrlOptions) - if (!props.isOrganizationAdmin) return - return ( - <> - void setParams({ 'access-view': value, 'request-id': null })} - /> - {params['access-view'] === 'requests' ? ( - - ) : ( - - )} - - ) -} - -function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControlProps) { +export function AccessControl({ + isOrganizationAdmin, + organizationId, + requestsHref, +}: AccessControlProps) { const params = useParams() const { features } = useDeploymentShape() const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined @@ -306,7 +283,10 @@ function PermissionGroups({ isOrganizationAdmin, organizationId }: AccessControl return ( <> - + Review requests} + > {permissionGroups.length === 0 ? ( No permission groups yet. Click "Create group" to get started. diff --git a/apps/sim/ee/access-requests/README.md b/apps/sim/ee/access-requests/README.md index 7d0b04e90c6..8031a07b5d7 100644 --- a/apps/sim/ee/access-requests/README.md +++ b/apps/sim/ee/access-requests/README.md @@ -1,11 +1,11 @@ # Permission access requests -Members request access from locked features, the block picker, or **My access requests**. Organization owners and administrators review requests in **Access control → Requests**, **Review access requests** in the workspace menu, or through an authenticated email link. The same queue handles increases to an administrator-set member credit cap. +Members request access from locked features or the block picker and track their requests through **My access requests** in the profile menu. The history page offers **Browse access** when additional access is requestable and identifies its workspace or organization scope. Organization owners and administrators review requests in **Organization settings → Requests** or through an authenticated email link. Requests remain available outside the Enterprise permission-group settings because the same queue handles increases to an administrator-set member credit cap. ## Deployment 1. Apply migration `0349_permission_access_requests.sql` before deploying the application changes. -2. Each organization starts with **Allow users to request permissions** enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available. +2. Each organization starts with requests enabled. An explicit organization opt-out disables creation and approval and restores existing feature hiding. History, cancellation, and decline remain available. 3. The existing outbox worker delivers notifications. Email links open authenticated review/history; email never applies a change. ## Policy and lifecycle diff --git a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx index b62b94f4232..3a97c225526 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx @@ -11,8 +11,16 @@ const mocks = vi.hoisted(() => ({ mine: vi.fn(), discovery: vi.fn(), cancel: vi.fn(), + workspace: vi.fn(), + hosted: true, url: vi.fn(), })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ + useDeploymentShape: () => ({ hosted: mocks.hosted }), +})) +vi.mock('@/hooks/queries/workspace-host', () => ({ + useWorkspaceHostContextQuery: mocks.workspace, +})) vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useMyAccessRequests: mocks.mine, @@ -41,13 +49,18 @@ describe('compact requester history', () => { let root: Root beforeEach(() => { vi.clearAllMocks() + mocks.hosted = true ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) mocks.mine.mockReturnValue(successful([request])) mocks.discovery.mockReturnValue({ - data: { enabled: true, entries: [], hasMore: false }, + data: { enabled: true, organizationId: 'organization', entries: [], hasMore: false }, + isSuccess: true, + }) + mocks.workspace.mockReturnValue({ + data: { workspace: { id: 'workspace', name: 'Design' }, hostOrganizationId: 'organization' }, isSuccess: true, }) mocks.cancel.mockReturnValue({ mutate: vi.fn(), isPending: false, error: null }) @@ -101,8 +114,7 @@ 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, state: 'requestable' }), - true + expect.objectContaining({ search: 'slack', offset: 50, state: 'requestable' }) ) }) @@ -115,9 +127,7 @@ describe('compact requester history', () => { 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' - ) + expect(container.querySelector('[aria-label="Access request views"]')).toBeNull() await vi.waitFor(() => expect(mocks.url).toHaveBeenLastCalledWith( expect.objectContaining({ queryString: '?search=slack' }) @@ -125,4 +135,102 @@ describe('compact requester history', () => { ) expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) }) + + it('does not promote an empty catalog from request history', () => { + mocks.mine.mockReturnValue(successful([])) + render('?search=slack&page=2') + expect(container.textContent).not.toContain('Browse access') + expect(container.textContent).toContain('No requests on this page') + expect(mocks.discovery).toHaveBeenCalledWith({ + ...scope, + search: '', + state: 'requestable', + limit: 1, + offset: 0, + }) + }) + + it('promotes browsing only when an unfiltered discovery finds a requestable item', () => { + mocks.discovery.mockReturnValue({ + data: { enabled: true, entries: [{ state: 'requestable' }] }, + isSuccess: true, + }) + render() + expect(container.querySelector('[role="radio"][value="catalog"]')?.textContent).toBe( + 'Browse access' + ) + }) + + it('keeps history available when new requests are paused', () => { + mocks.discovery.mockReturnValue({ + data: { enabled: false, organizationId: 'organization', entries: [] }, + isSuccess: true, + }) + render() + expect(container.textContent).toContain('Slack') + expect(container.textContent).toContain('Your organization has paused new requests.') + expect(container.textContent).not.toContain('Browse access') + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, undefined, true) + }) + + it('keeps history available if discovery fails with stale requestable data', () => { + mocks.discovery.mockReturnValue({ + data: { enabled: true, entries: [{ state: 'requestable' }] }, + isSuccess: false, + isError: true, + error: new Error('Discovery unavailable'), + }) + render() + expect(container.textContent).toContain('Slack') + expect(container.textContent).not.toContain('Browse access') + expect(container.textContent).not.toContain('Discovery unavailable') + }) + + it('distinguishes an empty catalog from an unsuccessful search and preserves its deep link', () => { + render('?view=catalog') + expect(container.textContent).toContain('Nothing to request in this workspace') + expect(container.textContent).not.toContain('No matching results') + expect(container.querySelector('[role="radio"][value="requests"]')).not.toBeNull() + expect(container.querySelector('[role="radio"][value="catalog"]')).not.toBeNull() + }) + + it('lets users clear a search with no matches', async () => { + render('?view=catalog&search=slack&page=2') + expect(container.textContent).toContain('No matching results') + const clear = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Clear search' + ) + expect(clear).toBeDefined() + await act(async () => clear?.click()) + await vi.waitFor(() => + expect(mocks.url).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?view=catalog' }) + ) + ) + }) + + it('explains paused requests on catalog links without a search control', () => { + mocks.discovery.mockReturnValue({ + data: { enabled: false, organizationId: 'organization', entries: [] }, + isSuccess: true, + }) + render('?view=catalog') + expect(container.textContent).toContain('New requests are paused') + expect(container.querySelector('[aria-label="Search access catalog"]')).toBeNull() + expect(container.querySelector('[role="radio"][value="requests"]')).not.toBeNull() + }) + + it('labels the current workspace and organization-wide credit request scope', () => { + render() + expect(container.textContent).toContain('Workspace: Design') + expect(container.textContent).toContain('Includes your organization credit limit requests.') + }) + + it('does not offer credit requests on self-hosted deployments', () => { + mocks.hosted = false + mocks.mine.mockReturnValue(successful([])) + render() + expect(container.textContent).not.toContain('credit') + expect(container.textContent).toContain('Workspace: Design') + }) }) diff --git a/apps/sim/ee/access-requests/components/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx index fe9a579f934..17c005c982e 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.tsx @@ -5,6 +5,7 @@ import { Lock, Search } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import { EmptyState } from '@/components/empty-state/empty-state' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { @@ -24,6 +25,7 @@ import { useMyAccessRequests, } from '@/ee/access-requests/hooks/access-requests' import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -32,6 +34,10 @@ interface MyAccessRequestsProps { } export function MyAccessRequests({ scope }: MyAccessRequestsProps) { + const { hosted } = useDeploymentShape() + const workspace = useWorkspaceHostContextQuery( + scope.kind === 'workspace' ? scope.workspaceId : '' + ) const [{ view, search, page, requestId }, setParams] = useQueryStates( accessRequestSearchParams, accessRequestUrlOptions @@ -43,17 +49,23 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) { const searchPending = view === 'catalog' && search.trim() !== debouncedSearch const offset = page * ACCESS_REQUEST_PAGE_SIZE const requests = useMyAccessRequests(scope, offset, undefined, view === 'requests') - const catalog = useDiscoverAccessRequests( - { - ...scope, - search: debouncedSearch, - state: 'requestable', - limit: ACCESS_REQUEST_PAGE_SIZE, - offset, - }, - view === 'catalog' - ) + const catalog = useDiscoverAccessRequests({ + ...scope, + search: view === 'catalog' ? debouncedSearch : '', + state: 'requestable', + limit: view === 'catalog' ? ACCESS_REQUEST_PAGE_SIZE : 1, + offset: view === 'catalog' ? offset : 0, + }) const currentQuery = view === 'requests' ? requests : catalog + const showCatalog = + view === 'catalog' || + (catalog.isSuccess && catalog.data.enabled && catalog.data.entries.length > 0) + const requestsPaused = + catalog.isSuccess && !catalog.data.enabled && Boolean(catalog.data.organizationId) + const scopeLabel = + scope.kind === 'workspace' + ? `Workspace: ${workspace.isSuccess ? workspace.data.workspace.name : 'Current workspace'}` + : 'Organization requests' return (
@@ -61,21 +73,34 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) {

My access requests

+

{scopeLabel}

+ {hosted && scope.kind === 'workspace' && workspace.data?.hostOrganizationId && ( +

+ Includes your organization credit limit requests. +

+ )}
{scope.kind === 'organization' && ( Your workspaces )}
- void setParams({ view: value, page: 0, requestId: null })} - /> - {view === 'catalog' && ( + {showCatalog && ( + void setParams({ view: value, page: 0, requestId: null })} + /> + )} + {view === 'requests' && requestsPaused && ( +

+ Your organization has paused new requests. Your request history is still available. +

+ )} + {view === 'catalog' && catalog.isSuccess && catalog.data.enabled && ( {requests.data?.requests.length === 0 && ( void setParams({ view: 'catalog', page: 0, requestId: null })} - > - Browse access - + title={page > 0 ? 'No requests on this page' : 'No access requests yet'} + description={ + page > 0 + ? 'Go to the previous page to see your requests.' + : 'Requests you send appear here so you can track their status.' } /> )} @@ -126,15 +148,36 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) { ) : !catalog.data?.enabled ? ( ) : (
{catalog.data.entries.length === 0 && ( 0 + ? 'No more access to request' + : `Nothing to request in this ${scope.kind}` + } + description={ + debouncedSearch + ? 'Try another search or clear it to see available requests.' + : page > 0 + ? 'Go to the previous page to see available requests.' + : 'There is no additional access available to request.' + } + action={ + debouncedSearch ? ( + setSearch('')}>Clear search + ) : undefined + } /> )} {catalog.data.entries.map((entry) => ( diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx index 22a170ba1e4..ba7441854d1 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/ee/access-requests/components/access-request-review', () => ({ AccessRequestReview: () => null, })) +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' describe('organization access request settings', () => { @@ -53,6 +54,7 @@ describe('organization access request settings', () => { createdAt: '2026-09-15T12:00:00Z', }, ], + total: 1, hasMore: false, }, }) @@ -62,11 +64,15 @@ describe('organization access request settings', () => { container.remove() }) - const render = () => + const render = (searchParams = '') => act(() => root.render( - - + + + + + + ) ) @@ -77,7 +83,70 @@ describe('organization access request settings', () => { 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() + expect(container.querySelector('[aria-label="Allow requests"]')).toBeNull() + }) + + it('shows the pending total from the existing paginated query', () => { + mocks.requests.mockReturnValue({ + isPending: false, + isError: false, + data: { requests: [], total: 0, hasMore: false }, + }) + render() + expect(container.textContent).toContain('Pending requests (0)') + expect(container.textContent).toContain('No pending requests.') + expect(container.textContent).not.toContain('No requests yet.') + }) + + it.each([ + ['?request-status=all', 'No requests yet.'], + ['?request-status=declined', 'No declined requests.'], + ['?request-search=Tables', 'No matching requests for "Tables".'], + ])('distinguishes the empty queue for %s', (query, message) => { + mocks.requests.mockReturnValue({ + isPending: false, + isError: false, + data: { requests: [], total: 0, hasMore: false }, + }) + render(query) + expect(container.textContent).toContain(message) + }) + + it('resets pagination and hides stale results while a new search is debounced', async () => { + vi.useFakeTimers() + mocks.requests.mockReturnValue({ + data: { + requests: [ + { + id: 'request', + targetLabel: 'Previous result', + requester: { name: 'Member' }, + status: 'pending', + createdAt: '2026-09-01T00:00:00Z', + }, + ], + total: 51, + hasMore: true, + }, + isPending: false, + }) + render('?request-page=2') + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 50, 'pending', '') + const input = container.querySelector('input')! + act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Tables' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(container.textContent).toContain('Loading requests...') + expect(container.textContent).not.toContain('Previous result') + expect(container.textContent).not.toContain('Page 3') + expect(container.textContent).not.toContain('Pending requests (51)') + await act(async () => vi.advanceTimersByTimeAsync(500)) + expect(mocks.requests).toHaveBeenLastCalledWith('organization', 0, 'pending', 'Tables') + vi.useRealTimers() }) it('allows settings failures to be retried independently of the request history', () => { @@ -91,7 +160,7 @@ describe('organization access request settings', () => { 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() + expect(container.querySelector('[aria-label="Allow requests"]')).toBeNull() const retry = Array.from(container.querySelectorAll('button')).find( (button) => button.textContent === 'Try again' ) @@ -108,9 +177,7 @@ describe('organization access request settings', () => { 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"]' - ) + const setting = container.querySelector('[role="radiogroup"][aria-label="Allow requests"]') expect(setting?.querySelector('[role="radio"][aria-checked="true"]')?.textContent).toBe( 'Enabled' ) diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.tsx index f31ddb33008..d192502a01a 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -58,6 +58,10 @@ export function OrganizationAccessRequests({ ) const settings = useAccessRequestSettings(organizationId) const updateSettings = useUpdateAccessRequestSettings(organizationId) + const status = params['request-status'] + const requestLabel = + status === 'all' ? 'Requests' : `${ACCESS_REQUEST_STATUS_LABELS[status]} requests` + const requestCount = !searchPending && !requests.isError ? requests.data?.total : undefined const content = (
@@ -73,15 +77,15 @@ export function OrganizationAccessRequests({ /> ) : ( )} {debouncedSearch - ? `No requests found matching "${searchTerm.trim()}"` - : 'No access requests. Requests from your members will appear here.'} + ? `No matching requests for "${debouncedSearch}".` + : status === 'pending' + ? 'No pending requests. Requests that need your review will appear here.' + : status === 'all' + ? 'No requests yet. Requests will appear here once someone sends one.' + : `No ${ACCESS_REQUEST_STATUS_LABELS[status].toLowerCase()} requests.`} )} {requests.data.requests.map((request) => ( diff --git a/apps/sim/ee/access-requests/lib/navigation.test.ts b/apps/sim/ee/access-requests/lib/navigation.test.ts new file mode 100644 index 00000000000..46ace5416a7 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/navigation.test.ts @@ -0,0 +1,47 @@ +/** @vitest-environment node */ +import { describe, expect, it } from 'vitest' +import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation' + +describe('legacy access request navigation', () => { + it('preserves review filters and selection without leaking permission group view state', () => { + const query = getLegacyAccessRequestsQuery('access-control', { + 'access-view': 'requests', + 'request-id': 'request/a', + 'request-search': 'Tables and files', + 'request-page': '2', + 'request-status': 'declined', + 'group-id': 'group', + search: 'Group search', + }) + expect(query?.toString()).toBe( + 'request-id=request%2Fa&request-search=Tables+and+files&request-page=2&request-status=declined' + ) + }) + + it('uses the first tab value consistently with URL query parsing', () => { + expect( + getLegacyAccessRequestsQuery('access-control', { 'access-view': ['requests', 'groups'] }) + ).toBeInstanceOf(URLSearchParams) + expect( + getLegacyAccessRequestsQuery('access-control', { 'access-view': ['groups', 'requests'] }) + ).toBeNull() + }) + + it('leaves other settings and the permission groups view alone', () => { + expect(getLegacyAccessRequestsQuery('access-control', {})).toBeNull() + expect(getLegacyAccessRequestsQuery('access-control', { 'access-view': 'groups' })).toBeNull() + expect(getLegacyAccessRequestsQuery('billing', { 'access-view': 'requests' })).toBeNull() + }) + + it('drops invalid review state through the canonical query parsers', () => { + expect( + getLegacyAccessRequestsQuery('access-control', { + 'access-view': 'requests', + 'request-page': '-1', + 'request-status': 'invalid', + 'request-id': '', + 'request-search': 'a'.repeat(201), + })?.toString() + ).toBe('') + }) +}) diff --git a/apps/sim/ee/access-requests/lib/navigation.ts b/apps/sim/ee/access-requests/lib/navigation.ts new file mode 100644 index 00000000000..cb899b527d0 --- /dev/null +++ b/apps/sim/ee/access-requests/lib/navigation.ts @@ -0,0 +1,20 @@ +import { omit } from '@sim/utils/object' +import { createLoader, createSerializer } from 'nuqs/server' +import { accessReviewSearchParams } from '@/ee/access-requests/components/search-params' + +const loadReviewSearchParams = createLoader(accessReviewSearchParams) +const serializeReviewSearchParams = createSerializer( + omit(accessReviewSearchParams, ['access-view']) +) + +/** Preserves review state when a saved Permission groups tab URL moves to Requests. */ +export function getLegacyAccessRequestsQuery( + section: string, + searchParams: Record +): URLSearchParams | null { + if (section !== 'access-control') return null + const params = loadReviewSearchParams(searchParams) + return params['access-view'] === 'requests' + ? new URLSearchParams(serializeReviewSearchParams(params)) + : null +} diff --git a/apps/sim/lib/settings/application/organization-section-access.test.ts b/apps/sim/lib/settings/application/organization-section-access.test.ts index c8640ceffd2..018eb54c6bd 100644 --- a/apps/sim/lib/settings/application/organization-section-access.test.ts +++ b/apps/sim/lib/settings/application/organization-section-access.test.ts @@ -174,6 +174,35 @@ describe('organization settings authorization', () => { expect(mocks.enterprise).not.toHaveBeenCalled() }) + it('keeps request review independent of the Enterprise plan and Search rollout', async () => { + mocks.enterprise.mockResolvedValue(false) + mocks.governance.mockResolvedValue(false) + mocks.search.mockResolvedValue(false) + + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'admin', + section: 'requests', + }) + ).resolves.toBe(true) + expect(mocks.canOpen).toHaveBeenCalledWith('target', 'admin', 'requests') + expect(mocks.enterprise).not.toHaveBeenCalled() + expect(mocks.governance).not.toHaveBeenCalled() + expect(mocks.search).not.toHaveBeenCalled() + }) + + it('rejects request review when target organization authority is absent', async () => { + mocks.canOpen.mockResolvedValue(false) + await expect( + authorizeOrganizationSettingsSection({ + organizationId: 'target', + userId: 'member', + section: 'requests', + }) + ).resolves.toBe(false) + }) + it('applies enterprise entitlement only after role authorization', async () => { mocks.enterprise.mockResolvedValue(false) expect( diff --git a/apps/sim/lib/settings/application/organization-section-access.ts b/apps/sim/lib/settings/application/organization-section-access.ts index 6e1df504b5b..f9f343555f3 100644 --- a/apps/sim/lib/settings/application/organization-section-access.ts +++ b/apps/sim/lib/settings/application/organization-section-access.ts @@ -31,11 +31,9 @@ export async function authorizeOrganizationSettingsSection({ return isKnowledgeMemberAccessAvailable({ organizationId }) const deployment = getDeploymentShape() - const needsEnterprisePlan = deployment.hosted && section !== 'members' && section !== 'billing' - /** - * Access Control's availability follows the permission regime rather than the plan gate, and no - * other section reads it — so each section pays for exactly one of the two lookups. - */ + const needsEnterprisePlan = + deployment.hosted && section !== 'members' && section !== 'billing' && section !== 'requests' + /** Access Control follows the permission regime rather than the plan gate. */ const readsRegime = needsEnterprisePlan && section === 'access-control' const [hasEnterprisePlan, governanceActive] = await Promise.all([ needsEnterprisePlan && !readsRegime diff --git a/apps/sim/lib/settings/application/workspace-section-access.test.ts b/apps/sim/lib/settings/application/workspace-section-access.test.ts index 77c804cd809..f48fc89e1ed 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -48,6 +48,7 @@ vi.mock('@/components/settings/navigation', () => ({ billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', + requests: 'requests', }, UNIFIED_TO_WORKSPACE_SECTION: { secrets: 'secrets', @@ -304,6 +305,31 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledTimes(1) }) + it('opens organization request settings without querying Enterprise entitlement', async () => { + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(false) + await expect(authorize('requests')).resolves.toEqual({ allowed: true }) + expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith( + 'organization-1', + 'viewer-1', + 'requests' + ) + expect(mocks.isOrganizationOnEnterprisePlan).not.toHaveBeenCalled() + }) + + it('rejects organization request settings for personal workspaces and non-admins', async () => { + await expect(authorize('requests')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) + await expect(authorize('requests')).resolves.toEqual({ + allowed: false, + disposition: 'redirect-general', + }) + }) + it('resolves the exact entitlement source only for gated workspace sections', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(PERSONAL_ACCESS) mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'forks' }]) diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index ab8c8e2a6d4..04be2103895 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -114,8 +114,10 @@ async function canOpenOrganizationSection( }) } - const needsEnterprisePlan = organizationSection !== 'members' && organizationSection !== 'billing' - /** Same split as the organization surface: Access Control follows the regime, everything else the plan. */ + const needsEnterprisePlan = + organizationSection !== 'members' && + organizationSection !== 'billing' && + organizationSection !== 'requests' const readsRegime = needsEnterprisePlan && organizationSection === 'access-control' const [canOpenSection, isEnterpriseOrganization, governanceActive] = await Promise.all([ canOpenOrganizationSettingsSection(workspace.organizationId, input.userId, organizationSection), diff --git a/apps/sim/lib/workspaces/admin-move-source-impact.ts b/apps/sim/lib/workspaces/admin-move-source-impact.ts index 8a770c24d6e..f6edaa0f012 100644 --- a/apps/sim/lib/workspaces/admin-move-source-impact.ts +++ b/apps/sim/lib/workspaces/admin-move-source-impact.ts @@ -63,6 +63,7 @@ const ENTERPRISE_GATED_SECTION_LABELS: Record