Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion apps/sim/app/access-requests/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
import { authMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { redirect } = vi.hoisted(() => ({ redirect: vi.fn() }))
const { redirect, organizationContext } = vi.hoisted(() => ({
redirect: vi.fn(),
organizationContext: 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,
}))
Expand All @@ -12,6 +18,7 @@ vi.mock('@/ee/access-requests/components/organization-access-requests', () => ({
}))

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

describe('access request sign-in redirect', () => {
beforeEach(() => {
Expand Down Expand Up @@ -67,4 +74,71 @@ describe('access request sign-in redirect', () => {
`/login?callbackUrl=${encodeURIComponent('/access-requests?organizationId=organization')}`
)
})

it('opens saved requester links in the organization shell with their filters and selection', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
organizationContext.mockResolvedValue({ searchAccess: { memberScoped: true } })
await expect(
AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'catalog',
requestId: 'request/a',
search: 'Slack & Notion',
page: '3',
callbackUrl: 'https://example.com/untrusted',
}),
})
).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(Object.fromEntries(destination.searchParams)).toEqual({
view: 'catalog',
requestId: 'request/a',
search: 'Slack & Notion',
page: '3',
})
})

it.each([null, { searchAccess: { memberScoped: false } }])(
'keeps the standalone route when the organization surface is unavailable: %j',
async (context) => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
organizationContext.mockResolvedValue(context)
await AccessRequestsPage({
searchParams: Promise.resolve({ organizationId: 'organization' }),
})
expect(redirect).not.toHaveBeenCalled()
}
)

it('keeps authenticated administrator email links on the review surface', async () => {
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'viewer' } })
await AccessRequestsPage({
searchParams: Promise.resolve({
organizationId: 'organization',
view: 'admin',
requestId: 'request',
}),
})
expect(redirect).not.toHaveBeenCalled()
expect(organizationContext).not.toHaveBeenCalled()
})

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' }),
})

expect(redirect).not.toHaveBeenCalled()
expect(page.props.children.type).toBe(MyAccessRequests)
expect(page.props.children.props).toEqual({
scope: { kind: 'organization', organizationId: 'organization' },
standalone: true,
})
})
})
42 changes: 36 additions & 6 deletions apps/sim/app/access-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Suspense } from 'react'
import { ChipLink } from '@sim/emcn'
import { createLogger } from '@sim/logger'
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 { getSession } from '@/lib/auth'
import { WORKSPACES_PATH } from '@/lib/navigation/paths'
import { APP_ENTRY_PATH, organizationRoutes } from '@/lib/navigation/paths'
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 } from '@/ee/access-requests/components/search-params'
import {
accessRequestEntrySearchParams,
accessRequestSearchParams,
} from '@/ee/access-requests/components/search-params'

export const metadata: Metadata = {
title: 'Access requests',
Expand All @@ -23,6 +28,8 @@ 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. */
export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) {
Expand All @@ -41,26 +48,49 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest
return (
<EmptyState
title='Choose an organization'
description='Open My access requests from your profile menu in a workspace.'
action={<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>}
description='Open My access requests from your profile menu in an organization or workspace.'
action={<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>}
/>
)
}

if (params.view !== 'admin') {
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,
})
)
}
}

