Skip to content

Commit 2b4804c

Browse files
committed
fix(access-requests): clarify request navigation and empty states
1 parent fe7d6d3 commit 2b4804c

33 files changed

Lines changed: 780 additions & 240 deletions

‎apps/sim/app/access-requests/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
4141
return (
4242
<EmptyState
4343
title='Choose an organization'
44-
description='Open My access requests from your workspace menu.'
44+
description='Open My access requests from your profile menu in a workspace.'
4545
action={<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>}
4646
/>
4747
)

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.test.tsx‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,12 +110,18 @@ async function selectSettings() {
110110
}
111111

112112
describe('OrganizationFooter settings navigation', () => {
113-
it('keeps only Settings and Sign out in the organization profile menu', async () => {
113+
it('offers personal request history in the organization profile menu', async () => {
114114
await openProfileMenu()
115115
expect(
116116
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
117-
).toEqual(['Settings', 'Sign out'])
117+
).toEqual(['Settings', 'My access requests', 'Sign out'])
118118
expect(document.querySelector('[role="separator"]')).toBeNull()
119+
const requests = document.querySelector<HTMLAnchorElement>(
120+
'a[href="/access-requests?organizationId=org-1"]'
121+
)
122+
expect(requests).not.toBeNull()
123+
await act(async () => requests!.click())
124+
expect(mockPush).toHaveBeenCalledWith('/access-requests?organizationId=org-1')
119125
})
120126

121127
it('navigates immediately when settings are clean', async () => {

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
'use client'
22

33
import type { ComponentProps } from 'react'
4+
import { ListChecks } from '@sim/emcn/icons'
45
import { useRouter } from 'next/navigation'
6+
import { createSerializer } from 'nuqs/server'
57
import { organizationRoutes } from '@/lib/navigation/paths'
68
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
79
import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer'
10+
import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params'
11+
12+
const serializeAccessRequestParams = createSerializer(accessRequestEntrySearchParams)
813

914
interface OrganizationFooterProps
1015
extends Omit<
@@ -16,13 +21,23 @@ export function OrganizationFooter(props: OrganizationFooterProps) {
1621
const { organization } = useOrganizationContext()
1722
const router = useRouter()
1823
const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general')
24+
const accessRequestsHref = serializeAccessRequestParams('/access-requests', {
25+
organizationId: organization.id,
26+
})
1927

2028
return (
2129
<SidebarFooter
2230
{...props}
2331
accountSettingsHref={accountSettingsHref}
2432
onOpenAccountSettings={() => router.push(accountSettingsHref)}
25-
navigationLinks={[]}
33+
navigationLinks={[
34+
{
35+
label: 'My access requests',
36+
icon: ListChecks,
37+
href: accessRequestsHref,
38+
onNavigate: () => router.push(accessRequestsHref),
39+
},
40+
]}
2641
/>
2742
)
2843
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/** @vitest-environment node */
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const mocks = vi.hoisted(() => ({ session: vi.fn(), authorize: vi.fn() }))
5+
vi.mock('next/navigation', () => ({
6+
redirect: (href: string) => {
7+
throw new Error(`redirect:${href}`)
8+
},
9+
notFound: () => {
10+
throw new Error('not-found')
11+
},
12+
}))
13+
vi.mock('@/lib/auth', () => ({ getSession: mocks.session }))
14+
vi.mock('@/lib/settings/application/organization-section-access', () => ({
15+
authorizeOrganizationSettingsSection: mocks.authorize,
16+
}))
17+
vi.mock('@/components/settings/account-settings-renderer', () => ({
18+
AccountSettingsRenderer: () => null,
19+
}))
20+
vi.mock('@/components/settings/prefetch-standalone-general', () => ({
21+
prefetchStandaloneGeneral: vi.fn(),
22+
}))
23+
vi.mock('@/app/o/[organizationId]/settings/[section]/settings', () => ({
24+
OrganizationSettings: () => null,
25+
}))
26+
27+
import OrganizationSettingsSectionPage from '@/app/o/[organizationId]/settings/[section]/page'
28+
29+
describe('organization request settings routing', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks()
32+
mocks.session.mockResolvedValue({ user: { id: 'viewer' } })
33+
mocks.authorize.mockResolvedValue(true)
34+
})
35+
36+
it('renders the canonical request section only through the shared organization gate', async () => {
37+
const page = await OrganizationSettingsSectionPage({
38+
params: Promise.resolve({ organizationId: 'organization', section: 'requests' }),
39+
})
40+
expect(page.props.section).toBe('requests')
41+
expect(mocks.authorize).toHaveBeenCalledWith({
42+
organizationId: 'organization',
43+
userId: 'viewer',
44+
section: 'requests',
45+
})
46+
})
47+
48+
it('authorizes saved review tabs as Requests and preserves their selected request', async () => {
49+
await expect(
50+
OrganizationSettingsSectionPage({
51+
params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }),
52+
searchParams: Promise.resolve({
53+
'access-view': 'requests',
54+
'request-id': 'selected',
55+
'request-status': 'all',
56+
'group-id': 'old-group',
57+
}),
58+
})
59+
).rejects.toThrow(
60+
'redirect:/o/organization/settings/requests?request-id=selected&request-status=all'
61+
)
62+
expect(mocks.authorize).toHaveBeenCalledWith({
63+
organizationId: 'organization',
64+
userId: 'viewer',
65+
section: 'requests',
66+
})
67+
})
68+
69+
it('conceals requests from viewers rejected by the organization gate', async () => {
70+
mocks.authorize.mockResolvedValue(false)
71+
await expect(
72+
OrganizationSettingsSectionPage({
73+
params: Promise.resolve({ organizationId: 'organization', section: 'access-control' }),
74+
searchParams: Promise.resolve({ 'access-view': 'requests' }),
75+
})
76+
).rejects.toThrow('not-found')
77+
})
78+
})

‎apps/sim/app/o/[organizationId]/settings/[section]/page.tsx‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Metadata } from 'next'
44
import { notFound, redirect } from 'next/navigation'
55
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
66
import {
7+
getOrganizationSettingsHref,
78
getSettingsSectionMeta,
89
ORGANIZATION_SETTINGS_ITEMS,
910
} from '@/components/settings/navigation'
@@ -16,9 +17,11 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1617
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1718
import { OrganizationSettings } from '@/app/o/[organizationId]/settings/[section]/settings'
1819
import { resolveOrganizationSurfaceSection } from '@/app/o/[organizationId]/settings/navigation'
20+
import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation'
1921

