diff --git a/apps/sim/app/access-requests/page.test.tsx b/apps/sim/app/access-requests/page.test.tsx index 652d4a8adc6..46def83a7cf 100644 --- a/apps/sim/app/access-requests/page.test.tsx +++ b/apps/sim/app/access-requests/page.test.tsx @@ -2,27 +2,29 @@ import { authMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { redirect, organizationContext } = vi.hoisted(() => ({ +const { redirect, organizationContext, organizationAccess } = vi.hoisted(() => ({ redirect: vi.fn(), organizationContext: vi.fn(), + organizationAccess: vi.fn(), })) vi.mock('next/navigation', () => ({ redirect })) vi.mock('@/lib/organizations/surface', () => ({ getOrganizationSurfaceContext: organizationContext, })) -vi.mock('@/ee/access-requests/components/my-access-requests', () => ({ - MyAccessRequests: () => null, +vi.mock('@/ee/access-requests/components/access-requests-settings', () => ({ + AccessRequestsSettings: () => null, })) -vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ - OrganizationAccessRequests: () => null, +vi.mock('@/lib/organizations/settings-access', () => ({ + getOrganizationSettingsAccess: organizationAccess, })) import AccessRequestsPage from '@/app/access-requests/page' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' +import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings' describe('access request sign-in redirect', () => { beforeEach(() => { vi.clearAllMocks() + organizationAccess.mockResolvedValue({ isAdmin: false, isMember: true }) authMockFns.mockGetSession.mockResolvedValue(null) redirect.mockImplementation(() => { throw new Error('Redirect') @@ -92,7 +94,7 @@ describe('access request sign-in redirect', () => { ).rejects.toThrow('Redirect') expect(organizationContext).toHaveBeenCalledWith('organization', 'viewer') const destination = new URL(redirect.mock.calls[0][0], 'https://example.com') - expect(destination.pathname).toBe('/o/organization/access-requests') + expect(destination.pathname).toBe('/o/organization/settings/requests') expect(Object.fromEntries(destination.searchParams)).toEqual({ view: 'catalog', requestId: 'request/a', @@ -107,38 +109,90 @@ describe('access request sign-in redirect', () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } }) organizationContext.mockResolvedValue(context) await AccessRequestsPage({ - searchParams: Promise.resolve({ organizationId: 'organization' }), + searchParams: Promise.resolve({ organizationId: 'organization', view: 'requests' }), }) expect(redirect).not.toHaveBeenCalled() } ) - it('keeps authenticated administrator email links on the review surface', async () => { + it('normalizes saved administrator email links without losing review state', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } }) - await AccessRequestsPage({ - searchParams: Promise.resolve({ - organizationId: 'organization', - view: 'admin', - requestId: 'request', - }), + await expect( + AccessRequestsPage({ + searchParams: Promise.resolve({ + organizationId: 'organization', + view: 'admin', + requestId: 'request', + 'request-status': 'declined', + }), + }) + ).rejects.toThrow('Redirect') + const destination = new URL(redirect.mock.calls[0][0], 'https://example.com') + expect(Object.fromEntries(destination.searchParams)).toEqual({ + organizationId: 'organization', + view: 'review', + 'request-id': 'request', + 'request-status': 'declined', }) - expect(redirect).not.toHaveBeenCalled() expect(organizationContext).not.toHaveBeenCalled() }) + it('routes reviewer links into organization settings when the shell is available', async () => { + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } }) + organizationContext.mockResolvedValue({ searchAccess: { memberScoped: true } }) + await expect( + AccessRequestsPage({ + searchParams: Promise.resolve({ + organizationId: 'organization', + view: 'review', + 'request-id': 'request', + }), + }) + ).rejects.toThrow('Redirect') + expect(redirect).toHaveBeenCalledWith( + '/o/organization/settings/requests?request-id=request&view=review' + ) + }) + + it.each([undefined, 'invalid', ['requests', 'review']])( + 'keeps invalid or old requester links on My requests: %j', + async (view) => { + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } }) + await expect( + AccessRequestsPage({ + searchParams: Promise.resolve({ + organizationId: 'organization', + requestId: 'request', + view, + }), + }) + ).rejects.toThrow('Redirect') + const destination = new URL(redirect.mock.calls[0][0], 'https://example.com') + expect(destination.searchParams.get('view')).toBe('requests') + expect(destination.searchParams.get('requestId')).toBe('request') + } + ) + it('renders the standalone requester when the optional organization navigation lookup fails', async () => { authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } }) organizationContext.mockRejectedValue(new Error('Organization context unavailable')) const page = await AccessRequestsPage({ - searchParams: Promise.resolve({ organizationId: 'organization', requestId: 'request' }), + searchParams: Promise.resolve({ + organizationId: 'organization', + view: 'requests', + requestId: 'request', + }), }) expect(redirect).not.toHaveBeenCalled() - expect(page.props.children.type).toBe(MyAccessRequests) - expect(page.props.children.props).toEqual({ + expect(page.props.children.props.children.props.children.props.children.type).toBe( + AccessRequestsSettings + ) + expect(page.props.children.props.children.props.children.props.children.props).toEqual({ scope: { kind: 'organization', organizationId: 'organization' }, standalone: true, + reviewOrganizationId: undefined, }) }) }) diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx index 008cb5ab5ae..c3038769936 100644 --- a/apps/sim/app/access-requests/page.tsx +++ b/apps/sim/app/access-requests/page.tsx @@ -5,17 +5,18 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' import { createSearchParamsCache, createSerializer } from 'nuqs/server' import { EmptyState } from '@/components/empty-state/empty-state' +import { ORGANIZATION_SETTINGS_ITEMS, toSettingsHeaderMeta } from '@/components/settings/navigation' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { SettingsSectionProvider } from '@/components/settings/settings-panel' import { getSession } from '@/lib/auth' import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths' +import { getOrganizationSettingsAccess } from '@/lib/organizations/settings-access' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' -import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' -import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' -import { - accessRequestEntrySearchParams, - accessRequestSearchParams, -} from '@/ee/access-requests/components/search-params' +import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings' +import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params' +import { getLegacyAccessRequestsSettingsQuery } from '@/ee/access-requests/lib/navigation' export const metadata: Metadata = { title: 'Access requests', @@ -28,7 +29,6 @@ interface AccessRequestsPageProps { const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams) const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams) -const serializeRequesterSearchParams = createSerializer(accessRequestSearchParams) const logger = createLogger('AccessRequestsPage') /** Session-only entry so access requests remain reachable outside the organization Search rollout. */ @@ -48,50 +48,50 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest return ( Back to Sim} /> ) } - if (params.view !== 'admin') { - const context = await getOrganizationSurfaceContext( - params.organizationId, - session.user.id - ).catch((error) => { + const query = getLegacyAccessRequestsSettingsQuery(rawParams) + if (params.view === 'admin' || params.view !== rawParams.view) { + const normalized = new URLSearchParams(query) + normalized.set('organizationId', params.organizationId) + redirect(`/access-requests?${normalized}`) + } + + const context = await getOrganizationSurfaceContext(params.organizationId, session.user.id).catch( + (error) => { logger.warn('Unable to resolve organization navigation for access requests', { error }) return null - }) - if (context?.searchAccess.memberScoped) { - redirect( - serializeRequesterSearchParams(organizationRoutes(params.organizationId).accessRequests, { - view: params.view, - search: params.search, - page: params.page, - requestId: params.requestId, - }) - ) } + ) + if (context?.searchAccess.memberScoped) { + redirect(organizationRoutes(params.organizationId).settingsSection('requests') + query) } + const access = await getOrganizationSettingsAccess(params.organizationId, session.user.id) + const meta = ORGANIZATION_SETTINGS_ITEMS.find((item) => item.id === 'requests')! return ( - }> - {params.view === 'admin' ? ( -
-
-
-

Access requests

- Back to Sim -
- -
-
- ) : ( - - )} -
+ + + + + Loading requests... + + } + > + + + + + ) } diff --git a/apps/sim/app/o/[organizationId]/access-requests/page.tsx b/apps/sim/app/o/[organizationId]/access-requests/page.tsx index db35d81abb2..6b6689e36d4 100644 --- a/apps/sim/app/o/[organizationId]/access-requests/page.tsx +++ b/apps/sim/app/o/[organizationId]/access-requests/page.tsx @@ -1,21 +1,21 @@ -import { Suspense } from 'react' -import type { Metadata } from 'next' -import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' +import { redirect } from 'next/navigation' +import { + getAccessRequestsSettingsHref, + getLegacyAccessRequestsSettingsQuery, +} from '@/ee/access-requests/lib/navigation' -export const metadata: Metadata = { title: 'My access requests' } - -interface OrganizationAccessRequestsPageProps { +interface AccessRequestsPageProps { params: Promise<{ organizationId: string }> + searchParams: Promise> } -export default async function OrganizationAccessRequestsPage({ +export default async function AccessRequestsPage({ params, -}: OrganizationAccessRequestsPageProps) { - const { organizationId } = await params - return ( - }> - - + searchParams, +}: AccessRequestsPageProps) { + const [{ organizationId }, query] = await Promise.all([params, searchParams]) + redirect( + getAccessRequestsSettingsHref({ kind: 'organization', organizationId }) + + getLegacyAccessRequestsSettingsQuery(query) ) } 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 990d6540037..bfb66dcb65d 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 @@ -114,12 +114,9 @@ describe('OrganizationFooter settings navigation', () => { await openProfileMenu() expect( [...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent) - ).toEqual(['Settings', 'My access requests', 'Sign out']) + ).toEqual(['Settings', 'Sign out']) expect(document.querySelector('[role="separator"]')).toBeNull() - const requests = document.querySelector('a[href="/o/org-1/access-requests"]') - expect(requests).not.toBeNull() - await act(async () => requests!.click()) - expect(mockPush).toHaveBeenCalledWith('/o/org-1/access-requests') + expect(document.body.textContent).not.toContain('My access requests') }) 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 a9110907f1a..125ab4202af 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,7 +1,6 @@ 'use client' import type { ComponentProps } from 'react' -import { ListChecks } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { organizationRoutes } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' @@ -17,21 +16,13 @@ export function OrganizationFooter(props: OrganizationFooterProps) { const { organization } = useOrganizationContext() const router = useRouter() const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general') - const accessRequestsHref = organizationRoutes(organization.id).accessRequests return ( router.push(accountSettingsHref)} - navigationLinks={[ - { - label: 'My access requests', - icon: ListChecks, - href: accessRequestsHref, - onNavigate: () => router.push(accessRequestsHref), - }, - ]} + navigationLinks={[]} /> ) } diff --git a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx index a436362048e..18dc35e326b 100644 --- a/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx @@ -47,9 +47,9 @@ 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 AccessRequestsSettings = dynamic(() => + import('@/ee/access-requests/components/access-requests-settings').then( + (m) => m.AccessRequestsSettings ) ) const AuditLogs = dynamic(() => @@ -111,7 +111,12 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) { requestsHref={getOrganizationSettingsHref(organizationId, 'requests')} /> )} - {section === 'requests' && } + {section === 'requests' && ( + + )} {section === 'audit-logs' && } {section === 'usage' && ( { it('exposes MCP setup and the read-only roster to an ordinary organization member', () => { expect( organizationSettingsNavigation(false, enterprise, available).map(({ id }) => id) - ).toEqual(['members', 'recently-deleted', 'search-mcp']) + ).toEqual(['members', 'recently-deleted', 'requests', 'search-mcp']) }) it('uses Sources for administration when Search is available', () => { @@ -138,7 +138,7 @@ describe('organization settings navigation', () => { it('hosts the account General section ahead of the organization sections', () => { expect( organizationSurfaceSettingsNavigation(false, enterprise, available).map(({ id }) => id) - ).toEqual(['general', 'members', 'recently-deleted', 'search-mcp']) + ).toEqual(['general', 'members', 'recently-deleted', 'requests', 'search-mcp']) expect(ORGANIZATION_SETTINGS_GROUPS.map(({ key }) => key)).toEqual([ 'account', 'organization', diff --git a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx index fa84a020a79..56b45eaa5b7 100644 --- a/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/access-requests/page.tsx @@ -1,19 +1,21 @@ -import { Suspense } from 'react' -import type { Metadata } from 'next' -import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' - -export const metadata: Metadata = { title: 'My access requests' } +import { redirect } from 'next/navigation' +import { + getAccessRequestsSettingsHref, + getLegacyAccessRequestsSettingsQuery, +} from '@/ee/access-requests/lib/navigation' interface AccessRequestsPageProps { params: Promise<{ workspaceId: string }> + searchParams: Promise> } -export default async function AccessRequestsPage({ params }: AccessRequestsPageProps) { - const { workspaceId } = await params - return ( - }> - - +export default async function AccessRequestsPage({ + params, + searchParams, +}: AccessRequestsPageProps) { + const [{ workspaceId }, query] = await Promise.all([params, searchParams]) + redirect( + getAccessRequestsSettingsHref({ kind: 'workspace', workspaceId }) + + getLegacyAccessRequestsSettingsQuery(query) ) } 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 63a09c805aa..c48bf2f221b 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx @@ -115,6 +115,21 @@ describe('WorkspaceSettingsSectionPage', () => { expect(mockSectionPrefetch).not.toHaveBeenCalled() }) + it('keeps Requests in the current workspace when the organization surface is enabled', async () => { + mockGetHostContext.mockResolvedValue({ + hostOrganizationId: 'org-target', + features: { organizationSearch: true }, + }) + + expect(await WorkspaceSettingsSectionPage(pageProps('requests'))).toBeTruthy() + expect(mockRedirect).not.toHaveBeenCalled() + expect(mockAuthorizeSection).toHaveBeenCalledWith({ + workspaceId: 'workspace-b', + userId: 'viewer-a', + section: 'requests', + }) + }) + it.each(Object.entries(UNIFIED_TO_ORGANIZATION_SECTION))( 'keeps %s in the workspace outside the organization rollout', async (section) => { @@ -137,7 +152,7 @@ describe('WorkspaceSettingsSectionPage', () => { it.each([ { organizationSearch: false, destination: '/workspace/workspace-b/settings/requests' }, - { organizationSearch: true, destination: '/o/org-target/settings/requests' }, + { organizationSearch: true, destination: '/workspace/workspace-b/settings/requests' }, ])( 'moves saved request review tabs to the canonical destination with org rollout=$organizationSearch', async ({ organizationSearch, destination }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index e85835b0dca..fa715bb3687 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -88,9 +88,9 @@ 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 AccessRequestsSettings = dynamic(() => + import('@/ee/access-requests/components/access-requests-settings').then( + (m) => m.AccessRequestsSettings ) ) const CustomBlocks = dynamic(() => @@ -191,7 +191,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 4656a24a217..b89e1795ad0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -25,7 +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: 'requests', label: 'Requests', section: 'workspace' }, { id: 'access-control', label: 'Permission groups', section: 'organization' }, { id: 'audit-logs', label: 'Audit logs', section: 'organization' }, { id: 'forks', label: 'Workspace forks', section: 'workspace' }, @@ -82,12 +82,12 @@ describe('unified settings navigation', () => { 'apikeys', 'sandboxes', 'recently-deleted', + 'requests', ]) expect(idsForSection('organization')).toEqual([ 'organization', 'usage', 'connected-accounts', - 'requests', 'access-control', 'audit-logs', 'whitelabeling', 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 b26c5ac8ccd..d2836aab364 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 @@ -68,6 +68,7 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI import { getWorkflowLockToggleIds } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils' import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks' import { RequestAccessModal } from '@/ee/access-requests/components/request-access-action' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { @@ -261,8 +262,12 @@ export const Panel = memo(function Panel() { if (usageExceeded) { if (usageLimitScope === 'member' && memberLimitTarget) { if (memberLimitTarget.pendingRequestId) { - const params = new URLSearchParams({ requestId: memberLimitTarget.pendingRequestId }) - router.push(`/workspace/${encodeURIComponent(workspaceId)}/access-requests?${params}`) + router.push( + getMyAccessRequestHref( + { kind: 'workspace', workspaceId }, + memberLimitTarget.pendingRequestId + ) + ) } else { setShowLimitRequest(true) } 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 6d4f0060a19..67c7002b03e 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 @@ -204,11 +204,11 @@ describe('workspace SettingsSidebar organization rollout', () => { }) it.each(['member', 'external'] as const)( - 'does not expose organization review to a workspace admin who is an org %s', + 'offers own requests to a workspace user who is an org %s', (role) => { hostContext = makeHostContext(role, false) renderSidebar() - expect(workspaceLink('requests')).toBeNull() + expect(workspaceLink('requests')).toHaveTextContent('Requests') } ) 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 d4576571666..3593e96d90b 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 @@ -167,7 +167,7 @@ export function SettingsSidebar({ ) } if (item.id === 'requests') { - return Boolean(hostContext.hostOrganizationId && isOrgAdminOrOwner) + return Boolean(hostContext.hostOrganizationId) } if (item.id === 'organization') { return Boolean( 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 02d119714c3..f79a8ee3582 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -27,7 +27,6 @@ import { Database, Files, Integration, - ListChecks, MoreHorizontal, PanelLeft, Pin, @@ -850,16 +849,6 @@ 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 9725a2e55db..ee85ba89a80 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -146,6 +146,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'requests', 'self-host', ]) }) @@ -303,7 +304,6 @@ describe('settings navigation boundaries', () => { 'data-drains', 'data-retention', 'organization', - 'requests', 'security', 'sso', 'usage', @@ -320,7 +320,6 @@ describe('settings navigation boundaries', () => { billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', - requests: 'requests', 'audit-logs': 'audit-logs', sso: 'sso', security: 'security', @@ -336,6 +335,7 @@ describe('settings navigation boundaries', () => { it('maps every workspace projection from its unified section', () => { expect(UNIFIED_TO_WORKSPACE_SECTION).toEqual({ + requests: 'requests', teammates: 'teammates', secrets: 'secrets', byok: 'byok', @@ -513,14 +513,14 @@ describe('settings navigation boundaries', () => { ).toBe(true) }) - it('limits request settings to organization admins while preserving self-hosted history', () => { + it('allows member requests while reserving management for organization admins', () => { expect( resolveOrganizationSectionAccess({ section: 'requests', isTargetOrganizationMember: true, isTargetOrganizationAdmin: false, }) - ).toBe('unavailable') + ).toBe('view') expect( resolveOrganizationSectionAccess({ section: 'requests', @@ -566,9 +566,10 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'requests', 'self-host', ], - mutable: [], + mutable: ['requests'], }, { permission: 'write' as const, @@ -583,9 +584,17 @@ describe('settings navigation boundaries', () => { 'inbox', 'recently-deleted', 'custom-blocks', + 'requests', 'self-host', ], - mutable: ['secrets', 'custom-tools', 'mcp', 'workflow-mcp-servers', 'recently-deleted'], + mutable: [ + 'secrets', + 'custom-tools', + 'mcp', + 'workflow-mcp-servers', + 'recently-deleted', + 'requests', + ], }, { permission: 'admin' as const, @@ -628,6 +637,7 @@ describe('settings navigation boundaries', () => { 'recently-deleted', 'forks', 'custom-blocks', + 'requests', 'self-host', ]) }) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 0a1c44c35d2..d6059f0d5d1 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -62,6 +62,7 @@ export type OrganizationSettingsSection = | 'whitelabeling' export type WorkspaceSettingsSection = + | 'requests' | 'teammates' | 'secrets' | 'byok' @@ -162,7 +163,7 @@ export interface UnifiedSettingsNavigationItem { docsLink?: string /** * The organization-scoped counterpart of this section. Declaring it marks the - * section as acting on the host organization rather than the workspace, which + * section without a workspace projection as acting on the host organization, which * routes it through the organization gate (host organization present, org-admin * viewer, plan entitlement) in both the sidebar and the section page. * @@ -389,11 +390,14 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] icon: ListChecks, unified: { id: 'requests', - description: 'Review requests across your organization.', - group: 'organization', - order: 3, + description: 'Track your requests and browse available access.', + group: 'workspace', + order: 12, organizationSection: 'requests', }, + planes: { + workspace: { id: 'requests', group: 'workspace', order: 12 }, + }, }, { label: 'Permission groups', @@ -873,7 +877,7 @@ export const WORKSPACE_SETTINGS_ITEMS: SettingsNavigationItem = new Set( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.unified?.organizationSection ? [entry.unified.id] : [] + entry.unified?.organizationSection && !entry.planes?.workspace ? [entry.unified.id] : [] ) ) @@ -991,7 +995,7 @@ export const UNIFIED_TO_ORGANIZATION_SECTION: Readonly< Partial> > = Object.fromEntries( SETTINGS_SECTION_REGISTRY.flatMap((entry) => - entry.unified?.organizationSection + entry.unified?.organizationSection && !entry.planes?.workspace ? [[entry.unified.id, entry.unified.organizationSection] as const] : [] ) @@ -1022,7 +1026,8 @@ export function resolveOrganizationSectionAccess({ }: ResolveOrganizationSectionAccessOptions): OrganizationSectionAccess { if (!isTargetOrganizationMember) return 'unavailable' if (section === 'search-mcp' || section === 'recently-deleted') return 'view' - if (section === 'members') return isTargetOrganizationAdmin ? 'manage' : 'view' + if (section === 'members' || section === 'requests') + return isTargetOrganizationAdmin ? 'manage' : 'view' return isTargetOrganizationAdmin ? 'manage' : 'unavailable' } @@ -1190,6 +1195,7 @@ export interface ResolvedWorkspaceNavigationItem } const WORKSPACE_MUTATION_PERMISSION: Record = { + requests: 'read', teammates: 'admin', secrets: 'write', byok: 'admin', @@ -1214,9 +1220,9 @@ export function canMutateWorkspaceSettingsSection( section: WorkspaceSettingsSection, capabilities: WorkspaceMutationCapabilities ): boolean { - return WORKSPACE_MUTATION_PERMISSION[section] === 'admin' - ? capabilities.canAdmin - : capabilities.canEdit + const permission = WORKSPACE_MUTATION_PERMISSION[section] + if (permission === 'read') return true + return permission === 'admin' ? capabilities.canAdmin : capabilities.canEdit } export function resolveWorkspaceNavigation({ diff --git a/apps/sim/ee/access-requests/README.md b/apps/sim/ee/access-requests/README.md index 8031a07b5d7..98345bd6e61 100644 --- a/apps/sim/ee/access-requests/README.md +++ b/apps/sim/ee/access-requests/README.md @@ -1,6 +1,6 @@ # Permission access requests -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. +Members request access from locked features or the block picker and track their requests in **Settings → Requests** on both workspace and organization surfaces. **My requests** shows the current scope's history, including the member's credit-limit requests, and **Browse access** lists additional requestable access. Organization administrators also have **Review requests** for the organization-wide queue. Saved requester links and authenticated email links resolve to settings; the standalone entry remains available outside the organization Search rollout. Requests are independent of the Enterprise permission-group settings because the same queue handles increases to an administrator-set member credit cap. ## Deployment diff --git a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/access-requests-settings.test.tsx similarity index 75% rename from apps/sim/ee/access-requests/components/my-access-requests.test.tsx rename to apps/sim/ee/access-requests/components/access-requests-settings.test.tsx index c2535b7e161..a263c976de2 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/access-requests-settings.test.tsx @@ -5,7 +5,8 @@ import { act, type ComponentProps } 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' -import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests' +import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' +import { AccessRequestsSettings } from '@/ee/access-requests/components/access-requests-settings' const mocks = vi.hoisted(() => ({ mine: vi.fn(), @@ -13,8 +14,17 @@ const mocks = vi.hoisted(() => ({ cancel: vi.fn(), workspace: vi.fn(), hosted: true, + review: vi.fn(), + push: vi.fn(), url: vi.fn(), })) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), + usePathname: () => '/workspace/workspace/settings/requests', +})) +vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({ + OrganizationAccessRequests: mocks.review, +})) vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({ hosted: mocks.hosted }), })) @@ -71,12 +81,16 @@ describe('compact requester history', () => { }) const render = ( searchParams = '', - props: Partial> = {} + props: Partial> = {} ) => act(() => root.render( - + + + + + ) ) @@ -93,7 +107,7 @@ describe('compact requester history', () => { const organizationScope = { kind: 'organization', organizationId: 'organization' } as const render('', { scope: organizationScope }) expect(mocks.mine).toHaveBeenCalledWith(organizationScope, 0, undefined, true) - expect(container.textContent).toContain('Organization requests') + expect(container.textContent).not.toContain('Workspace:') expect(container.textContent).not.toContain('Your workspaces') expect(container.textContent).not.toContain('Back to Sim') }) @@ -103,8 +117,12 @@ describe('compact requester history', () => { scope: { kind: 'organization', organizationId: 'organization' }, standalone: true, }) - const back = container.querySelector('a[href="/home"]') - expect(back?.textContent).toBe('Back to Sim') + const back = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Back to Sim' + ) + expect(back).toBeDefined() + act(() => back!.click()) + expect(mocks.push).toHaveBeenCalledWith('/home') }) it('loads a deep-linked request independently of the retained list page', () => { render('?page=3&requestId=request') @@ -159,7 +177,7 @@ describe('compact requester history', () => { expect(container.querySelector('[aria-label="Access request views"]')).toBeNull() await vi.waitFor(() => expect(mocks.url).toHaveBeenLastCalledWith( - expect.objectContaining({ queryString: '?search=slack' }) + expect.objectContaining({ queryString: '?view=requests&search=slack' }) ) ) expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) @@ -244,22 +262,50 @@ describe('compact requester history', () => { isSuccess: true, }) render('?view=catalog') - expect(container.textContent).toContain('New requests are paused') + expect(container.textContent).toContain('Your organization has paused new requests.') 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', () => { + it('uses the settings shell context without repeating scope notices', () => { render() - expect(container.textContent).toContain('Workspace: Design') - expect(container.textContent).toContain('Includes your organization credit limit requests.') + expect(container.textContent).not.toContain('Workspace: Design') + expect(container.textContent).not.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') + it('does not mount reviewer queries for members even with a forged review view', () => { + render('?view=review&request-id=other-request') + expect(mocks.review).not.toHaveBeenCalled() + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, undefined, true) + expect(container.textContent).not.toContain('Review requests') + expect(container.textContent).not.toContain('Allow requests') + }) + + it('preserves the default administrator review destination and exposes My requests', async () => { + render('', { reviewOrganizationId: 'organization' }) + expect(mocks.review).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: 'organization' }), + undefined + ) + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, undefined, false) + expect( + container.querySelector('[role="radio"][value="review"]')?.getAttribute('aria-checked') + ).toBe('true') + await act(async () => + container.querySelector('[role="radio"][value="requests"]')!.click() + ) + expect(mocks.mine).toHaveBeenLastCalledWith(scope, 0, undefined, true) + await vi.waitFor(() => + expect(mocks.url).toHaveBeenLastCalledWith( + expect.objectContaining({ queryString: '?view=requests' }) + ) + ) + }) + + it('opens an administrator own-request deep link without mounting the review queue', () => { + mocks.mine.mockImplementation((_scope, _offset, id) => successful(id ? [] : [request])) + render('?view=requests&requestId=missing', { reviewOrganizationId: 'organization' }) + expect(mocks.review).not.toHaveBeenCalled() + expect(container.textContent).toContain('Slack') }) }) diff --git a/apps/sim/ee/access-requests/components/access-requests-settings.tsx b/apps/sim/ee/access-requests/components/access-requests-settings.tsx new file mode 100644 index 00000000000..60c9478888b --- /dev/null +++ b/apps/sim/ee/access-requests/components/access-requests-settings.tsx @@ -0,0 +1,231 @@ +'use client' + +import { Chip, ChipSwitch, ChipTag } from '@sim/emcn' +import { Lock } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import { SettingsPanel } from '@/components/settings/settings-panel' +import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' +import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' +import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests' +import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' +import { + accessRequestSettingsSearchParams, + accessRequestUrlOptions, +} from '@/ee/access-requests/components/search-params' +import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' +import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +import { + ACCESS_REQUEST_PAGE_SIZE, + useDiscoverAccessRequests, + useMyAccessRequests, +} from '@/hooks/queries/access-requests' +import { useDebounce } from '@/hooks/use-debounce' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +interface AccessRequestsSettingsProps { + scope: AccessRequestScope + reviewOrganizationId?: string + standalone?: boolean +} + +export function AccessRequestsSettings({ + scope, + reviewOrganizationId, + standalone = false, +}: AccessRequestsSettingsProps) { + const router = useRouter() + const actions = standalone + ? [{ text: 'Back to Sim', onSelect: () => router.push(APP_ENTRY_PATH) }] + : undefined + const [{ view: selectedView, search, page, requestId }, setParams] = useQueryStates( + accessRequestSettingsSearchParams, + accessRequestUrlOptions + ) + const view = + selectedView === 'review' && !reviewOrganizationId + ? 'requests' + : (selectedView ?? (reviewOrganizationId ? 'review' : 'requests')) + const setSearch = useDebouncedSearchSetter((value, options) => + setParams({ search: value, page: 0 }, options) + ) + const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) + 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: 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) + return ( +
+ {(showCatalog || reviewOrganizationId) && ( + + void setParams({ view: value, page: 0, requestId: null, 'request-id': null }) + } + /> + )} + {view === 'review' && reviewOrganizationId ? ( + + ) : ( + + {view === 'requests' && requestsPaused && ( +

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

+ )} + {searchPending || currentQuery.isPending ? ( + + Loading... + + ) : currentQuery.isError ? ( + void currentQuery.refetch()} + /> + ) : view === 'requests' ? ( +
+ {requests.data?.requests.length === 0 && ( + + {page > 0 + ? 'No requests on this page. Go to the previous page to see your requests.' + : 'No requests yet. Requests you send will appear here.'} + + )} + {requests.data?.requests.map((request) => ( + {ACCESS_REQUEST_STATUS_LABELS[request.status]} + } + onClick={() => void setParams({ requestId: request.id }, { history: 'push' })} + clickLabel={`View request for ${request.targetLabel}`} + /> + ))} +
+ ) : !catalog.data?.enabled ? ( + + {requestsPaused + ? 'Your organization has paused new requests. Your request history is still available.' + : 'Access requests are available in organization workspaces.'} + + ) : ( +
+ {catalog.data.entries.length === 0 && ( + +
+ + {debouncedSearch + ? 'No matching results. Try another search.' + : page > 0 + ? 'No more access to request.' + : `Nothing to request in this ${scope.kind}.`} + + {debouncedSearch && setSearch('')}>Clear search} +
+
+ )} + {catalog.data.entries.map((entry) => ( + } + iconVariant='plain' + title={entry.label} + badge={ + entry.state === 'allowed' ? ( + Available + ) : undefined + } + trailing={ + entry.state === 'requestable' ? ( + + void setParams({ requestId }, { history: 'push' }) + } + /> + ) : undefined + } + /> + ))} +
+ )} + {(page > 0 || currentQuery.data?.hasMore) && ( +
+ void setParams({ page: page - 1 })} + > + Previous + + Page {page + 1} + void setParams({ page: page + 1 })} + > + Next + +
+ )} + {requestId && ( + void setParams({ requestId: null })} + /> + )} +
+ )} +
+ ) +} diff --git a/apps/sim/ee/access-requests/components/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx deleted file mode 100644 index 12b72bac03f..00000000000 --- a/apps/sim/ee/access-requests/components/my-access-requests.tsx +++ /dev/null @@ -1,257 +0,0 @@ -'use client' - -import { useRef } from 'react' -import { - Chip, - ChipInput, - ChipLink, - ChipSwitch, - ChipTag, - cn, - scrollFadeAttributes, - scrollFadeClass, - useScrollEdges, -} from '@sim/emcn' -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 { APP_ENTRY_PATH } from '@/lib/navigation/paths' -import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { MyAccessRequestDetails } from '@/ee/access-requests/components/my-access-request-details' -import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { - accessRequestSearchParams, - accessRequestUrlOptions, -} from '@/ee/access-requests/components/search-params' -import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' -import { - ACCESS_REQUEST_PAGE_SIZE, - useDiscoverAccessRequests, - useMyAccessRequests, -} from '@/hooks/queries/access-requests' -import { useWorkspaceHostContextQuery } from '@/hooks/queries/workspace-host' -import { useDebounce } from '@/hooks/use-debounce' -import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' - -interface MyAccessRequestsProps { - scope: AccessRequestScope - standalone?: boolean -} - -export function MyAccessRequests({ scope, standalone = false }: MyAccessRequestsProps) { - const scrollRef = useRef(null) - const scrollEdges = useScrollEdges(scrollRef) - const { hosted } = useDeploymentShape() - const workspace = useWorkspaceHostContextQuery( - scope.kind === 'workspace' ? scope.workspaceId : '' - ) - const [{ view, search, page, requestId }, setParams] = useQueryStates( - accessRequestSearchParams, - accessRequestUrlOptions - ) - const setSearch = useDebouncedSearchSetter((value, options) => - setParams({ search: value, page: 0 }, options) - ) - const debouncedSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - 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: 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 ( -
-
-
-
-

My access requests

-

{scopeLabel}

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

- Includes your organization credit limit requests. -

- )} -
- {standalone && Back to Sim} -
- {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 && ( - setSearch(event.target.value)} - maxLength={ACCESS_REQUEST_MAX_SEARCH_LENGTH} - placeholder='Search features, integrations, and models...' - aria-label='Search access catalog' - /> - )} - {searchPending || currentQuery.isPending ? ( -

- Loading... -

- ) : currentQuery.isError ? ( - void currentQuery.refetch()}>Try again} - /> - ) : view === 'requests' ? ( -
- {requests.data?.requests.length === 0 && ( - 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.' - } - /> - )} - {requests.data?.requests.map((request) => ( - {ACCESS_REQUEST_STATUS_LABELS[request.status]} - } - onClick={() => void setParams({ requestId: request.id }, { history: 'push' })} - clickLabel={`View request for ${request.targetLabel}`} - /> - ))} -
- ) : !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) => ( - } - iconVariant='plain' - title={entry.label} - badge={ - entry.state === 'allowed' ? ( - Available - ) : undefined - } - trailing={ - entry.state === 'requestable' ? ( - - void setParams({ requestId }, { history: 'push' }) - } - /> - ) : undefined - } - /> - ))} -
- )} - {(page > 0 || currentQuery.data?.hasMore) && ( -
- void setParams({ page: page - 1 })} - > - Previous - - Page {page + 1} - void setParams({ page: page + 1 })} - > - Next - -
- )} -
- {requestId && ( - void setParams({ requestId: null })} - /> - )} -
- ) -} 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 e989f8cb369..40ed426fa99 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -1,8 +1,8 @@ 'use client' -import { Chip, ChipDropdown, ChipInput, ChipSwitch, ChipTag, toast } from '@sim/emcn' -import { Search } from '@sim/emcn/icons' +import { Chip, ChipDropdown, ChipSwitch, ChipTag, toast } from '@sim/emcn' import { useQueryStates } from 'nuqs' +import type { SettingsAction } from '@/components/settings/settings-header' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { SettingsEmptyState, @@ -32,16 +32,15 @@ import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' interface OrganizationAccessRequestsProps { organizationId: string - standalone?: boolean + actions?: SettingsAction[] } export function OrganizationAccessRequests({ organizationId, - standalone = false, + actions, }: OrganizationAccessRequestsProps) { const [params, setParams] = useQueryStates(accessReviewSearchParams, { ...accessRequestUrlOptions, - urlKeys: { 'request-id': standalone ? 'requestId' : 'request-id' }, }) const searchTerm = params['request-search'] const setSearchTerm = useDebouncedSearchSetter((value, options) => @@ -200,20 +199,9 @@ export function OrganizationAccessRequests({ maxLength: ACCESS_REQUEST_MAX_SEARCH_LENGTH, } - return standalone ? ( -
- search.onChange(event.target.value)} - placeholder={search.placeholder} - maxLength={search.maxLength} - aria-label='Search requests' - autoComplete='off' - /> + return ( + {content} -
- ) : ( - {content} + ) } diff --git a/apps/sim/ee/access-requests/components/request-access-action.test.tsx b/apps/sim/ee/access-requests/components/request-access-action.test.tsx index 259ad7a7c41..ad121e6870c 100644 --- a/apps/sim/ee/access-requests/components/request-access-action.test.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.test.tsx @@ -118,7 +118,7 @@ describe('request form lifecycle', () => { expect(document.querySelector('textarea')).toBeNull() clickDialogButton('View request') expect(mocks.push).toHaveBeenCalledWith( - '/workspace/workspace/access-requests?requestId=pending%2Frequest' + '/workspace/workspace/settings/requests?view=requests&requestId=pending%2Frequest' ) expect(mocks.create).not.toHaveBeenCalled() expect(document.querySelector('[role="dialog"]')).toBeNull() @@ -149,7 +149,7 @@ describe('request form lifecycle', () => { }) clickDialogButton('View request') expect(mocks.push).toHaveBeenCalledWith( - '/access-requests?requestId=limit-request&organizationId=organization' + '/access-requests?view=requests&requestId=limit-request&organizationId=organization' ) expect(mocks.create).not.toHaveBeenCalled() }) diff --git a/apps/sim/ee/access-requests/components/request-access-action.tsx b/apps/sim/ee/access-requests/components/request-access-action.tsx index c7bcb7e3f0d..8de1715cf91 100644 --- a/apps/sim/ee/access-requests/components/request-access-action.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.tsx @@ -16,6 +16,7 @@ import { } from '@sim/emcn' import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' import { getAccessRequestTargetKey } from '@/ee/access-requests/lib/targets' import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' @@ -28,16 +29,6 @@ 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, @@ -61,7 +52,7 @@ export function RequestAccessAction({ return ( View request @@ -218,7 +209,7 @@ export function RequestAccessModal({ : pendingRequestId ? () => { if (onViewRequest) onViewRequest(pendingRequestId) - else router.push(accessRequestHref(scope, pendingRequestId)) + else router.push(getMyAccessRequestHref(scope, pendingRequestId)) onClose() } : submit, diff --git a/apps/sim/ee/access-requests/components/search-params.ts b/apps/sim/ee/access-requests/components/search-params.ts index 208950b9dbf..67187346d03 100644 --- a/apps/sim/ee/access-requests/components/search-params.ts +++ b/apps/sim/ee/access-requests/components/search-params.ts @@ -37,6 +37,13 @@ export const accessRequestSearchParams = { page: accessRequestPageParser, } as const +/** An omitted view preserves the review landing page for administrators. */ +export const accessRequestSettingsSearchParams = { + ...accessRequestSearchParams, + view: parseAsStringLiteral(['requests', 'catalog', 'review'] as const), + 'request-id': accessRequestIdParser, +} as const + export const accessReviewSearchParams = { 'access-view': parseAsStringLiteral(['groups', 'requests'] as const).withDefault('groups'), 'request-id': accessRequestIdParser, @@ -57,7 +64,10 @@ export const accessRequestUrlOptions = { history: 'replace', clearOnDefault: tru export const accessRequestEntrySearchParams = { ...accessRequestSearchParams, organizationId: accessRequestIdParser, - view: parseAsStringLiteral(['requests', 'catalog', 'admin'] as const).withDefault('requests'), + view: parseAsStringLiteral(['requests', 'catalog', 'review', 'admin'] as const).withDefault( + 'requests' + ), + 'request-id': accessReviewSearchParams['request-id'], 'request-page': accessReviewSearchParams['request-page'], 'request-search': accessReviewSearchParams['request-search'], 'request-status': accessReviewSearchParams['request-status'], diff --git a/apps/sim/ee/access-requests/lib/navigation.test.ts b/apps/sim/ee/access-requests/lib/navigation.test.ts index 46ace5416a7..083266c0fbc 100644 --- a/apps/sim/ee/access-requests/lib/navigation.test.ts +++ b/apps/sim/ee/access-requests/lib/navigation.test.ts @@ -1,6 +1,88 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation' +import { + getAccessRequestsSettingsHref, + getLegacyAccessRequestsQuery, + getLegacyAccessRequestsSettingsQuery, + getMyAccessRequestHref, +} from '@/ee/access-requests/lib/navigation' + +describe('requests settings navigation', () => { + it('keeps workspace links in workspace settings and organization links in the resolver', () => { + expect(getAccessRequestsSettingsHref({ kind: 'workspace', workspaceId: 'workspace/a' })).toBe( + '/workspace/workspace%2Fa/settings/requests' + ) + expect(getAccessRequestsSettingsHref({ kind: 'organization', organizationId: 'org-a' })).toBe( + '/o/org-a/settings/requests' + ) + expect( + getMyAccessRequestHref({ kind: 'workspace', workspaceId: 'workspace/a' }, 'request/a') + ).toBe('/workspace/workspace%2Fa/settings/requests?view=requests&requestId=request%2Fa') + expect( + getMyAccessRequestHref({ kind: 'organization', organizationId: 'org/a' }, 'request/a') + ).toBe('/access-requests?view=requests&requestId=request%2Fa&organizationId=org%2Fa') + }) + + it('preserves requester selection, search and pagination with an explicit requester view', () => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + requestId: 'request/a', + search: 'Tables and files', + page: '2', + organizationId: 'org-a', + }) + ) + expect(Object.fromEntries(query)).toEqual({ + view: 'requests', + requestId: 'request/a', + search: 'Tables and files', + page: '2', + }) + }) + + it.each(['admin', 'review'])( + 'preserves %s review links with canonical selection and filters', + (view) => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + view, + requestId: 'request/a', + 'request-search': 'Tables', + 'request-page': '2', + 'request-status': 'declined', + }) + ) + expect(Object.fromEntries(query)).toEqual({ + view: 'review', + 'request-id': 'request/a', + 'request-search': 'Tables', + 'request-page': '2', + 'request-status': 'declined', + }) + } + ) + + it('prefers canonical review selection when both selection parameters are present', () => { + const query = new URLSearchParams( + getLegacyAccessRequestsSettingsQuery({ + view: 'review', + requestId: 'old', + 'request-id': 'current', + }) + ) + expect(query.get('request-id')).toBe('current') + expect(query.has('requestId')).toBe(false) + }) + + it('preserves Browse access and parses invalid state through shared parsers', () => { + expect(getLegacyAccessRequestsSettingsQuery({ view: 'catalog', search: 'Tables' })).toBe( + '?view=catalog&search=Tables' + ) + expect( + getLegacyAccessRequestsSettingsQuery({ view: 'invalid', page: '-1', search: 'a'.repeat(201) }) + ).toBe('?view=requests') + }) +}) describe('legacy access request navigation', () => { it('preserves review filters and selection without leaking permission group view state', () => { diff --git a/apps/sim/ee/access-requests/lib/navigation.ts b/apps/sim/ee/access-requests/lib/navigation.ts index cb899b527d0..c3acfd40498 100644 --- a/apps/sim/ee/access-requests/lib/navigation.ts +++ b/apps/sim/ee/access-requests/lib/navigation.ts @@ -1,6 +1,52 @@ import { omit } from '@sim/utils/object' import { createLoader, createSerializer } from 'nuqs/server' -import { accessReviewSearchParams } from '@/ee/access-requests/components/search-params' +import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' +import { organizationRoutes } from '@/lib/navigation/paths' +import { + accessRequestEntrySearchParams, + accessRequestSettingsSearchParams, + accessReviewSearchParams, +} from '@/ee/access-requests/components/search-params' + +const loadEntrySearchParams = createLoader(accessRequestEntrySearchParams) +const serializeSettingsSearchParams = createSerializer({ + ...accessReviewSearchParams, + ...accessRequestSettingsSearchParams, +}) + +export function getAccessRequestsSettingsHref(scope: AccessRequestScope): string { + return scope.kind === 'workspace' + ? `/workspace/${encodeURIComponent(scope.workspaceId)}/settings/requests` + : organizationRoutes(scope.organizationId).settingsSection('requests') +} + +/** Organization links use the entry resolver so they also work outside the Search rollout. */ +export function getMyAccessRequestHref(scope: AccessRequestScope, requestId: string): string { + const pathname = + scope.kind === 'workspace' ? getAccessRequestsSettingsHref(scope) : '/access-requests' + const query = serializeSettingsSearchParams({ view: 'requests', requestId }) + const params = new URLSearchParams(query) + if (scope.kind === 'organization') params.set('organizationId', scope.organizationId) + return `${pathname}?${params}` +} + +/** Moves requester and email links into settings without changing their selected view or scope. */ +export function getLegacyAccessRequestsSettingsQuery( + searchParams: Record +): string { + const params = loadEntrySearchParams(searchParams) + const review = params.view === 'admin' || params.view === 'review' + return serializeSettingsSearchParams({ + view: review ? 'review' : params.view === 'catalog' ? 'catalog' : 'requests', + search: params.search, + page: params.page, + requestId: review ? null : params.requestId, + 'request-id': review ? (params['request-id'] ?? params.requestId) : null, + 'request-search': params['request-search'], + 'request-page': params['request-page'], + 'request-status': params['request-status'], + }) +} const loadReviewSearchParams = createLoader(accessReviewSearchParams) const serializeReviewSearchParams = createSerializer( diff --git a/apps/sim/ee/access-requests/lib/notifications.test.ts b/apps/sim/ee/access-requests/lib/notifications.test.ts index 2ecee67538f..359bf588f27 100644 --- a/apps/sim/ee/access-requests/lib/notifications.test.ts +++ b/apps/sim/ee/access-requests/lib/notifications.test.ts @@ -218,7 +218,7 @@ describe('access request administrator notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'created', requestLink: - 'https://sim.example/access-requests?organizationId=organization-one&view=admin&requestId=request-one', + 'https://sim.example/access-requests?organizationId=organization-one&view=review&request-id=request-one', }) expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ to: 'current-admin@example.com', emailType: 'transactional' }) @@ -270,7 +270,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace-one/settings/requests?view=requests&requestId=request-one', }) expect(mockSend).toHaveBeenCalledWith( expect.objectContaining({ to: 'requester@example.com', emailType: 'transactional' }) @@ -294,7 +294,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace-one/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace-one/settings/requests?view=requests&requestId=request-one', }) }) @@ -307,7 +307,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledExactlyOnceWith({ kind: 'decided', requestLink: - 'https://sim.example/workspace/workspace%2Fwith%3Fcharacters/access-requests?requestId=request-one', + 'https://sim.example/workspace/workspace%2Fwith%3Fcharacters/settings/requests?view=requests&requestId=request-one', }) }) @@ -350,7 +350,7 @@ describe('access request requester notifications', () => { expect(mockRender).toHaveBeenCalledWith({ kind: 'decided', requestLink: - 'https://sim.example/access-requests?organizationId=organization-one&requestId=request-one', + 'https://sim.example/access-requests?view=requests&requestId=request-one&organizationId=organization-one', }) }) diff --git a/apps/sim/ee/access-requests/lib/notifications.ts b/apps/sim/ee/access-requests/lib/notifications.ts index 0ef80c6ae7f..28d2bf48012 100644 --- a/apps/sim/ee/access-requests/lib/notifications.ts +++ b/apps/sim/ee/access-requests/lib/notifications.ts @@ -14,6 +14,7 @@ import { import { getBaseUrl } from '@/lib/core/utils/urls' import { hasEmailService, sendEmail } from '@/lib/messaging/email/mailer' import { loadAccessRequestMembership } from '@/ee/access-requests/lib/application/authorization' +import { getMyAccessRequestHref } from '@/ee/access-requests/lib/navigation' import { PERMISSION_ACCESS_REQUEST_CREATED_EVENT, PERMISSION_ACCESS_REQUEST_DECIDED_EVENT, @@ -70,16 +71,22 @@ async function requesterHasCurrentAccess(request: NotificationRequest): Promise< } function requestLink(request: NotificationRequest, kind: 'created' | 'decided'): string { - const requesterWorkspaceId = kind === 'decided' ? request.workspaceId : null - const url = new URL( - requesterWorkspaceId - ? `/workspace/${encodeURIComponent(requesterWorkspaceId)}/access-requests` - : '/access-requests', - getBaseUrl() - ) - if (!requesterWorkspaceId) url.searchParams.set('organizationId', request.organizationId) - if (kind === 'created') url.searchParams.set('view', 'admin') - url.searchParams.set('requestId', request.id) + if (kind === 'decided') { + return new URL( + getMyAccessRequestHref( + request.workspaceId + ? { kind: 'workspace', workspaceId: request.workspaceId } + : { kind: 'organization', organizationId: request.organizationId }, + request.id + ), + getBaseUrl() + ).toString() + } + + const url = new URL('/access-requests', getBaseUrl()) + url.searchParams.set('organizationId', request.organizationId) + url.searchParams.set('view', 'review') + url.searchParams.set('request-id', request.id) return url.toString() } diff --git a/apps/sim/lib/navigation/paths.test.ts b/apps/sim/lib/navigation/paths.test.ts index 84c81a68131..d966ba8a8ea 100644 --- a/apps/sim/lib/navigation/paths.test.ts +++ b/apps/sim/lib/navigation/paths.test.ts @@ -32,7 +32,6 @@ describe('organizationRoutes', () => { expect(routes.root).toBe('/o/org-1') expect(routes.home).toBe('/o/org-1/home') expect(routes.search).toBe('/o/org-1/search') - expect(routes.accessRequests).toBe('/o/org-1/access-requests') expect(routes.settings).toBe('/o/org-1/settings') expect(routes.settingsSection('members')).toBe('/o/org-1/settings/members') expect(routes.chat('c-1')).toBe('/o/org-1/chat/c-1') diff --git a/apps/sim/lib/navigation/paths.ts b/apps/sim/lib/navigation/paths.ts index 17bf3036b39..b788834eb40 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -36,7 +36,6 @@ export function organizationRoutes(organizationId: string) { home: `${root}/home`, search: `${root}/search`, integrations: `${root}/integrations`, - accessRequests: `${root}/access-requests`, skills: `${root}/skills`, settings: `${root}/settings`, searchProvider: (connectorType: string) => 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 f48fc89e1ed..2ca53d86e43 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.test.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.test.ts @@ -48,9 +48,9 @@ vi.mock('@/components/settings/navigation', () => ({ billing: 'billing', 'connected-accounts': 'connected-accounts', 'access-control': 'access-control', - requests: 'requests', }, UNIFIED_TO_WORKSPACE_SECTION: { + requests: 'requests', secrets: 'secrets', forks: 'forks', 'custom-blocks': 'custom-blocks', @@ -305,25 +305,16 @@ describe('authorizeWorkspaceSettingsSection', () => { expect(mocks.isOrganizationOnEnterprisePlan).toHaveBeenCalledTimes(1) }) - it('opens organization request settings without querying Enterprise entitlement', async () => { + it('opens own requests for workspace members without organization administration or Enterprise entitlement', async () => { mocks.checkWorkspaceAccess.mockResolvedValue(ORGANIZATION_ACCESS) - mocks.isOrganizationOnEnterprisePlan.mockResolvedValue(false) + mocks.resolveWorkspaceNavigation.mockReturnValue([{ id: 'requests' }]) + mocks.canOpenOrganizationSettingsSection.mockResolvedValue(false) await expect(authorize('requests')).resolves.toEqual({ allowed: true }) - expect(mocks.canOpenOrganizationSettingsSection).toHaveBeenCalledWith( - 'organization-1', - 'viewer-1', - 'requests' - ) + expect(mocks.canOpenOrganizationSettingsSection).not.toHaveBeenCalled() 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) + it('does not offer request settings in a personal workspace', async () => { await expect(authorize('requests')).resolves.toEqual({ allowed: false, disposition: 'redirect-general', diff --git a/apps/sim/lib/settings/application/workspace-section-access.ts b/apps/sim/lib/settings/application/workspace-section-access.ts index 04be2103895..6072a7541c0 100644 --- a/apps/sim/lib/settings/application/workspace-section-access.ts +++ b/apps/sim/lib/settings/application/workspace-section-access.ts @@ -41,6 +41,9 @@ async function authorizeWorkspaceSection( }, permission: NonNullable>['permission']> ): Promise { + if (section === 'requests' && !workspace.organizationId) { + return { allowed: false, disposition: 'redirect-general' } + } const [accessControl, forksAvailable, customBlocksAvailable] = await Promise.all([ workspaceSectionUsesPermissionConfig(section) ? resolveVerifiedUserAccessControlContext(