Skip to content

Commit 9d176cd

Browse files
authored
fix(access): align invitations and request navigation (#8145)
* fix(access): align invitations and request navigation * fix(access): preserve standalone requests on navigation lookup failures
1 parent 3aa603f commit 9d176cd

41 files changed

Lines changed: 495 additions & 152 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22
import { authMockFns } from '@sim/testing'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() }))
5+
const { redirect, organizationContext } = vi.hoisted(() => ({
6+
redirect: vi.fn(),
7+
organizationContext: vi.fn(),
8+
}))
69
vi.mock('next/navigation', () => ({ redirect }))
10+
vi.mock('@/lib/organizations/surface', () => ({
11+
getOrganizationSurfaceContext: organizationContext,
12+
}))
713
vi.mock('@/ee/access-requests/components/my-access-requests', () => ({
814
MyAccessRequests: () => null,
915
}))
@@ -12,6 +18,7 @@ vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({
1218
}))
1319

1420
import AccessRequestsPage from '@/app/access-requests/page'
21+
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
1522

1623
describe('access request sign-in redirect', () => {
1724
beforeEach(() => {
@@ -67,4 +74,71 @@ describe('access request sign-in redirect', () => {
6774
`/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}`
6875
)
6976
})
77+
78+
it('opens saved requester links in the organization shell with their filters and selection', async () => {
79+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
80+
organizationContext.mockResolvedValue({ searchAccess: { memberScoped: true } })
81+
await expect(
82+
AccessRequestsPage({
83+
searchParams: Promise.resolve({
84+
organizationId: 'organization',
85+
view: 'catalog',
86+
requestId: 'request/a',
87+
search: 'Slack & Notion',
88+
page: '3',
89+
callbackUrl: 'https://example.com/untrusted',
90+
}),
91+
})
92+
).rejects.toThrow('Redirect')
93+
expect(organizationContext).toHaveBeenCalledWith('organization', 'viewer')
94+
const destination = new URL(redirect.mock.calls[0][0], 'https://example.com')
95+
expect(destination.pathname).toBe('/o/organization/access-requests')
96+
expect(Object.fromEntries(destination.searchParams)).toEqual({
97+
view: 'catalog',
98+
requestId: 'request/a',
99+
search: 'Slack & Notion',
100+
page: '3',
101+
})
102+
})
103+
104+
it.each([null, { searchAccess: { memberScoped: false } }])(
105+
'keeps the standalone route when the organization surface is unavailable: %j',
106+
async (context) => {
107+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
108+
organizationContext.mockResolvedValue(context)
109+
await AccessRequestsPage({
110+
searchParams: Promise.resolve({ organizationId: 'organization' }),
111+
})
112+
expect(redirect).not.toHaveBeenCalled()
113+
}
114+
)
115+
116+
it('keeps authenticated administrator email links on the review surface', async () => {
117+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
118+
await AccessRequestsPage({
119+
searchParams: Promise.resolve({
120+
organizationId: 'organization',
121+
view: 'admin',
122+
requestId: 'request',
123+
}),
124+
})
125+
expect(redirect).not.toHaveBeenCalled()
126+
expect(organizationContext).not.toHaveBeenCalled()
127+
})
128+
129+
it('renders the standalone requester when the optional organization navigation lookup fails', async () => {
130+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
131+
organizationContext.mockRejectedValue(new Error('Organization context unavailable'))
132+
133+
const page = await AccessRequestsPage({
134+
searchParams: Promise.resolve({ organizationId: 'organization', requestId: 'request' }),
135+
})
136+
137+
expect(redirect).not.toHaveBeenCalled()
138+
expect(page.props.children.type).toBe(MyAccessRequests)
139+
expect(page.props.children.props).toEqual({
140+
scope: { kind: 'organization', organizationId: 'organization' },
141+
standalone: true,
142+
})
143+
})
70144
})

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import { Suspense } from 'react'
22
import { ChipLink } from '@sim/emcn'
3+
import { createLogger } from '@sim/logger'
34
import type { Metadata } from 'next'
45
import { redirect } from 'next/navigation'
56
import { createSearchParamsCache, createSerializer } from 'nuqs/server'
67
import { EmptyState } from '@/components/empty-state/empty-state'
78
import { getSession } from '@/lib/auth'
8-
import { WORKSPACES_PATH } from '@/lib/navigation/paths'
9+
import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
10+
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
911
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1012
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'
1113
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
1214
import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests'
13-
import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params'
15+
import {
16+
accessRequestEntrySearchParams,
17+
accessRequestSearchParams,
18+
} from '@/ee/access-requests/components/search-params'
1419

1520
export const metadata: Metadata = {
1621
title: 'Access requests',
@@ -23,6 +28,8 @@ interface AccessRequestsPageProps {
2328

2429
const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams)
2530
const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams)
31+
const serializeRequesterSearchParams = createSerializer(accessRequestSearchParams)
32+
const logger = createLogger('AccessRequestsPage')
2633

2734
/** Session-only entry so access requests remain reachable outside the organization Search rollout. */
2835
export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) {
@@ -41,26 +48,49 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
4148
return (
4249
<EmptyState
4350
title='Choose an organization'
44-
description='Open My access requests from your profile menu in a workspace.'
45-
action={<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>}
51+
description='Open My access requests from your profile menu in an organization or workspace.'
52+
action={<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>}
4653
/>
4754
)
4855
}
4956