2022
interface OrganizationSettingsSectionPageProps {
2123
params: Promise<{ organizationId: string; section: string }>
24+
searchParams?: Promise<Record<string, string | string[] | undefined>>
2225
}
2326

2427
export async function generateMetadata({
@@ -42,6 +45,7 @@ export async function generateMetadata({
4245
*/
4346
export default async function OrganizationSettingsSectionPage({
4447
params,
48+
searchParams,
4549
}: OrganizationSettingsSectionPageProps) {
4650
const { organizationId, section } = await params
4751
const routes = organizationRoutes(organizationId)
@@ -58,15 +62,23 @@ export default async function OrganizationSettingsSectionPage({
5862
}
5963

6064
if (resolved.plane === 'organization') {
65+
const legacyRequestsQuery = getLegacyAccessRequestsQuery(
66+
resolved.section,
67+
(await searchParams) ?? {}
68+
)
69+
const organizationSection = legacyRequestsQuery ? 'requests' : resolved.section
6170
if (
6271
!(await authorizeOrganizationSettingsSection({
6372
organizationId,
6473
userId: session.user.id,
65-
section: resolved.section,
74+
section: organizationSection,
6675
}))
6776
) {
6877
notFound()
6978
}
79+
if (legacyRequestsQuery) {
80+
redirect(getOrganizationSettingsHref(organizationId, 'requests', legacyRequestsQuery))
81+
}
7082
return <OrganizationSettings section={resolved.section} />
7183
}
7284

‎apps/sim/app/o/[organizationId]/settings/[section]/settings.tsx‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ const Billing = dynamic(() =>
4747
const AccessControl = dynamic(() =>
4848
import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
4949
)
50+
const OrganizationAccessRequests = dynamic(() =>
51+
import('@/ee/access-requests/components/organization-access-requests').then(
52+
(m) => m.OrganizationAccessRequests
53+
)
54+
)
5055
const AuditLogs = dynamic(() =>
5156
import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs)
5257
)
@@ -100,8 +105,13 @@ export function OrganizationSettings({ section }: OrganizationSettingsProps) {
100105
)}
101106
{section === 'billing' && <Billing scope='organization' organizationId={organizationId} />}
102107
{section === 'access-control' && (
103-
<AccessControl organizationId={organizationId} isOrganizationAdmin={viewer.isAdmin} />
108+
<AccessControl
109+
organizationId={organizationId}
110+
isOrganizationAdmin={viewer.isAdmin}
111+
requestsHref={getOrganizationSettingsHref(organizationId, 'requests')}
112+
/>
104113
)}
114+
{section === 'requests' && <OrganizationAccessRequests organizationId={organizationId} />}
105115
{section === 'audit-logs' && <AuditLogs organizationId={organizationId} />}
106116
{section === 'usage' && (
107117
<UsageMonitoring

‎apps/sim/app/o/[organizationId]/settings/navigation.test.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe('organization settings navigation', () => {
5050
{ ...enterprise, hasEnterprisePlan: false, governanceActive: false },
5151
available
5252
).map(({ id }) => id)
53-
).toEqual(['billing', 'members', 'recently-deleted', 'search-mcp'])
53+
).toEqual(['billing', 'members', 'recently-deleted', 'requests', 'search-mcp'])
5454
})
5555

5656
/**
@@ -65,7 +65,14 @@ describe('organization settings navigation', () => {
6565
{ ...enterprise, hasEnterprisePlan: false, governanceActive: true },
6666
available
6767
).map(({ id }) => id)
68-
).toEqual(['billing', 'members', 'recently-deleted', 'access-control', 'search-mcp'])
68+
).toEqual([
69+
'billing',
70+
'members',
71+
'recently-deleted',
72+
'requests',
73+
'access-control',
74+
'search-mcp',
75+
])
6976
})
7077

7178
it('honors individual self-hosted feature flags and hides billing when disabled', () => {
@@ -80,7 +87,15 @@ describe('organization settings navigation', () => {
8087
},
8188
available
8289
).map(({ id }) => id)
83-
).toEqual(['members', 'recently-deleted', 'sso', 'integrations', 'search-mcp', 'search-slack'])
90+
).toEqual([
91+
'members',
92+
'recently-deleted',
93+
'requests',
94+
'sso',
95+
'integrations',
96+
'search-mcp',
97+
'search-slack',
98+
])
8499
})
85100

86101
it('normalizes old section names and does not expose unsupported routes', () => {
@@ -107,6 +122,7 @@ describe('organization settings navigation', () => {
107122
'organization:usage',
108123
'organization:whitelabeling',
109124
'organization:recently-deleted',
125+
'organization:requests',
110126
'governance:audit-logs',
111127
'governance:access-control',
112128
'governance:sso',

‎apps/sim/app/workspace/[workspaceId]/settings/[section]/page.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ vi.mock('@/app/workspace/[workspaceId]/settings/navigation', () => ({
5252
'organization',
5353
'usage',
5454
'access-control',
55+
'requests',
5556
'audit-logs',
5657
'sso',
5758
'security',
@@ -134,6 +135,36 @@ describe('WorkspaceSettingsSectionPage', () => {
134135
}
135136
)
136137

138+
it.each([
139+
{ organizationSearch: false, destination: '/workspace/workspace-b/settings/requests' },
140+
{ organizationSearch: true, destination: '/o/org-target/settings/requests' },
141+
])(
142+
'moves saved request review tabs to the canonical destination with org rollout=$organizationSearch',
143+
async ({ organizationSearch, destination }) => {
144+
mockGetHostContext.mockResolvedValue({
145+
hostOrganizationId: 'org-target',
146+
features: { organizationSearch },
147+
})
148+
await expect(
149+
WorkspaceSettingsSectionPage({
150+
...pageProps('access-control'),
151+
searchParams: Promise.resolve({
152+
'access-view': 'requests',
153+
'request-id': 'selected',
154+
'request-status': 'all',
155+
'group-id': 'old-group',
156+
}),
157+
})
158+
).rejects.toThrow(`NEXT_REDIRECT:${destination}?request-id=selected&request-status=all`)
159+
expect(mockAuthorizeSection).toHaveBeenCalledWith({
160+
workspaceId: 'workspace-b',
161+
userId: 'viewer-a',
162+
section: 'requests',
163+
})
164+
expect(mockSectionPrefetch).not.toHaveBeenCalled()
165+
}
166+
)
167+
137168
it.each([undefined, { credentialGroups: true, knowledgeMemberAccess: true }])(
138169
'keeps settings in the workspace when older host context omits the org rollout',
139170
async (features) => {

‎apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
1313
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
1414
import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
1515
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
16+
import { getLegacyAccessRequestsQuery } from '@/ee/access-requests/lib/navigation'
1617
import { SECTION_PREFETCHERS } from './prefetch'
1718
import { SettingsPage } from './settings'
1819

@@ -47,7 +48,9 @@ export default async function WorkspaceSettingsSectionPage({
4748
/** The layout already rejected an unknown segment; this narrows the type and fails safe. */
4849
const resolved = resolveSettingsSection(section)
4950
if (!resolved) notFound()
50-
const parsed = resolved.id
51+
const queryParams = (await searchParams) ?? {}
52+
const legacyRequestsQuery = getLegacyAccessRequestsQuery(resolved.id, queryParams)
53+
const parsed = legacyRequestsQuery ? 'requests' : resolved.id
5154

5255
const access = await authorizeWorkspaceSettingsSection({
5356
workspaceId,
@@ -77,8 +80,8 @@ export default async function WorkspaceSettingsSectionPage({
7780
if (organizationSection) {
7881
const hostContext = await getWorkspaceHostContextForViewer(workspaceId, session.user.id)
7982
if (hostContext?.hostOrganizationId && hostContext.features?.organizationSearch) {
80-
const query = new URLSearchParams()
81-
for (const [key, value] of Object.entries((await searchParams) ?? {})) {
83+
const query = legacyRequestsQuery ?? new URLSearchParams()
84+
for (const [key, value] of Object.entries(legacyRequestsQuery ? {} : queryParams)) {
8285
for (const entry of Array.isArray(value) ? value : value === undefined ? [] : [value]) {
8386
query.append(key, entry)
8487
}
@@ -89,6 +92,11 @@ export default async function WorkspaceSettingsSectionPage({
8992
}
9093
}
9194

95+
if (legacyRequestsQuery) {
96+
const query = legacyRequestsQuery.toString()
97+
redirect(`/workspace/${workspaceId}/settings/requests${query ? `?${query}` : ''}`)
98+
}
99+
92100
const queryClient = getQueryClient()
93101
/**
94102
* Protected section data starts only after the current server-side section gate succeeds.

‎apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ const WorkflowMcpServers = dynamic(() =>
8888
const AccessControl = dynamic(() =>
8989
import('@/ee/access-control/components/access-control').then((m) => m.AccessControl)
9090
)
91+
const OrganizationAccessRequests = dynamic(() =>
92+
import('@/ee/access-requests/components/organization-access-requests').then(
93+
(m) => m.OrganizationAccessRequests
94+
)
95+
)
9196
const CustomBlocks = dynamic(() =>
9297
import('@/ee/custom-blocks/components/custom-blocks').then((m) => m.CustomBlocks)
9398
)
@@ -182,8 +187,12 @@ function SettingsPageContent({ section }: SettingsPageProps) {
182187
<AccessControl
183188
organizationId={organizationId}
184189
isOrganizationAdmin={hostContext.viewer.isHostOrganizationAdmin}
190+
requestsHref={`/workspace/${hostContext.workspace.id}/settings/requests`}
185191
/>
186192
)}
193+
{effectiveSection === 'requests' && organizationId && (
194+
<OrganizationAccessRequests organizationId={organizationId} />
195+
)}
187196
{effectiveSection === 'custom-blocks' && <CustomBlocks />}
188197
{effectiveSection === 'audit-logs' && organizationId && (
189198
<AuditLogs organizationId={organizationId} />

0 commit comments

Comments
 (0)