Skip to content

Commit 8f9ff32

Browse files
committed
fix(access): align invitations and request navigation
1 parent 3aa603f commit 8f9ff32

41 files changed

Lines changed: 470 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: 58 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
}))
@@ -67,4 +73,55 @@ describe('access request sign-in redirect', () => {
6773
`/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}`
6874
)
6975
})
76+
77+
it('opens saved requester links in the organization shell with their filters and selection', async () => {
78+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
79+
organizationContext.mockResolvedValue({ searchAccess: { memberScoped: true } })
80+
await expect(
81+
AccessRequestsPage({
82+
searchParams: Promise.resolve({
83+
organizationId: 'organization',
84+
view: 'catalog',
85+
requestId: 'request/a',
86+
search: 'Slack & Notion',
87+
page: '3',
88+
callbackUrl: 'https://example.com/untrusted',
89+
}),
90+
})
91+
).rejects.toThrow('Redirect')
92+
expect(organizationContext).toHaveBeenCalledWith('organization', 'viewer')
93+
const destination = new URL(redirect.mock.calls[0][0], 'https://example.com')
94+
expect(destination.pathname).toBe('/o/organization/access-requests')
95+
expect(Object.fromEntries(destination.searchParams)).toEqual({
96+
view: 'catalog',
97+
requestId: 'request/a',
98+
search: 'Slack & Notion',
99+
page: '3',
100+
})
101+
})
102+
103+
it.each([null, { searchAccess: { memberScoped: false } }])(
104+
'keeps the standalone route when the organization surface is unavailable: %j',
105+
async (context) => {
106+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
107+
organizationContext.mockResolvedValue(context)
108+
await AccessRequestsPage({
109+
searchParams: Promise.resolve({ organizationId: 'organization' }),
110+
})
111+
expect(redirect).not.toHaveBeenCalled()
112+
}
113+
)
114+
115+
it('keeps authenticated administrator email links on the review surface', async () => {
116+
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
117+
await AccessRequestsPage({
118+
searchParams: Promise.resolve({
119+
organizationId: 'organization',
120+
view: 'admin',
121+
requestId: 'request',
122+
}),
123+
})
124+
expect(redirect).not.toHaveBeenCalled()
125+
expect(organizationContext).not.toHaveBeenCalled()
126+
})
70127
})

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

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import { redirect } from 'next/navigation'
55
import { createSearchParamsCache, createSerializer } from 'nuqs/server'
66
import { EmptyState } from '@/components/empty-state/empty-state'
77
import { getSession } from '@/lib/auth'
8-
import { WORKSPACES_PATH } from '@/lib/navigation/paths'
8+
import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
9+
import { getOrganizationSurfaceContext } from '@/lib/organizations/surface'
910
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
1011
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'
1112
import { MyAccessRequests } from '@/ee/access-requests/components/my-access-requests'
1213
import { OrganizationAccessRequests } from '@/ee/access-requests/components/organization-access-requests'
13-
import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params'
14+
import {
15+
accessRequestEntrySearchParams,
16+
accessRequestSearchParams,
17+
} from '@/ee/access-requests/components/search-params'
1418

1519
export const metadata: Metadata = {
1620
title: 'Access requests',
@@ -23,6 +27,7 @@ interface AccessRequestsPageProps {
2327

2428
const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams)
2529
const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams)
30+
const serializeRequesterSearchParams = createSerializer(accessRequestSearchParams)
2631

2732
/** Session-only entry so access requests remain reachable outside the organization Search rollout. */
2833
export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) {
@@ -41,26 +46,43 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
4146
return (
4247
<EmptyState
4348
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>}
49+
description='Open My access requests from your profile menu in an organization or workspace.'
50+
action={<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>}
4651
/>
4752
)
4853
}
4954

55+
if (params.view !== 'admin') {
56+
const context = await getOrganizationSurfaceContext(params.organizationId, session.user.id)
57+
if (context?.searchAccess.memberScoped) {
58+
redirect(
59+
serializeRequesterSearchParams(organizationRoutes(params.organizationId).accessRequests, {
60+
view: params.view,
61+
search: params.search,
62+
page: params.page,
63+
requestId: params.requestId,
64+
})
65+
)
66+
}
67+
}
68+
5069
return (
5170
<Suspense fallback={<AccessRequestsLoading />}>
5271
{params.view === 'admin' ? (
5372
<main className='flex-1 px-6 py-8'>
5473
<div className='mx-auto flex max-w-3xl flex-col gap-6'>
5574
<div className='flex items-center justify-between gap-4'>
5675
<h1 className='text-[var(--text-primary)] text-lg'>Access requests</h1>
57-
<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>
76+
<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>
5877
</div>
5978
<OrganizationAccessRequests organizationId={params.organizationId} standalone />
6079
</div>
6180
</main>
6281
) : (
63-
<MyAccessRequests scope={{ kind: 'organization', organizationId: params.organizationId }} />
82+
<MyAccessRequests
83+
scope={{ kind: 'organization', organizationId: params.organizationId }}
84+
standalone
85+
/>
6486
)}
6587
</Suspense>
6688
)

‎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)