57+
if (params.view !== 'admin') {
58+
const context = await getOrganizationSurfaceContext(
59+
params.organizationId,
60+
session.user.id
61+
).catch((error) => {
62+
logger.warn('Unable to resolve organization navigation for access requests', { error })
63+
return null
64+
})
65+
if (context?.searchAccess.memberScoped) {
66+
redirect(
67+
serializeRequesterSearchParams(organizationRoutes(params.organizationId).accessRequests, {
68+
view: params.view,
69+
search: params.search,
70+
page: params.page,
71+
requestId: params.requestId,
72+
})
73+
)
74+
}
75+
}
76+
5077
return (
5178
<Suspense fallback={<AccessRequestsLoading />}>
5279
{params.view === 'admin' ? (
5380
<main className='flex-1 px-6 py-8'>
5481
<div className='mx-auto flex max-w-3xl flex-col gap-6'>
5582
<div className='flex items-center justify-between gap-4'>
5683
<h1 className='text-[var(--text-primary)] text-lg'>Access requests</h1>
57-
<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>
84+
<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>
5885
</div>
5986
<OrganizationAccessRequests organizationId={params.organizationId} standalone />
6087
</div>
6188
</main>
6289
) : (
63-
<MyAccessRequests scope={{ kind: 'organization', organizationId: params.organizationId }} />
90+
<MyAccessRequests
91+
scope={{ kind: 'organization', organizationId: params.organizationId }}
92+
standalone
93+
/>
6494
)}
6595
</Suspense>
6696
)

‎apps/sim/app/api/invitations/[id]/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export const GET = withRouteHandler(
104104
grants: inv.grants.map((grant) => ({
105105
workspaceId: grant.workspaceId,
106106
workspaceName: grant.workspaceName,
107+
workspaceLogoUrl: grant.workspaceLogoUrl,
107108
permission: grant.permission,
108109
})),
109110
},

‎apps/sim/app/api/invitations/route.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ function invitation(id: string) {
3737
createdAt: new Date('2026-01-01T00:00:00.000Z'),
3838
inviterName: 'Ada',
3939
inviterEmail: 'ada@example.com',
40-
grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }],
40+
grants: [
41+
{
42+
workspaceId: 'ws-1',
43+
workspaceName: 'WS',
44+
workspaceLogoUrl: 'https://example.com/workspace.png',
45+
permission: 'read',
46+
},
47+
],
4148
}
4249
}
4350