return (
<Suspense fallback={<AccessRequestsLoading />}>
{params.view === 'admin' ? (
<main className='flex-1 px-6 py-8'>
<div className='mx-auto flex max-w-3xl flex-col gap-6'>
<div className='flex items-center justify-between gap-4'>
<h1 className='text-[var(--text-primary)] text-lg'>Access requests</h1>
<ChipLink href={WORKSPACES_PATH}>Your workspaces</ChipLink>
<ChipLink href={APP_ENTRY_PATH}>Back to Sim</ChipLink>
</div>
<OrganizationAccessRequests organizationId={params.organizationId} standalone />
</div>
</main>
) : (
<MyAccessRequests scope={{ kind: 'organization', organizationId: params.organizationId }} />
<MyAccessRequests
scope={{ kind: 'organization', organizationId: params.organizationId }}
standalone
/>
)}
</Suspense>
)
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/invitations/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export const GET = withRouteHandler(
grants: inv.grants.map((grant) => ({
workspaceId: grant.workspaceId,
workspaceName: grant.workspaceName,
workspaceLogoUrl: grant.workspaceLogoUrl,
permission: grant.permission,
})),
},
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/app/api/invitations/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ function invitation(id: string) {
createdAt: new Date('2026-01-01T00:00:00.000Z'),
inviterName: 'Ada',
inviterEmail: 'ada@example.com',
grants: [{ workspaceId: 'ws-1', workspaceName: 'WS', permission: 'read' }],
grants: [
{
workspaceId: 'ws-1',
workspaceName: 'WS',
workspaceLogoUrl: 'https://example.com/workspace.png',
permission: 'read',
},
],
}
}

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

