From 8f9ff32dc368a8f75f03f312b234042798cdbbbc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 11:09:03 -0700 Subject: [PATCH 1/2] fix(access): align invitations and request navigation --- apps/sim/app/access-requests/page.test.tsx | 59 ++++++- apps/sim/app/access-requests/page.tsx | 34 +++- apps/sim/app/api/invitations/[id]/route.ts | 1 + apps/sim/app/api/invitations/route.test.ts | 10 +- apps/sim/app/api/invitations/route.ts | 1 + apps/sim/app/invite/[id]/invite.test.tsx | 9 +- apps/sim/app/invite/components/index.ts | 1 + .../components/invitation-disclosure.tsx | 31 ++-- .../invitation-workspace-access.tsx | 32 ++++ .../access-requests/loading.tsx | 5 + .../[organizationId]/access-requests/page.tsx | 21 +++ .../organization-footer.test.tsx | 6 +- .../organization-footer.tsx | 8 +- .../knowledge/knowledge.test.tsx | 2 +- .../[workspaceId]/prefetch-access.test.tsx | 2 +- .../[workspaceId]/prefetch-access.ts | 2 +- .../w/[workflowId]/components/panel/panel.tsx | 2 +- .../search-modal/search-modal.test.tsx | 2 +- .../view-invitations-modal.test.tsx | 74 ++++++++- .../view-invitations-modal.tsx | 147 +++++++++++------- .../components/access-request-review.test.tsx | 2 +- .../components/access-request-review.tsx | 5 +- .../member-limit-request-action.tsx | 2 +- .../components/my-access-request-details.tsx | 12 +- .../components/my-access-requests.test.tsx | 37 ++++- .../components/my-access-requests.tsx | 40 +++-- .../organization-access-requests.test.tsx | 2 +- .../organization-access-requests.tsx | 5 +- .../permission-access-boundary.test.tsx | 2 +- .../components/permission-access-boundary.tsx | 4 +- .../components/request-access-action.test.tsx | 2 +- .../components/request-access-action.tsx | 9 +- .../components/search-params.ts | 1 + .../queries}/access-requests.test.tsx | 4 +- .../queries}/access-requests.ts | 4 +- .../queries/utils}/access-request-keys.ts | 0 apps/sim/lib/api/contracts/invitations.ts | 3 +- apps/sim/lib/invitations/core.test.ts | 34 ++++ apps/sim/lib/invitations/core.ts | 3 + apps/sim/lib/navigation/paths.test.ts | 1 + apps/sim/lib/navigation/paths.ts | 1 + 41 files changed, 470 insertions(+), 152 deletions(-) create mode 100644 apps/sim/app/invite/components/invitation-workspace-access.tsx create mode 100644 apps/sim/app/o/[organizationId]/access-requests/loading.tsx create mode 100644 apps/sim/app/o/[organizationId]/access-requests/page.tsx rename apps/sim/{ee/access-requests/hooks => hooks/queries}/access-requests.test.tsx (98%) rename apps/sim/{ee/access-requests/hooks => hooks/queries}/access-requests.ts (99%) rename apps/sim/{ee/access-requests/hooks => hooks/queries/utils}/access-request-keys.ts (100%) diff --git a/apps/sim/app/access-requests/page.test.tsx b/apps/sim/app/access-requests/page.test.tsx index 9ea92ec6dd5..2e8670c6ab6 100644 --- a/apps/sim/app/access-requests/page.test.tsx +++ b/apps/sim/app/access-requests/page.test.tsx @@ -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, })) @@ -67,4 +73,55 @@ 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() + }) }) diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx index b0d7accfba9..c43870c9e15 100644 --- a/apps/sim/app/access-requests/page.tsx +++ b/apps/sim/app/access-requests/page.tsx @@ -5,12 +5,16 @@ 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', @@ -23,6 +27,7 @@ interface AccessRequestsPageProps { const entrySearchParams = createSearchParamsCache(accessRequestEntrySearchParams) const serializeEntrySearchParams = createSerializer(accessRequestEntrySearchParams) +const serializeRequesterSearchParams = createSerializer(accessRequestSearchParams) /** Session-only entry so access requests remain reachable outside the organization Search rollout. */ export default async function AccessRequestsPage({ searchParams }: AccessRequestsPageProps) { @@ -41,12 +46,26 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest return ( Your workspaces} + description='Open My access requests from your profile menu in an organization or workspace.' + action={Back to Sim} /> ) } + if (params.view !== 'admin') { + const context = await getOrganizationSurfaceContext(params.organizationId, session.user.id) + if (context?.searchAccess.memberScoped) { + redirect( + serializeRequesterSearchParams(organizationRoutes(params.organizationId).accessRequests, { + view: params.view, + search: params.search, + page: params.page, + requestId: params.requestId, + }) + ) + } + } + return ( }> {params.view === 'admin' ? ( @@ -54,13 +73,16 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest

Access requests

- Your workspaces + Back to Sim
) : ( - + )}
) diff --git a/apps/sim/app/api/invitations/[id]/route.ts b/apps/sim/app/api/invitations/[id]/route.ts index 1dd511f7aea..0414c6644a2 100644 --- a/apps/sim/app/api/invitations/[id]/route.ts +++ b/apps/sim/app/api/invitations/[id]/route.ts @@ -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, })), }, diff --git a/apps/sim/app/api/invitations/route.test.ts b/apps/sim/app/api/invitations/route.test.ts index 4fa986c1603..ac62833a5ae 100644 --- a/apps/sim/app/api/invitations/route.test.ts +++ b/apps/sim/app/api/invitations/route.test.ts @@ -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', + }, + ], } } @@ -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' }, diff --git a/apps/sim/app/api/invitations/route.ts b/apps/sim/app/api/invitations/route.ts index 11b12fbced3..c06c17a88c5 100644 --- a/apps/sim/app/api/invitations/route.ts +++ b/apps/sim/app/api/invitations/route.ts @@ -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], diff --git a/apps/sim/app/invite/[id]/invite.test.tsx b/apps/sim/app/invite/[id]/invite.test.tsx index b04b3123473..d14cd956cb7 100644 --- a/apps/sim/app/invite/[id]/invite.test.tsx +++ b/apps/sim/app/invite/[id]/invite.test.tsx @@ -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' }, @@ -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' @@ -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( diff --git a/apps/sim/app/invite/components/index.ts b/apps/sim/app/invite/components/index.ts index 9098fcdfab2..f72433bf8a8 100644 --- a/apps/sim/app/invite/components/index.ts +++ b/apps/sim/app/invite/components/index.ts @@ -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' diff --git a/apps/sim/app/invite/components/invitation-disclosure.tsx b/apps/sim/app/invite/components/invitation-disclosure.tsx index eab067718ed..83d773db68b 100644 --- a/apps/sim/app/invite/components/invitation-disclosure.tsx +++ b/apps/sim/app/invite/components/invitation-disclosure.tsx @@ -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 = @@ -14,7 +20,7 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc 'the workspace’s organization' return ( -
+
{outcome === 'will-join' && ( <>

@@ -50,13 +56,6 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc additional seat is used, and none of your personal workspaces will move.

)} - {outcome === 'external' && ( -

- 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. -

- )} {outcome === 'blocked' && (

This invitation cannot currently be accepted. Try accepting to see what needs to be @@ -69,16 +68,10 @@ export function InvitationDisclosure({ invitation, joinPreview }: InvitationDisc Refresh the invitation to review these details before accepting.

)} - {invitation.grants.length > 0 && ( -
-

Invited workspace access:

-
    - {invitation.grants.map((grant) => ( -
  • - {grant.workspaceName || 'Unnamed workspace'}: {grant.permission} access -
  • - ))} -
+ {showWorkspaceAccess && invitation.grants.length > 0 && ( +
+

Workspace access

+
)}
diff --git a/apps/sim/app/invite/components/invitation-workspace-access.tsx b/apps/sim/app/invite/components/invitation-workspace-access.tsx new file mode 100644 index 00000000000..b9bd00c9b05 --- /dev/null +++ b/apps/sim/app/invite/components/invitation-workspace-access.tsx @@ -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 ( +
    + {grants.map((grant) => ( +
  • + + } + iconVariant='custom' + title={grant.workspaceName || 'Unnamed workspace'} + badge={{grant.permission} access} + /> +
  • + ))} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/access-requests/loading.tsx b/apps/sim/app/o/[organizationId]/access-requests/loading.tsx new file mode 100644 index 00000000000..5ee4cac94d1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { AccessRequestsLoading } from '@/ee/access-requests/components/access-requests-loading' + +export default function Loading() { + return +} diff --git a/apps/sim/app/o/[organizationId]/access-requests/page.tsx b/apps/sim/app/o/[organizationId]/access-requests/page.tsx new file mode 100644 index 00000000000..db35d81abb2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/access-requests/page.tsx @@ -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 ( + }> + + + ) +} 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 57c3d94e6e1..990d6540037 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 @@ -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( - 'a[href="/access-requests?organizationId=org-1"]' - ) + const requests = document.querySelector('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 () => { 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 4c8b101c3d6..a9110907f1a 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 @@ -3,13 +3,9 @@ import type { ComponentProps } from 'react' import { ListChecks } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' -import { createSerializer } from 'nuqs/server' import { organizationRoutes } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer' -import { accessRequestEntrySearchParams } from '@/ee/access-requests/components/search-params' - -const serializeAccessRequestParams = createSerializer(accessRequestEntrySearchParams) interface OrganizationFooterProps extends Omit< @@ -21,9 +17,7 @@ export function OrganizationFooter(props: OrganizationFooterProps) { const { organization } = useOrganizationContext() const router = useRouter() const accountSettingsHref = organizationRoutes(organization.id).settingsSection('general') - const accessRequestsHref = serializeAccessRequestParams('/access-requests', { - organizationId: organization.id, - }) + const accessRequestsHref = organizationRoutes(organization.id).accessRequests return ( ({ usePermissionConfig: () => ({ vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: () => ({ data: { config: {} }, isPending: false }), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx index 482b16115b4..e1fa4aed93b 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.test.tsx @@ -53,7 +53,7 @@ import { PermissionAccessBoundary } from '@/ee/access-requests/components/permis import { accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/ee/access-requests/hooks/access-request-keys' +} from '@/hooks/queries/utils/access-request-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' const principal = { kind: 'session', userId: 'viewer', sessionId: 'session' } as const diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts index 1b05ba5913a..6fb0e5162aa 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch-access.ts @@ -11,7 +11,7 @@ import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, workspaceFeatureDiscoveryQuery, -} from '@/ee/access-requests/hooks/access-request-keys' +} from '@/hooks/queries/utils/access-request-keys' import { PERMISSION_GROUPS_STALE_TIME, permissionGroupKeys, 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 5f894cfc5f3..b26c5ac8ccd 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,7 +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 { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' import { useCopilotChatSelection } from '@/hooks/queries/copilot-chat-selection' import { type CopilotChatListItem, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx index cc1deb58045..8b89a41104f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.test.tsx @@ -68,7 +68,7 @@ vi.mock('@/hooks/use-permission-config', () => ({ }), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: () => ({ data: { enabled: false, entries: [] }, isPending: false }), })) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx index aa1203e2471..438bc083e0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.test.tsx @@ -97,6 +97,72 @@ afterEach(async () => { }) describe('ViewInvitationsModal', () => { + it('keeps multiple workspace invitations and their grants attached to the correct actions', async () => { + const first: MyInvitation = { + ...invitation, + kind: 'workspace', + membershipIntent: 'external', + grants: [ + { + workspaceId: 'alpha', + workspaceName: 'Alpha', + workspaceLogoUrl: 'https://example.com/alpha.png', + permission: 'admin', + }, + ], + joinPreview: { + outcome: 'external', + organizationName: null, + workspaceIdsToMove: [], + workspacesToMove: [], + }, + } + const second: MyInvitation = { + ...first, + id: 'invitation-2', + grants: [ + { + workspaceId: 'design', + workspaceName: 'Design', + workspaceLogoUrl: 'https://example.com/design.png', + permission: 'read', + }, + { workspaceId: 'engineering', workspaceName: 'Engineering', permission: 'write' }, + ], + } + mocks.query.mockReturnValue({ data: [first, second], isPending: false, isError: false }) + await renderModal() + expect(document.body.textContent).not.toContain('Before you join') + expect(document.body.textContent).not.toContain('without joining an organization') + const firstRow = document.querySelector('section[aria-label="Invitation to Alpha"]') + expect(firstRow?.textContent).toContain('admin access') + expect(firstRow?.querySelector('ul')).toBeNull() + expect(firstRow?.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/alpha.png' + ) + const secondRow = Array.from(document.querySelectorAll('section')).find( + (row) => row.getAttribute('aria-label') === 'Invitation to Design +1' + ) + expect(secondRow).toBeDefined() + const grants = Array.from(secondRow?.querySelectorAll('li') ?? [], (row) => row.textContent) + expect(grants).toHaveLength(2) + expect(grants[0]).toContain('Designread access') + expect(grants[1]).toContain('Engineeringwrite access') + expect(secondRow?.querySelector('img')?.getAttribute('src')).toBe( + 'https://example.com/design.png' + ) + const accept = Array.from(secondRow?.querySelectorAll('button') ?? []).find( + (button) => button.textContent === 'Accept' + ) + expect(accept).toBeDefined() + await act(async () => accept?.click()) + expect(mocks.accept).toHaveBeenCalledWith({ + invitationId: 'invitation-2', + disclosedWorkspaceIds: [], + disclosedOutcome: 'external', + }) + }) + it('discloses the complete migration and sends exactly those workspace IDs on acceptance', async () => { await renderModal() expect(document.body.textContent).toContain( @@ -137,7 +203,7 @@ describe('ViewInvitationsModal', () => { }) it.each([ - ['external', 'workspace access without joining an organization'], + ['external', null], ['already-member', 'Your organization role will stay the same'], ['blocked', 'This invitation cannot currently be accepted'], ] as const)('discloses %s and preserves its empty-set stale check', async (outcome, message) => { @@ -148,7 +214,11 @@ describe('ViewInvitationsModal', () => { workspaceIdsToMove: [], } await renderModal() - expect(document.body.textContent).toContain(message) + if (message) expect(document.body.textContent).toContain(message) + else { + expect(document.body.textContent).not.toContain('Before you join') + expect(document.body.textContent).not.toContain('without joining an organization') + } expect(document.body.textContent).not.toContain('as an organization admin') await act(async () => button('Accept')?.click()) expect(mocks.accept).toHaveBeenCalledWith({ diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx index 9dd8b438ef2..07640aeff67 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/pending-invitations/view-invitations-modal.tsx @@ -1,5 +1,6 @@ 'use client' +import { Fragment } from 'react' import { Chip, ChipModal, @@ -7,19 +8,24 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - OverflowText, + ChipModalSeparator, + ChipTag, toast, } from '@sim/emcn' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useRouter } from 'next/navigation' +import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { MyInvitation } from '@/lib/api/contracts/invitations' import { getInvitationErrorMessage } from '@/lib/invitations/error-messages' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' import { InvitationDisclosure } from '@/app/invite/components/invitation-disclosure' +import { InvitationWorkspaceAccess } from '@/app/invite/components/invitation-workspace-access' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { useAcceptMyInvitation, useDeclineMyInvitation, @@ -45,13 +51,6 @@ function invitationLabel(inv: MyInvitation): string { return 'Workspace' } -/** Secondary line: who invited, plus role (org) or permission (workspace). */ -function invitationSubLabel(inv: MyInvitation): string { - const invitedBy = inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited' - const detail = inv.kind === 'organization' ? inv.role : inv.grants[0]?.permission - return detail ? `${invitedBy} · ${detail}` : invitedBy -} - interface ViewInvitationsModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -123,59 +122,101 @@ export function ViewInvitationsModal({ open, onOpenChange }: ViewInvitationsModa ) : invitations.length === 0 ? ( No pending invitations. ) : ( - invitations.map((inv) => { + invitations.map((inv, index) => { const isDisclosureMissing = inv.membershipIntent === 'internal' && !inv.joinPreview + const singleWorkspaceGrant = + inv.kind === 'workspace' && inv.grants.length === 1 ? inv.grants[0] : null + const showJoinNotice = inv.joinPreview + ? inv.joinPreview.outcome !== 'external' + : inv.membershipIntent === 'internal' + const showWorkspaceAccess = !singleWorkspaceGrant && inv.grants.length > 0 + const hasDetails = showJoinNotice || showWorkspaceAccess + const actions = ( +
+ void handleDecline(inv)} + aria-label={`Decline invitation to ${invitationLabel(inv)}`} + > + Decline + + void handleAccept(inv)} + > + Accept + +
+ ) return ( -
-
-
- - + + {index > 0 && } +
+
+
+ + ) + } + iconVariant='custom' + title={invitationLabel(inv)} + description={inv.inviterName ? `Invited by ${inv.inviterName}` : 'Invited'} + badge={ + singleWorkspaceGrant && ( + + {singleWorkspaceGrant.permission} access + + ) + } + /> +
+ {!hasDetails && actions}
-
- - - {isDisclosureMissing && ( - void invitationsQuery.refetch()} - > - Refresh invitation - + {showJoinNotice && ( + + + {isDisclosureMissing && ( + void invitationsQuery.refetch()} + > + Refresh invitation + + )} + )} - -
- void handleDecline(inv)} - aria-label={`Decline invitation to ${invitationLabel(inv)}`} - > - Decline - - void handleAccept(inv)} - > - Accept - -
-
+ {showWorkspaceAccess && ( + + + + )} + {hasDetails &&
{actions}
} + + ) }) )} - onOpenChange(false)} - primaryAction={{ label: 'Done', onClick: () => onOpenChange(false) }} - /> + {invitations.length === 0 && ( + onOpenChange(false)} + /> + )} ) } diff --git a/apps/sim/ee/access-requests/components/access-request-review.test.tsx b/apps/sim/ee/access-requests/components/access-request-review.test.tsx index dc7366e0aaa..01d464f0507 100644 --- a/apps/sim/ee/access-requests/components/access-request-review.test.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.test.tsx @@ -13,7 +13,7 @@ vi.mock('@sim/emcn', async (importOriginal) => ({ })) const mocks = vi.hoisted(() => ({ preview: vi.fn(), resolve: vi.fn() })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useAccessRequestPreview: mocks.preview, useResolveAccessRequest: mocks.resolve, })) diff --git a/apps/sim/ee/access-requests/components/access-request-review.tsx b/apps/sim/ee/access-requests/components/access-request-review.tsx index 6772705a1fc..bda96ad2607 100644 --- a/apps/sim/ee/access-requests/components/access-request-review.tsx +++ b/apps/sim/ee/access-requests/components/access-request-review.tsx @@ -13,10 +13,7 @@ import { } from '@sim/emcn' import { PolicyChanges } from '@/ee/access-requests/components/policy-changes' import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { - useAccessRequestPreview, - useResolveAccessRequest, -} from '@/ee/access-requests/hooks/access-requests' +import { useAccessRequestPreview, useResolveAccessRequest } from '@/hooks/queries/access-requests' interface AccessRequestReviewProps { organizationId: string diff --git a/apps/sim/ee/access-requests/components/member-limit-request-action.tsx b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx index f02409de2ad..b5d6be6f38f 100644 --- a/apps/sim/ee/access-requests/components/member-limit-request-action.tsx +++ b/apps/sim/ee/access-requests/components/member-limit-request-action.tsx @@ -2,7 +2,7 @@ import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' interface MemberLimitRequestActionProps { scope: AccessRequestScope diff --git a/apps/sim/ee/access-requests/components/my-access-request-details.tsx b/apps/sim/ee/access-requests/components/my-access-request-details.tsx index 12b69995577..1cc9cc1d181 100644 --- a/apps/sim/ee/access-requests/components/my-access-request-details.tsx +++ b/apps/sim/ee/access-requests/components/my-access-request-details.tsx @@ -13,10 +13,7 @@ import { } from '@sim/emcn' import type { AccessRequestScope } from '@/lib/api/contracts/access-requests' import { ACCESS_REQUEST_STATUS_LABELS } from '@/ee/access-requests/components/status' -import { - useCancelAccessRequest, - useMyAccessRequests, -} from '@/ee/access-requests/hooks/access-requests' +import { useCancelAccessRequest, useMyAccessRequests } from '@/hooks/queries/access-requests' interface MyAccessRequestDetailsProps { scope: AccessRequestScope @@ -30,6 +27,8 @@ export function MyAccessRequestDetails({ scope, requestId, onClose }: MyAccessRe const request = details.isSuccess ? details.data.requests[0] : undefined const title = request ? `${request.targetLabel} request` : 'Access request' + if (details.isSuccess && !request) return null + return ( void details.refetch()}>Try again )} - {details.isSuccess && !request && ( -

- This request is no longer available. -

- )} {request && ( <>
diff --git a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx index 3a97c225526..c2535b7e161 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +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' @@ -21,7 +21,7 @@ vi.mock('@/lib/core/config/deployment-shape', () => ({ vi.mock('@/hooks/queries/workspace-host', () => ({ useWorkspaceHostContextQuery: mocks.workspace, })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useMyAccessRequests: mocks.mine, useDiscoverAccessRequests: mocks.discovery, @@ -69,11 +69,14 @@ describe('compact requester history', () => { act(() => root.unmount()) container.remove() }) - const render = (searchParams = '') => + const render = ( + searchParams = '', + props: Partial> = {} + ) => act(() => root.render( - + ) ) @@ -85,6 +88,24 @@ describe('compact requester history', () => { expect(container.textContent).not.toContain('Cancel') expect(container.querySelector('[aria-label="View request for Slack"]')).not.toBeNull() }) + + it('uses organization scope inside the organization shell without a workspace exit link', () => { + 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('Your workspaces') + expect(container.textContent).not.toContain('Back to Sim') + }) + + it('returns standalone visitors through the shared app entry', () => { + render('', { + scope: { kind: 'organization', organizationId: 'organization' }, + standalone: true, + }) + const back = container.querySelector('a[href="/home"]') + expect(back?.textContent).toBe('Back to Sim') + }) it('loads a deep-linked request independently of the retained list page', () => { render('?page=3&requestId=request') expect(mocks.mine).toHaveBeenCalledWith(scope, 75, undefined, true) @@ -92,6 +113,14 @@ describe('compact requester history', () => { expect(document.querySelector('[role="dialog"]')?.textContent).toContain(request.reason) expect(document.querySelector('[role="dialog"]')?.textContent).toContain('Cancel request') }) + it('leaves the list visible when a deep-linked request no longer exists', () => { + mocks.mine.mockImplementation((_scope, _offset, id) => successful(id ? [] : [request])) + render('?page=3&requestId=removed-request') + expect(mocks.mine).toHaveBeenCalledWith(scope, 0, 'removed-request') + expect(container.textContent).toContain('Slack') + expect(container.textContent).toContain('Page 4') + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) it('does not show cached detail or allow cancellation after an authorized detail refresh fails', () => { mocks.mine.mockImplementation((_scope, _offset, id) => id diff --git a/apps/sim/ee/access-requests/components/my-access-requests.tsx b/apps/sim/ee/access-requests/components/my-access-requests.tsx index 17c005c982e..12b72bac03f 100644 --- a/apps/sim/ee/access-requests/components/my-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/my-access-requests.tsx @@ -1,12 +1,23 @@ 'use client' -import { Chip, ChipInput, ChipLink, ChipSwitch, ChipTag } from '@sim/emcn' +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 { WORKSPACES_PATH } from '@/lib/navigation/paths' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { RESOURCE_LIST_STACK, @@ -19,21 +30,24 @@ import { 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 '@/ee/access-requests/hooks/access-requests' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +} 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 }: MyAccessRequestsProps) { +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 : '' @@ -68,8 +82,15 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) { : 'Organization requests' return ( -
-
+
+

My access requests

@@ -80,9 +101,7 @@ export function MyAccessRequests({ scope }: MyAccessRequestsProps) {

)}
- {scope.kind === 'organization' && ( - Your workspaces - )} + {standalone && Back to Sim}
{showCatalog && ( void setParams({ requestId: request.id }, { history: 'push' })} clickLabel={`View request for ${request.targetLabel}`} - navigable /> ))}
diff --git a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx index ba7441854d1..8b52c547f6b 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.test.tsx @@ -11,7 +11,7 @@ const mocks = vi.hoisted(() => ({ mutate: vi.fn(), refetch: vi.fn(), })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ ACCESS_REQUEST_PAGE_SIZE: 25, useAccessRequestSettings: mocks.settings, useOrganizationAccessRequests: mocks.requests, 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 d192502a01a..e989f8cb369 100644 --- a/apps/sim/ee/access-requests/components/organization-access-requests.tsx +++ b/apps/sim/ee/access-requests/components/organization-access-requests.tsx @@ -20,13 +20,13 @@ import { accessReviewSearchParams, } 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, useAccessRequestSettings, useOrganizationAccessRequests, useUpdateAccessRequestSettings, -} from '@/ee/access-requests/hooks/access-requests' -import { ACCESS_REQUEST_MAX_SEARCH_LENGTH } from '@/ee/access-requests/lib/constants' +} from '@/hooks/queries/access-requests' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -160,7 +160,6 @@ export function OrganizationAccessRequests({ } onClick={() => void setParams({ 'request-id': request.id }, { history: 'push' })} clickLabel={`Review ${request.targetLabel} request from ${request.requester.name || request.requester.email}`} - navigable /> ))}
diff --git a/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx index a48b07d88a6..bf36909da9c 100644 --- a/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.test.tsx @@ -34,7 +34,7 @@ vi.mock('@sim/emcn/icons', () => ({ BookOpen: () => null, })) vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ useUserPermissionConfig: policy })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useDiscoverAccessRequests: discovery, })) vi.mock('@/ee/access-requests/components/request-access-action', () => ({ diff --git a/apps/sim/ee/access-requests/components/permission-access-boundary.tsx b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx index 77d0f78487b..a3067e64f88 100644 --- a/apps/sim/ee/access-requests/components/permission-access-boundary.tsx +++ b/apps/sim/ee/access-requests/components/permission-access-boundary.tsx @@ -10,8 +10,8 @@ import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/re import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups' import { RequestAccessAction } from '@/ee/access-requests/components/request-access-action' -import { workspaceFeatureDiscoveryQuery } from '@/ee/access-requests/hooks/access-request-keys' -import { useDiscoverAccessRequests } from '@/ee/access-requests/hooks/access-requests' +import { useDiscoverAccessRequests } from '@/hooks/queries/access-requests' +import { workspaceFeatureDiscoveryQuery } from '@/hooks/queries/utils/access-request-keys' /** Safe feature metadata shared by navigation and access-required pages. */ export function useWorkspaceAccessRequestFeatures() { 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 7a13b40ac72..259ad7a7c41 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 @@ -17,7 +17,7 @@ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mocks.push }), usePathname: () => '/workspace/workspace', })) -vi.mock('@/ee/access-requests/hooks/access-requests', () => ({ +vi.mock('@/hooks/queries/access-requests', () => ({ useCreateAccessRequest: () => ({ mutate: mocks.create, isPending: false, error: null }), useDiscoverAccessRequests: mocks.discovery, })) 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 49f5641ce3d..c7bcb7e3f0d 100644 --- a/apps/sim/ee/access-requests/components/request-access-action.tsx +++ b/apps/sim/ee/access-requests/components/request-access-action.tsx @@ -14,14 +14,10 @@ import { type ChipProps, toast, } from '@sim/emcn' -import { Lock } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import type { AccessRequestScope, AccessRequestTarget } from '@/lib/api/contracts/access-requests' -import { - useCreateAccessRequest, - useDiscoverAccessRequests, -} from '@/ee/access-requests/hooks/access-requests' import { getAccessRequestTargetKey } from '@/ee/access-requests/lib/targets' +import { useCreateAccessRequest, useDiscoverAccessRequests } from '@/hooks/queries/access-requests' interface RequestAccessActionProps { scope: AccessRequestScope @@ -55,7 +51,6 @@ export function RequestAccessAction({ return ( onViewRequest(pendingRequestId)} aria-label={`View request for ${label}`} > @@ -67,7 +62,6 @@ export function RequestAccessAction({ View request @@ -100,7 +94,6 @@ function RequestableAccessAction({ <> setOpen(true)} aria-label={ target.kind === 'usage_limit' diff --git a/apps/sim/ee/access-requests/components/search-params.ts b/apps/sim/ee/access-requests/components/search-params.ts index efe8d9cdb99..208950b9dbf 100644 --- a/apps/sim/ee/access-requests/components/search-params.ts +++ b/apps/sim/ee/access-requests/components/search-params.ts @@ -23,6 +23,7 @@ const accessRequestSearchParser = createParser({ serialize: String, }).withDefault('') +/** Missing IDs mean no request selection or organization context, so there is no default. */ const accessRequestIdParser = createParser({ parse: (value) => value.length > 0 && value.length <= ACCESS_REQUEST_MAX_ID_LENGTH ? value : null, diff --git a/apps/sim/ee/access-requests/hooks/access-requests.test.tsx b/apps/sim/hooks/queries/access-requests.test.tsx similarity index 98% rename from apps/sim/ee/access-requests/hooks/access-requests.test.tsx rename to apps/sim/hooks/queries/access-requests.test.tsx index 9ce34d47fc1..21d97bf12b7 100644 --- a/apps/sim/ee/access-requests/hooks/access-requests.test.tsx +++ b/apps/sim/hooks/queries/access-requests.test.tsx @@ -14,12 +14,12 @@ import { listMyAccessRequestsContract, resolveAccessRequestContract, } from '@/lib/api/contracts/access-requests' -import { accessRequestKeys } from '@/ee/access-requests/hooks/access-request-keys' import { useDiscoverAccessRequests, useMyAccessRequests, useResolveAccessRequest, -} from '@/ee/access-requests/hooks/access-requests' +} from '@/hooks/queries/access-requests' +import { accessRequestKeys } from '@/hooks/queries/utils/access-request-keys' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { workspaceUsageKeys } from '@/hooks/queries/utils/workspace-usage-keys' diff --git a/apps/sim/ee/access-requests/hooks/access-requests.ts b/apps/sim/hooks/queries/access-requests.ts similarity index 99% rename from apps/sim/ee/access-requests/hooks/access-requests.ts rename to apps/sim/hooks/queries/access-requests.ts index 9b1d31d5c6d..281e0c48df8 100644 --- a/apps/sim/ee/access-requests/hooks/access-requests.ts +++ b/apps/sim/hooks/queries/access-requests.ts @@ -22,11 +22,11 @@ import type { WorkspaceCreditAvailability, WorkspaceUsageGate, } from '@/lib/api/contracts/workspaces' +import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' import { ACCESS_REQUESTS_STALE_TIME, accessRequestKeys, -} from '@/ee/access-requests/hooks/access-request-keys' -import { ACCESS_REQUEST_LIST_PAGE_SIZE } from '@/ee/access-requests/lib/constants' +} from '@/hooks/queries/utils/access-request-keys' import { invalidateWorkspaceUsage } from '@/hooks/queries/utils/invalidate-usage' import { organizationKeys } from '@/hooks/queries/utils/organization-keys' import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' diff --git a/apps/sim/ee/access-requests/hooks/access-request-keys.ts b/apps/sim/hooks/queries/utils/access-request-keys.ts similarity index 100% rename from apps/sim/ee/access-requests/hooks/access-request-keys.ts rename to apps/sim/hooks/queries/utils/access-request-keys.ts diff --git a/apps/sim/lib/api/contracts/invitations.ts b/apps/sim/lib/api/contracts/invitations.ts index ea21b56c7a5..5605918bc12 100644 --- a/apps/sim/lib/api/contracts/invitations.ts +++ b/apps/sim/lib/api/contracts/invitations.ts @@ -1,7 +1,7 @@ import { z } from 'zod' import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' -import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces' +import { workspacePermissionSchema, workspaceSchema } from '@/lib/api/contracts/workspaces' import { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' export { MAX_INVITE_EMAILS, MAX_INVITE_WORKSPACES } from '@/lib/invitations/limits' @@ -194,6 +194,7 @@ export const invitationDetailsSchema = z.object({ z.object({ workspaceId: z.string(), workspaceName: z.string().nullable(), + workspaceLogoUrl: workspaceSchema.shape.logoUrl, permission: workspacePermissionSchema, }) ), diff --git a/apps/sim/lib/invitations/core.test.ts b/apps/sim/lib/invitations/core.test.ts index d9d796e5e34..8eebbd61907 100644 --- a/apps/sim/lib/invitations/core.test.ts +++ b/apps/sim/lib/invitations/core.test.ts @@ -90,6 +90,7 @@ vi.mock('@sim/audit', () => auditMock) import { acceptInvitation, + getInvitationById, rejectInvitation, resolveInvitationAdmissionOrganizationId, revokeInvitationAsAdmin, @@ -128,6 +129,39 @@ function executedSqlContaining(substring: string): boolean { afterAll(resetEnvFlagsMock) +describe('invitation workspace identity', () => { + it('hydrates uploaded logos and workspaces without a logo in the grant query', async () => { + resetDbChainMock() + queueWhereResponses([ + [{ id: 'invitation', organizationId: null, inviterId: 'inviter' }], + [ + { + id: 'grant-one', + workspaceId: 'one', + workspaceName: 'Design', + workspaceLogoUrl: 'https://example.com/design.png', + permission: 'read', + }, + { + id: 'grant-two', + workspaceId: 'two', + workspaceName: 'Engineering', + workspaceLogoUrl: null, + permission: 'write', + }, + ], + [{ name: 'Inviter', email: 'inviter@example.com' }], + ]) + + const result = await getInvitationById('invitation') + + expect(result?.grants.map((grant) => grant.workspaceLogoUrl)).toEqual([ + 'https://example.com/design.png', + null, + ]) + }) +}) + describe('acceptInvitation', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/invitations/core.ts b/apps/sim/lib/invitations/core.ts index 2bd155871f3..4d7583295a6 100644 --- a/apps/sim/lib/invitations/core.ts +++ b/apps/sim/lib/invitations/core.ts @@ -71,6 +71,7 @@ export interface InvitationWithGrants { workspaceId: string permission: 'admin' | 'write' | 'read' workspaceName: string | null + workspaceLogoUrl: string | null }> organizationName: string | null inviterName: string | null @@ -221,6 +222,7 @@ async function hydrateInvitation( workspaceId: invitationWorkspaceGrant.workspaceId, permission: invitationWorkspaceGrant.permission, workspaceName: workspace.name, + workspaceLogoUrl: workspace.logoUrl, }) .from(invitationWorkspaceGrant) .leftJoin(workspace, eq(workspace.id, invitationWorkspaceGrant.workspaceId)) @@ -266,6 +268,7 @@ async function hydrateInvitation( workspaceId: grant.workspaceId, permission: grant.permission, workspaceName: grant.workspaceName, + workspaceLogoUrl: grant.workspaceLogoUrl, })), organizationName, inviterName: inviterRow?.name ?? null, diff --git a/apps/sim/lib/navigation/paths.test.ts b/apps/sim/lib/navigation/paths.test.ts index d966ba8a8ea..84c81a68131 100644 --- a/apps/sim/lib/navigation/paths.test.ts +++ b/apps/sim/lib/navigation/paths.test.ts @@ -32,6 +32,7 @@ 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 b788834eb40..17bf3036b39 100644 --- a/apps/sim/lib/navigation/paths.ts +++ b/apps/sim/lib/navigation/paths.ts @@ -36,6 +36,7 @@ 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) => From 55edd792806fe407dd0d51573f6e8ff5a4a1d3ea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 22 Sep 2026 11:22:26 -0700 Subject: [PATCH 2/2] fix(access): preserve standalone requests on navigation lookup failures --- apps/sim/app/access-requests/page.test.tsx | 17 +++++++++++++++++ apps/sim/app/access-requests/page.tsx | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/access-requests/page.test.tsx b/apps/sim/app/access-requests/page.test.tsx index 2e8670c6ab6..652d4a8adc6 100644 --- a/apps/sim/app/access-requests/page.test.tsx +++ b/apps/sim/app/access-requests/page.test.tsx @@ -18,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(() => { @@ -124,4 +125,20 @@ describe('access request sign-in redirect', () => { 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, + }) + }) }) diff --git a/apps/sim/app/access-requests/page.tsx b/apps/sim/app/access-requests/page.tsx index c43870c9e15..008cb5ab5ae 100644 --- a/apps/sim/app/access-requests/page.tsx +++ b/apps/sim/app/access-requests/page.tsx @@ -1,5 +1,6 @@ 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' @@ -28,6 +29,7 @@ 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) { @@ -53,7 +55,13 @@ export default async function AccessRequestsPage({ searchParams }: AccessRequest } if (params.view !== 'admin') { - const context = await getOrganizationSurfaceContext(params.organizationId, session.user.id) + 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, {