@@ -56,6 +63,7 @@ describe('GET /api/invitations', () => {
5663
const { invitations } = await (await GET(createMockRequest('GET'))).json()
5764

5865
expect(invitations.map((i: { id: string }) => i.id)).toEqual(['a', 'b', 'c'])
66+
expect(invitations[0].grants[0].workspaceLogoUrl).toBe('https://example.com/workspace.png')
5967
expect(invitations.map((i: { joinPreview: unknown }) => i.joinPreview)).toEqual([
6068
{ for: 'a' },
6169
{ for: 'b' },

‎apps/sim/app/api/invitations/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const GET = withRouteHandler(async () => {
7272
grants: inv.grants.map((grant) => ({
7373
workspaceId: grant.workspaceId,
7474
workspaceName: grant.workspaceName,
75+
workspaceLogoUrl: grant.workspaceLogoUrl,
7576
permission: grant.permission,
7677
})),
7778
joinPreview: previews[index],

‎apps/sim/app/invite/[id]/invite.test.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,9 @@ describe('Invite', () => {
419419
(item) => item.textContent
420420
)
421421
).toEqual(['Personal work', 'Archived project'])
422-
expect(container.textContent).toContain('External Workspace: admin access')
422+
const workspaceAccess = container.querySelector('[aria-label="Invited workspace access"] li')
423+
expect(workspaceAccess?.textContent).toContain('External Workspace')
424+
expect(workspaceAccess?.textContent).toContain('admin access')
423425
await clickAction('Accept Invitation')
424426
expect(mockRequestJson).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' }), {
425427
params: { id: 'invitation-1' },
@@ -433,7 +435,7 @@ describe('Invite', () => {
433435

434436
it.each([
435437
['already-member', 'Your organization role will stay the same'],
436-
['external', 'workspace access without joining an organization'],
438+
['external', null],
437439
['blocked', 'This invitation cannot currently be accepted'],
438440
] as const)('discloses %s without promising a membership change', async (outcome, message) => {
439441
membershipIntent = 'internal'
@@ -444,7 +446,8 @@ describe('Invite', () => {
444446
workspacesToMove: [],
445447
}
446448
await renderInvite()
447-
expect(container.textContent).toContain(message)
449+
if (message) expect(container.textContent).toContain(message)
450+
else expect(container.textContent).not.toContain('without joining an organization')
448451
expect(container.textContent).not.toContain('as an organization admin')
449452
await clickAction('Accept Invitation')
450453
expect(mockRequestJson).toHaveBeenCalledWith(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure'
2+
export { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access'
23
export { default as InviteLayout } from '@/app/invite/components/layout'
34
export { InviteStatusCard } from '@/app/invite/components/status-card'

‎apps/sim/app/invite/components/invitation-disclosure.tsx‎

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import type { InvitationDetails, MyInvitation } from '@/lib/api/contracts/invitations'
2+
import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access'
23

34
interface InvitationDisclosureProps {
45
invitation: InvitationDetails
56
joinPreview: MyInvitation['joinPreview']
7+
showWorkspaceAccess?: boolean
68
}
79

8-
export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisclosureProps) {
10+
export function InvitationDisclosure({
11+
invitation,
12+
joinPreview,
13+
showWorkspaceAccess = true,
14+
}: InvitationDisclosureProps) {
915
const outcome =
1016
joinPreview?.outcome ?? (invitation.membershipIntent === 'external' ? 'external' : null)
1117
const organizationName =
@@ -14,7 +20,7 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
1420
'the workspace’s organization'
1521

1622
return (
17-
<div className='space-y-3 text-left text-[var(--text-body)] text-sm'>
23+
<div className='space-y-3 text-left text-[var(--text-body)] text-small'>
1824
{outcome === 'will-join' && (
1925
<>
2026
<p>
@@ -50,13 +56,6 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
5056
additional seat is used, and none of your personal workspaces will move.
5157
</p>
5258
)}
53-
{outcome === 'external' && (
54-
<p>
55-
You will receive workspace access without joining an organization or using one of its
56-
seats. Your organization membership will stay the same, and none of your personal
57-
workspaces will move.
58-
</p>
59-
)}
6059
{outcome === 'blocked' && (
6160
<p>
6261
This invitation cannot currently be accepted. Try accepting to see what needs to be
@@ -69,16 +68,10 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
6968
Refresh the invitation to review these details before accepting.
7069
</p>
7170
)}
72-
{invitation.grants.length > 0 && (
73-
<div>
74-
<p>Invited workspace access:</p>
75-
<ul className='mt-1 list-disc space-y-1 pl-5' aria-label='Invited workspace access'>
76-
{invitation.grants.map((grant) => (
77-
<li key={grant.workspaceId} className='break-words'>
78-
{grant.workspaceName || 'Unnamed workspace'}: {grant.permission} access
79-
</li>
80-
))}
81-
</ul>
71+
{showWorkspaceAccess && invitation.grants.length > 0 && (
72+
<div className='space-y-2'>
73+
<p className='text-[var(--text-muted)] text-caption'>Workspace access</p>
74+
<InvitationWorkspaceAccess grants={invitation.grants} />
8275
</div>
8376
)}
8477
</div>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ChipTag } from '@sim/emcn'
2+
import { IdentityTile } from '@/components/identity-tile/identity-tile'
3+
import type { InvitationDetails } from '@/lib/api/contracts/invitations'
4+
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
5+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
6+
7+
interface InvitationWorkspaceAccessProps {
8+
grants: InvitationDetails['grants']
9+
}
10+
11+
export function InvitationWorkspaceAccess({ grants }: InvitationWorkspaceAccessProps) {
12+
return (
13+
<ul className='space-y-2' aria-label='Invited workspace access'>
14+
{grants.map((grant) => (
15+
<li key={grant.workspaceId}>
16+
<SettingsResourceRow
17+
flush
18+
icon={
19+
<IdentityTile
20+
initial={getWorkspaceInitial(grant.workspaceName ?? undefined)}
21+
logoUrl={grant.workspaceLogoUrl}
22+
/>
23+
}
24+
iconVariant='custom'
25+
title={grant.workspaceName || 'Unnamed workspace'}
26+
badge={<ChipTag variant='gray'>{grant.permission} access</ChipTag>}
27+
/>
28+
</li>
29+
))}
30+
</ul>
31+
)
32+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'
2+
3+
export default function Loading() {
4+
return <AccessRequestsLoading />
5+
}

0 commit comments

Comments
 (0)