expect(invitations.map((i: { id: string }) => i.id)).toEqual(['a', 'b', 'c'])
expect(invitations[0].grants[0].workspaceLogoUrl).toBe('https://example.com/workspace.png')
expect(invitations.map((i: { joinPreview: unknown }) => i.joinPreview)).toEqual([
{ for: 'a' },
{ for: 'b' },
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/invitations/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const GET = withRouteHandler(async () => {
grants: inv.grants.map((grant) => ({
workspaceId: grant.workspaceId,
workspaceName: grant.workspaceName,
workspaceLogoUrl: grant.workspaceLogoUrl,
permission: grant.permission,
})),
joinPreview: previews[index],
Expand Down
9 changes: 6 additions & 3 deletions apps/sim/app/invite/[id]/invite.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,9 @@ describe('Invite', () => {
(item) => item.textContent
)
).toEqual(['Personal work', 'Archived project'])
expect(container.textContent).toContain('External Workspace: admin access')
const workspaceAccess = container.querySelector('[aria-label="Invited workspace access"] li')
expect(workspaceAccess?.textContent).toContain('External Workspace')
expect(workspaceAccess?.textContent).toContain('admin access')
await clickAction('Accept Invitation')
expect(mockRequestJson).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST' }), {
params: { id: 'invitation-1' },
Expand All @@ -433,7 +435,7 @@ describe('Invite', () => {

it.each([
['already-member', 'Your organization role will stay the same'],
['external', 'workspace access without joining an organization'],
['external', null],
['blocked', 'This invitation cannot currently be accepted'],
] as const)('discloses %s without promising a membership change', async (outcome, message) => {
membershipIntent = 'internal'
Expand All @@ -444,7 +446,8 @@ describe('Invite', () => {
workspacesToMove: [],
}
await renderInvite()
expect(container.textContent).toContain(message)
if (message) expect(container.textContent).toContain(message)
else expect(container.textContent).not.toContain('without joining an organization')
expect(container.textContent).not.toContain('as an organization admin')
await clickAction('Accept Invitation')
expect(mockRequestJson).toHaveBeenCalledWith(
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/invite/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure'
export { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access'
export { default as InviteLayout } from '@/app/invite/components/layout'
export { InviteStatusCard } from '@/app/invite/components/status-card'
31 changes: 12 additions & 19 deletions apps/sim/app/invite/components/invitation-disclosure.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { InvitationDetails, MyInvitation } from '@/lib/api/contracts/invitations'
import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access'

interface InvitationDisclosureProps {
invitation: InvitationDetails
joinPreview: MyInvitation['joinPreview']
showWorkspaceAccess?: boolean
}

export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisclosureProps) {
export function InvitationDisclosure({
invitation,
joinPreview,
showWorkspaceAccess = true,
}: InvitationDisclosureProps) {
const outcome =
joinPreview?.outcome ?? (invitation.membershipIntent === 'external' ? 'external' : null)
const organizationName =
Expand All @@ -14,7 +20,7 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
'the workspace’s organization'

return (
<div className='space-y-3 text-left text-[var(--text-body)] text-sm'>
<div className='space-y-3 text-left text-[var(--text-body)] text-small'>
{outcome === 'will-join' && (
<>
<p>
Expand Down Expand Up @@ -50,13 +56,6 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
additional seat is used, and none of your personal workspaces will move.
</p>
)}
{outcome === 'external' && (
<p>
You will receive workspace access without joining an organization or using one of its
seats. Your organization membership will stay the same, and none of your personal
workspaces will move.
</p>
)}
{outcome === 'blocked' && (
<p>
This invitation cannot currently be accepted. Try accepting to see what needs to be
Expand All @@ -69,16 +68,10 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc
Refresh the invitation to review these details before accepting.
</p>
)}
{invitation.grants.length > 0 && (
<div>
<p>Invited workspace access:</p>
<ul className='mt-1 list-disc space-y-1 pl-5' aria-label='Invited workspace access'>
{invitation.grants.map((grant) => (
<li key={grant.workspaceId} className='break-words'>
{grant.workspaceName || 'Unnamed workspace'}: {grant.permission} access
</li>
))}
</ul>
{showWorkspaceAccess && invitation.grants.length > 0 && (
Comment thread
waleedlatif1 marked this conversation as resolved.
<div className='space-y-2'>
<p className='text-[var(--text-muted)] text-caption'>Workspace access</p>
<InvitationWorkspaceAccess grants={invitation.grants} />
</div>
)}
</div>
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/app/invite/components/invitation-workspace-access.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ChipTag } from '@sim/emcn'
import { IdentityTile } from '@/components/identity-tile/identity-tile'
import type { InvitationDetails } from '@/lib/api/contracts/invitations'
import { getWorkspaceInitial } from '@/lib/workspaces/initials'
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'

interface InvitationWorkspaceAccessProps {
grants: InvitationDetails['grants']
}

export function InvitationWorkspaceAccess({ grants }: InvitationWorkspaceAccessProps) {
return (
<ul className='space-y-2' aria-label='Invited workspace access'>
{grants.map((grant) => (
<li key={grant.workspaceId}>
<SettingsResourceRow
flush
icon={
<IdentityTile
initial={getWorkspaceInitial(grant.workspaceName ?? undefined)}
logoUrl={grant.workspaceLogoUrl}
/>
}
iconVariant='custom'
title={grant.workspaceName || 'Unnamed workspace'}
badge={<ChipTag variant='gray'>{grant.permission} access</ChipTag>}
/>
</li>
))}
</ul>
)
}
5 changes: 5 additions & 0 deletions apps/sim/app/o/[organizationId]/access-requests/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading'

export default function Loading() {
return <AccessRequestsLoading />
}
21 changes: 21 additions & 0 deletions apps/sim/app/o/[organizationId]/access-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +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' }

interface OrganizationAccessRequestsPageProps {
params: Promise<{ organizationId: string }>
}

export default async function OrganizationAccessRequestsPage({
params,
}: OrganizationAccessRequestsPageProps) {
const { organizationId } = await params
return (
<Suspense fallback={<AccessRequestsLoading />}>
<MyAccessRequests scope={{ kind: 'organization', organizationId }} />
</Suspense>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,10 @@ describe('OrganizationFooter settings navigation', () => {
[...document.querySelectorAll('[role="menuitem"]')].map((item) => item.textContent)
).toEqual(['Settings', 'My access requests', 'Sign out'])
expect(document.querySelector('[role="separator"]')).toBeNull()
const requests = document.querySelector<HTMLAnchorElement>(
'a[href="/access-requests?organizationId=org-1"]'
)
const requests = document.querySelector<HTMLAnchorElement>('a[href="/o/org-1/access-requests"]')
expect(requests).not.toBeNull()
await act(async () => requests!.click())
expect(mockPush).toHaveBeenCalledWith('/access-requests?organizationId=org-1')
expect(mockPush).toHaveBeenCalledWith('/o/org-1/access-requests')
})

it('navigates immediately when settings are clean', async () => {
Expand Down
Loading
Loading