From 6423f877774f6c8ab2cb5f3f8c20bd6858d39690 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 18:06:23 -0600 Subject: [PATCH 1/6] refactor(mosaic): support connected invitation table states --- .../invitations-table-tab.messages.ts | 2 + .../invitations-table-tab.types.ts | 6 +- .../invitations-table-tab.view.tsx | 75 +++++++++++++------ 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts index 74dbae1705e..5a59c03b263 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts @@ -12,6 +12,8 @@ export const invitationsTableTabMessages = { noInvitations: 'No pending invitations', noInvitationsDescription: 'Invite people to add them to this organization.', loading: 'Loading invitations', + loadError: 'Unable to load invitations', + retry: 'Try again', selectAll: 'Select all invitations on this page', select: 'Select {name}', manage: 'Manage {name}', diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts index a073db301e0..f009f02ed2b 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts @@ -18,12 +18,14 @@ export interface InvitationsTableTabViewProps { totalCount: number; page: number; pageSize?: number; - searchValue: string; + searchValue?: string; isLoading: boolean; isFetching?: boolean; + isError?: boolean; + onRetry?: () => void; onPageChange: (page: number) => void; onPageSizeChange?: (pageSize: number) => void; - onSearchChange: (value: string) => void; + onSearchChange?: (value: string) => void; onInvite?: MouseEventHandler; onRevoke?: (id: string) => void | Promise; onBulkAction?: (ids: string[]) => void; diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index fa05c2cad1c..a40fcd5d3a9 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -21,6 +21,7 @@ import { tableTabStyles } from './table-tab.styles'; const getRowId = (invitation: OrganizationProfileInvitation) => invitation.id; +// eslint-disable-next-line sonarjs/cognitive-complexity -- The invitation table renders independent states and optional controls in one view. export function InvitationsTableTabView({ invitations, onInvite, @@ -28,7 +29,7 @@ export function InvitationsTableTabView({ totalCount, page, pageSize = 10, - searchValue, + searchValue = '', onSearchChange, onPageChange, onPageSizeChange, @@ -37,16 +38,19 @@ export function InvitationsTableTabView({ onSortChange, isLoading, isFetching = false, + isError = false, + onRetry, }: InvitationsTableTabViewProps) { const m = useMessages('invitationsTableTab'); const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); const searchInput = useRef(null); const inviteButton = useRef(null); + const tableContainer = useRef(null); const removalFocus = useListRemovalFocus({ ids: invitations.map(getRowId), onRemove: onRevoke, - fallback: () => inviteButton.current ?? searchInput.current, + fallback: () => inviteButton.current ?? searchInput.current ?? tableContainer.current, }); const revokeDialog = useMemo(() => Confirmation.createHandle(), []); const { table, sortHeader, pagination } = useServerDataTable({ @@ -61,29 +65,44 @@ export function InvitationsTableTabView({ onPageChange, onPageSizeChange, searchValue, - onSearchChange, + onSearchChange: onSearchChange ?? (() => {}), }); return ( <> -
- - - {onInvite ? ( - - ) : null} - +
+ {onSearchChange || onInvite ? ( + + {onSearchChange ? ( + + ) : null} + {onInvite ? ( + + ) : null} + + ) : null} + {isError && table.rows.length > 0 ? ( +

+ {m.loadError} {onRetry ? : null} +

+ ) : null} {m.loading} + ) : isError && table.rows.length === 0 ? ( + + + + {m.loadError} + {onRetry ? ( + + + + ) : null} + + ) : table.rows.length === 0 ? ( From 68118d9d70c9574bd598f7d6e33f737ae94f2d29 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 18:06:40 -0600 Subject: [PATCH 2/6] feat(mosaic): connect organization invitations and revocation --- .changeset/tidy-invitations-tab.md | 2 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 3 + .../feature/fake-fapi/organization-members.ts | 25 ++ ...ation-profile-invitations.feature.test.tsx | 266 ++++++++++++++++++ .../invitations-table-tab.controller.ts | 23 ++ .../invitations-table-tab.model.ts | 50 ++++ .../members-table-tab.model.ts | 12 - ...rganization-profile-members-panel.model.ts | 24 ++ .../organization-profile-members-panel.tsx | 43 ++- 9 files changed, 431 insertions(+), 17 deletions(-) create mode 100644 .changeset/tidy-invitations-tab.md create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts create mode 100644 packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.model.ts diff --git a/.changeset/tidy-invitations-tab.md b/.changeset/tidy-invitations-tab.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-invitations-tab.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 9935aa83a46..3d6ab2e3aed 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -5,6 +5,7 @@ import type { EmailAddressJSON, EnterpriseConnectionJSON, OAuthProvider, + OrganizationInvitationJSON, OrganizationJSON, OrganizationMembershipJSON, OrganizationSuggestionJSON, @@ -59,6 +60,7 @@ export interface FakeFapiState { roles: RoleJSON[]; hasRoleSetMigration: boolean; invitations: UserOrganizationInvitationJSON[]; + organizationInvitations: OrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; apiKeys: ApiKeyJSON[]; verification: FakeVerificationState; @@ -293,6 +295,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { roles: [], hasRoleSetMigration: false, invitations: [], + organizationInvitations: [], suggestions: [], apiKeys: [], passwordUpdates: [], diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts index d4654ca2e56..5699bc52872 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts @@ -39,6 +39,31 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: ) : missing(); }), + http.get(fapiUrl('/v1/organizations/:organizationId/invitations'), ({ params, request }) => { + const url = new URL(request.url); + const statuses = url.searchParams.getAll('status'); + const matching = state.organizationInvitations.filter( + invitation => + invitation.organization_id === params.organizationId && + (statuses.length === 0 || statuses.includes(invitation.status)), + ); + const offset = Number(url.searchParams.get('offset') ?? 0); + const limit = Number(url.searchParams.get('limit') ?? 10); + return envelope({ data: matching.slice(offset, offset + limit), total_count: matching.length }, null); + }), + http.post(fapiUrl('/v1/organizations/:organizationId/invitations/:invitationId/revoke'), ({ params }) => { + const invitation = state.organizationInvitations.find( + item => item.organization_id === params.organizationId && item.id === params.invitationId, + ); + if (!invitation) { + return missing(); + } + const revoked = { ...invitation, status: 'revoked' as const }; + state.organizationInvitations = state.organizationInvitations.map(item => + item.id === revoked.id ? revoked : item, + ); + return envelope(revoked, null); + }), http.post(fapiUrl('/v1/organizations/:organizationId/memberships/:userId'), async ({ params, request }) => { const member = state.memberships.find( item => item.organization.id === params.organizationId && item.public_user_data?.user_id === params.userId, diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx new file mode 100644 index 00000000000..3b1192550a1 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx @@ -0,0 +1,266 @@ +import type { OrganizationInvitationJSON } from '@clerk/shared/types'; +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiMembership, fapiOrganization, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { OrganizationProfileMembersPanel } from '../organization-profile-members-panel'; + +const organization = fapiOrganization({ id: 'org_invitations', name: 'Acme' }); +const invitation: OrganizationInvitationJSON = { + object: 'organization_invitation', + id: 'orginv_ada', + email_address: 'ada@example.com', + organization_id: organization.id, + public_metadata: {}, + status: 'pending', + role: 'org:member', + role_name: 'Member', + created_at: 1_785_000_000_000, + updated_at: 1_785_000_000_000, +}; + +function serve(permissions: string[]) { + const membership = fapiMembership(organization, { permissions }); + const user = fapiUser({ id: 'user_invitations', organization_memberships: [membership] }); + return serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_invitations', user, last_active_organization_id: organization.id })]), + memberships: [membership], + organizationInvitations: [invitation], + }); +} + +describe('connected organization invitations', () => { + it('shows members without requesting invitations for readers', async () => { + serve(['org:sys_memberships:read']); + const requests = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + await renderWithClerk(); + expect(await screen.findByRole('tab', { name: 'Members' })).toBeVisible(); + expect(screen.queryByRole('tab', { name: 'Invitations' })).toBeNull(); + expect(requests.requests).toHaveLength(0); + requests.release(); + }); + + it('hides the panel without read or manage permission', async () => { + serve([]); + const requests = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + const { container } = await renderWithClerk(); + expect(container).toBeEmptyDOMElement(); + expect(requests.requests).toHaveLength(0); + requests.release(); + }); + + it('shows both tabs when both permissions are present', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await renderWithClerk(); + expect(await screen.findByRole('tab', { name: 'Members' })).toBeVisible(); + expect(screen.getByRole('tab', { name: 'Invitations' })).toBeVisible(); + await userEvent.setup().click(screen.getByRole('tab', { name: 'Invitations' })); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + }); + + it('loads pending invitations for managers without fetching members or roles', async () => { + serve(['org:sys_memberships:manage']); + const requests = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + await renderWithClerk(); + await waitFor(() => expect(requests.requests).toHaveLength(1)); + const url = new URL(requests.requests[0]?.url ?? ''); + expect(url.searchParams.get('limit')).toBe('10'); + expect(url.searchParams.getAll('status')).toEqual(['pending']); + expect(members.requests).toHaveLength(0); + expect(roles.requests).toHaveLength(0); + requests.release(); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + expect(screen.queryByRole('searchbox', { name: 'Search invitations' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invited' })).toBeNull(); + members.release(); + roles.release(); + }); + + it('shows a failed initial load and retries the invitation list', async () => { + serve(['org:sys_memberships:manage']); + const initial = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + await renderWithClerk(); + await waitFor(() => expect(initial.requests).toHaveLength(1)); + initial.fail('network_error', 'Unavailable'); + expect(await screen.findByText('Unable to load invitations', {}, { timeout: 12_000 })).toBeVisible(); + serve(['org:sys_memberships:manage']); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + }, 20_000); + + it('revokes only after confirmation and restores focus after the final row', async () => { + const fapi = serve(['org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(fapi.organizationInvitations[0]?.status).toBe('pending'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(fapi.organizationInvitations[0]?.status).toBe('revoked')); + expect(await screen.findByText('No pending invitations')).toBeVisible(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + await waitFor(() => + expect(screen.getByRole('group', { name: 'Invitations' })).toContainElement(document.activeElement), + ); + }, 20_000); + + it('keeps a failed revoke open for retry', async () => { + const fapi = serve(['org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + const revoke = holdRequests('post', '/v1/organizations/:organizationId/invitations/:invitationId/revoke'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); + revoke.fail('invitation_rejected', 'Invitation rejected'); + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toBeVisible(); + expect(fapi.organizationInvitations[0]?.status).toBe('pending'); + const retry = serve(['org:sys_memberships:manage']); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(retry.organizationInvitations[0]?.status).toBe('revoked')); + }); + + it('keeps remaining rows and offers retry when refresh fails after revoke', async () => { + const fapi = serve(['org:sys_memberships:manage']); + const other = { ...invitation, id: 'orginv_other', email_address: 'other@example.com' }; + fapi.organizationInvitations.push(other); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + const refresh = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(refresh.requests).toHaveLength(1)); + refresh.fail('network_error', 'Unavailable'); + expect(await screen.findByText('Unable to load invitations', {}, { timeout: 12_000 })).toBeVisible(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.organizationInvitations.find(item => item.id === invitation.id)?.status).toBe('revoked'); + expect(screen.getByText('other@example.com')).toBeVisible(); + expect(screen.queryByText('ada@example.com')).toBeNull(); + const retry = serve(['org:sys_memberships:manage']); + retry.organizationInvitations = [other]; + await user.click(screen.getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByText('Unable to load invitations')).toBeNull()); + expect(screen.getByText('other@example.com')).toBeVisible(); + expect(screen.queryByText('ada@example.com')).toBeNull(); + }, 20_000); + + it('returns to the previous page after revoking the last invitation on a later page', async () => { + const fapi = serve(['org:sys_memberships:manage']); + fapi.organizationInvitations = [ + ...Array.from({ length: 10 }, (_, index) => ({ + ...invitation, + id: `orginv_${index}`, + email_address: `user${index}@example.com`, + })), + invitation, + ]; + await renderWithClerk(); + const user = userEvent.setup(); + expect(await screen.findByText('user0@example.com')).toBeVisible(); + const nextPage = holdRequests('get', '/v1/organizations/:organizationId/invitations'); + await user.click(await screen.findByRole('button', { name: 'Next invitations page' })); + await waitFor(() => expect(nextPage.requests).toHaveLength(1)); + expect(screen.getByText('user0@example.com')).toBeVisible(); + nextPage.release(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + expect(await screen.findByText('user0@example.com')).toBeVisible(); + expect(screen.queryByText('ada@example.com')).toBeNull(); + }); + + it('closes an obsolete revoke confirmation after switching sessions', async () => { + const fapi = serve(['org:sys_memberships:manage']); + const currentUser = fapi.client.sessions[0]?.user; + if (!currentUser) { + throw new Error('Expected a signed-in user'); + } + fapi.client = fapiClient([ + ...fapi.client.sessions, + fapiSession({ id: 'sess_second', user: currentUser, last_active_organization_id: organization.id }), + ]); + const { clerk } = await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + await act(() => clerk.setActive({ session: 'sess_second' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(fapi.organizationInvitations[0]?.status).toBe('pending'); + }); + + it('replaces invitations and closes confirmation after switching organizations', async () => { + const otherOrganization = fapiOrganization({ id: 'org_other', name: 'Other' }); + const firstMembership = fapiMembership(organization, { permissions: ['org:sys_memberships:manage'] }); + const secondMembership = fapiMembership(otherOrganization, { permissions: ['org:sys_memberships:manage'] }); + const user = fapiUser({ + id: 'user_invitations', + organization_memberships: [firstMembership, secondMembership], + }); + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_invitations', user, last_active_organization_id: organization.id })]), + memberships: [firstMembership, secondMembership], + organizationInvitations: [ + invitation, + { + ...invitation, + id: 'orginv_other', + organization_id: otherOrganization.id, + email_address: 'other@example.com', + }, + ], + }); + const { clerk } = await renderWithClerk(); + const actor = userEvent.setup(); + await actor.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await actor.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + await act(() => clerk.setActive({ organization: otherOrganization.id })); + expect(await screen.findByText('other@example.com')).toBeVisible(); + expect(screen.queryByText('ada@example.com')).toBeNull(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + }); + + it('ignores an old revoke completion after switching organizations', async () => { + const otherOrganization = fapiOrganization({ id: 'org_other', name: 'Other' }); + const firstMembership = fapiMembership(organization, { permissions: ['org:sys_memberships:manage'] }); + const secondMembership = fapiMembership(otherOrganization, { permissions: ['org:sys_memberships:manage'] }); + const user = fapiUser({ id: 'user_invitations', organization_memberships: [firstMembership, secondMembership] }); + const fapi = serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_invitations', user, last_active_organization_id: organization.id })]), + memberships: [firstMembership, secondMembership], + organizationInvitations: [ + invitation, + { + ...invitation, + id: 'orginv_other', + organization_id: otherOrganization.id, + email_address: 'other@example.com', + }, + ], + }); + const { clerk } = await renderWithClerk(); + const actor = userEvent.setup(); + await actor.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await actor.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + const revoke = holdRequests('post', '/v1/organizations/:organizationId/invitations/:invitationId/revoke'); + await actor.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); + await act(() => clerk.setActive({ organization: otherOrganization.id })); + expect(await screen.findByText('other@example.com')).toBeVisible(); + revoke.release(); + await waitFor(() => expect(fapi.organizationInvitations[0]?.status).toBe('revoked')); + expect(screen.getByText('other@example.com')).toBeVisible(); + expect(screen.queryByRole('alertdialog')).toBeNull(); + expect(screen.queryByText('ada@example.com')).toBeNull(); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts new file mode 100644 index 00000000000..db791e763a7 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts @@ -0,0 +1,23 @@ +import { useLocale } from '../../localization'; +import { formatDate } from '../api-keys/api-keys-table.format'; +import type { useInvitationsTableModel } from './invitations-table-tab.model'; + +export function useInvitationsTableController(model: ReturnType) { + const locale = useLocale(); + return { + invitations: model.rows.map(({ id, emailAddress, createdAt, roleName }) => ({ + id, + email: emailAddress, + invitedAtLabel: formatDate(createdAt, locale), + roleLabel: roleName, + })), + totalCount: model.totalCount, + page: model.page, + isLoading: model.isLoading, + isFetching: model.isFetching, + isError: model.isError, + onRetry: model.retry, + onPageChange: model.fetchPage, + onRevoke: model.revoke, + }; +} diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts new file mode 100644 index 00000000000..3f09568f4d6 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts @@ -0,0 +1,50 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useOrganization, useSession } from '@clerk/shared/react'; +import type { OrganizationInvitationResource } from '@clerk/shared/types'; + +const PAGE_SIZE = 10; +const MANAGE_PERMISSION = 'org:sys_memberships:manage'; + +export function useInvitationsTableModel() { + const { session } = useSession(); + const canManage = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + const { organization, invitations } = useOrganization({ + invitations: canManage ? { pageSize: PAGE_SIZE, keepPreviousData: true } : undefined, + }); + const rows = (invitations?.data ?? []) + .filter(invitation => invitation.organizationId === organization?.id && invitation.status === 'pending') + .map(toInvitation); + + return { + rows, + totalCount: invitations?.count ?? 0, + page: invitations?.page ?? 1, + isLoading: invitations?.isLoading ?? true, + isFetching: invitations?.isFetching ?? false, + isError: invitations?.isError ?? false, + retry: () => invitations?.revalidate?.(), + fetchPage: (page: number) => invitations?.fetchPage?.(page), + revoke: canManage + ? async (id: string) => { + const invitation = invitations?.data?.find( + item => item.id === id && item.organizationId === organization?.id && item.status === 'pending', + ); + if (!invitation) { + throw new ClerkRuntimeError('This invitation cannot be revoked.', { code: 'invitation_unavailable' }); + } + const page = invitations?.page ?? 1; + const lastOnPage = invitations?.data?.length === 1 && page > 1; + await invitation.revoke(); + await invitations?.revalidate?.(); + if (lastOnPage) { + invitations?.fetchPage?.(page - 1); + } + } + : undefined, + }; +} + +function toInvitation(invitation: OrganizationInvitationResource) { + const { id, emailAddress, createdAt, roleName } = invitation; + return { id, emailAddress, createdAt, roleName }; +} diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.model.ts index 377b65190f0..f8a2278e091 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.model.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.model.ts @@ -9,18 +9,6 @@ const PAGE_SIZE = 10; const READ_PERMISSION = 'org:sys_memberships:read'; const MANAGE_PERMISSION = 'org:sys_memberships:manage'; -export function useMembersTableAccessModel() { - const { isLoaded, organization } = useOrganization(); - const { isLoaded: isSessionLoaded, session } = useSession(); - if (!isLoaded || !isSessionLoaded) { - return { status: 'loading' as const }; - } - if (!organization || !session?.checkAuthorization({ permission: READ_PERMISSION })) { - return { status: 'hidden' as const }; - } - return { status: 'ready' as const, organizationId: organization.id, sessionId: session.id }; -} - export function useMembersTableModel(query: string) { const { session } = useSession(); const canRead = session?.checkAuthorization({ permission: READ_PERMISSION }) ?? false; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.model.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.model.ts new file mode 100644 index 00000000000..2afdc2f5c82 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.model.ts @@ -0,0 +1,24 @@ +import { useOrganization, useSession } from '@clerk/shared/react'; + +const READ_PERMISSION = 'org:sys_memberships:read'; +const MANAGE_PERMISSION = 'org:sys_memberships:manage'; + +export function useMembersPanelAccessModel() { + const { isLoaded, organization } = useOrganization(); + const { isLoaded: isSessionLoaded, session } = useSession(); + if (!isLoaded || !isSessionLoaded) { + return { status: 'loading' as const }; + } + const canReadMembers = session?.checkAuthorization({ permission: READ_PERMISSION }) ?? false; + const canManageInvitations = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + if (!organization || !session || (!canReadMembers && !canManageInvitations)) { + return { status: 'hidden' as const }; + } + return { + status: 'ready' as const, + organizationId: organization.id, + sessionId: session.id, + canReadMembers, + canManageInvitations, + }; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.tsx index d3e223779c4..bdfe86bba69 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.tsx @@ -1,23 +1,56 @@ import type { ReactNode } from 'react'; +import { useInvitationsTableController } from './invitations-table-tab.controller'; +import { useInvitationsTableModel } from './invitations-table-tab.model'; import { useMembersTableController, useMembersTableSearchController } from './members-table-tab.controller'; -import { useMembersTableAccessModel, useMembersTableModel } from './members-table-tab.model'; +import { useMembersTableModel } from './members-table-tab.model'; +import { useMembersPanelAccessModel } from './organization-profile-members-panel.model'; import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; export function OrganizationProfileMembersPanel({ fallback }: { fallback?: ReactNode }) { - const access = useMembersTableAccessModel(); + const access = useMembersPanelAccessModel(); if (access.status === 'loading') { return fallback ?? null; } if (access.status === 'hidden') { return null; } - return ; + return ( + + ); } -function ConnectedMembersPanel() { +function ConnectedMembersPanel({ + canReadMembers, + canManageInvitations, +}: { + canReadMembers: boolean; + canManageInvitations: boolean; +}) { + const model = useInvitationsTableModel(); + const invitations = useInvitationsTableController(model); + if (canReadMembers) { + return ; + } + return ; +} + +function ConnectedReadableMembersPanel({ + invitations, +}: { + invitations?: ReturnType; +}) { const search = useMembersTableSearchController(); const model = useMembersTableModel(search.query); const members = useMembersTableController(model); - return ; + return ( + + ); } From bb960448bc2729cab4c0942db0fd25fcabc7ca89 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 19:54:21 -0600 Subject: [PATCH 3/6] test(mosaic): remove sequential invitation revocation focus test --- .../invitations-table-tab.view.test.tsx | 49 +------------------ 1 file changed, 1 insertion(+), 48 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index 6823e6c4865..237a32556b7 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -1,9 +1,7 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { deferred } from '../../../__tests__/async'; import { MosaicProvider } from '../../../mosaic-provider'; import type { InvitationsTableTabViewProps } from '../invitations-table-tab.types'; import { InvitationsTableTabView } from '../invitations-table-tab.view'; @@ -142,48 +140,3 @@ describe('InvitationsTableTabView', () => { expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); }); - -it('revokes the confirmed invitation', async () => { - const user = userEvent.setup(); - const pending = deferred(); - const onMutation = vi - .fn<(id: string) => Promise>() - .mockImplementationOnce(() => pending.promise) - .mockResolvedValue(undefined); - function Example() { - const [items, setItems] = useState([ - { ...propsFor().invitations[0], id: 'ada', email: 'ada@example.com' }, - { ...propsFor().invitations[0], id: 'grace', email: 'Grace' }, - ]); - return ( - - { - await onMutation(id); - setItems(current => current.filter(item => item.id !== id)); - }} - /> - - ); - } - render(); - await user.click(screen.getByRole('button', { name: 'Manage ada@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - expect(onMutation).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - expect(onMutation).toHaveBeenCalledExactlyOnceWith('ada'); - expect(screen.getByRole('alertdialog')).toBeInTheDocument(); - await act(async () => { - pending.resolve(); - await pending.promise; - }); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Manage Grace' })); - await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(onMutation).toHaveBeenLastCalledWith('grace'); -}); From c5d8353b1694c898afd03d72c89ae5ec6190d457 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 8 Oct 2026 22:49:15 -0600 Subject: [PATCH 4/6] test(mosaic): reject unknown params in fake organization list endpoints --- .../feature/fake-fapi/organization-members.ts | 10 ++++++++- .../src/__tests__/feature/fake-fapi/shared.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts index 5699bc52872..9f41deec130 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts @@ -1,12 +1,16 @@ import { http, HttpResponse } from 'msw'; import type { FakeFapiState } from '../fake-fapi'; -import { envelope, missing } from './shared'; +import { envelope, missing, rejectUnknownParams } from './shared'; export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: string) => string) { return [ http.get(fapiUrl('/v1/organizations/:organizationId/memberships'), ({ params, request }) => { const url = new URL(request.url); + const rejected = rejectUnknownParams(url, ['query', 'role']); + if (rejected) { + return rejected; + } const query = url.searchParams.get('query')?.toLowerCase(); if (query === '') { return HttpResponse.json( @@ -41,6 +45,10 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: }), http.get(fapiUrl('/v1/organizations/:organizationId/invitations'), ({ params, request }) => { const url = new URL(request.url); + const rejected = rejectUnknownParams(url, ['status']); + if (rejected) { + return rejected; + } const statuses = url.searchParams.getAll('status'); const matching = state.organizationInvitations.filter( invitation => diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts index ad0f1a7a659..bac91959e14 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts @@ -32,3 +32,25 @@ export function updateUser(state: ClientState, user: UserJSON): void { export function missing() { return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); } + +const paginationParams = ['limit', 'offset', 'paginated']; + +export function rejectUnknownParams(url: URL, allowed: readonly string[]) { + const unknown = [...url.searchParams.keys()].find( + key => !key.startsWith('_') && !paginationParams.includes(key) && !allowed.includes(key), + ); + return unknown + ? HttpResponse.json( + { + errors: [ + { + code: 'form_param_unknown', + message: `${unknown} is not a valid parameter for this request.`, + meta: { param_name: unknown }, + }, + ], + }, + { status: 422 }, + ) + : undefined; +} From bbffff86fc571c83174a758ff539583afcde7f71 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 12:17:08 -0600 Subject: [PATCH 5/6] refactor(mosaic): align invitations table with the members table Replace cached rows with the table error state when a refresh fails, localize invitation role labels from the roles catalog, localize the not-pending revoke errors, and drop the search, sort and page-size controls the invitations API cannot serve. --- .../invitations-table-tab.controller.ts | 12 +- .../invitations-table-tab.messages.ts | 4 - .../invitations-table-tab.model.ts | 10 +- .../invitations-table-tab.types.ts | 12 +- .../invitations-table-tab.view.tsx | 285 ++++++++++-------- .../src/localization/errors.messages.ts | 2 + .../stories/fixtures/invitations-table-tab.ts | 37 +-- .../organization-profile-members-panel.mdx | 6 +- 8 files changed, 177 insertions(+), 191 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts index db791e763a7..ae92b62e214 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.controller.ts @@ -1,23 +1,25 @@ -import { useLocale } from '../../localization'; -import { formatDate } from '../api-keys/api-keys-table.format'; +import { useLocale, useMessages } from '../../localization'; +import { formatDate } from '../../utils/format-date'; import type { useInvitationsTableModel } from './invitations-table-tab.model'; export function useInvitationsTableController(model: ReturnType) { const locale = useLocale(); + const roleNames = useMessages('roles'); return { - invitations: model.rows.map(({ id, emailAddress, createdAt, roleName }) => ({ + invitations: model.rows.map(({ id, emailAddress, createdAt, role, roleName }) => ({ id, email: emailAddress, invitedAtLabel: formatDate(createdAt, locale), - roleLabel: roleName, + roleLabel: roleNames[role] ?? roleName, })), + pageSize: model.pageSize, totalCount: model.totalCount, page: model.page, + onPageChange: model.fetchPage, isLoading: model.isLoading, isFetching: model.isFetching, isError: model.isError, onRetry: model.retry, - onPageChange: model.fetchPage, onRevoke: model.revoke, }; } diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts index 5a59c03b263..52ee7282085 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.messages.ts @@ -4,11 +4,7 @@ export const invitationsTableTabMessages = { invitedAt: 'Invited', roleLabel: 'Role', actions: 'Actions', - search: 'Search invitations', - clearSearch: 'Clear search', invite: 'Invite', - empty: 'No invitations found', - emptyDescription: 'Your search for "{query}" did not return any results.', noInvitations: 'No pending invitations', noInvitationsDescription: 'Invite people to add them to this organization.', loading: 'Loading invitations', diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts index 3f09568f4d6..c4de9ec38cf 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.model.ts @@ -17,6 +17,7 @@ export function useInvitationsTableModel() { return { rows, + pageSize: PAGE_SIZE, totalCount: invitations?.count ?? 0, page: invitations?.page ?? 1, isLoading: invitations?.isLoading ?? true, @@ -32,12 +33,11 @@ export function useInvitationsTableModel() { if (!invitation) { throw new ClerkRuntimeError('This invitation cannot be revoked.', { code: 'invitation_unavailable' }); } - const page = invitations?.page ?? 1; - const lastOnPage = invitations?.data?.length === 1 && page > 1; + const lastOnPage = (invitations?.data?.length ?? 0) === 1 && (invitations?.page ?? 1) > 1; await invitation.revoke(); await invitations?.revalidate?.(); if (lastOnPage) { - invitations?.fetchPage?.(page - 1); + invitations?.fetchPage?.((invitations?.page ?? 1) - 1); } } : undefined, @@ -45,6 +45,6 @@ export function useInvitationsTableModel() { } function toInvitation(invitation: OrganizationInvitationResource) { - const { id, emailAddress, createdAt, roleName } = invitation; - return { id, emailAddress, createdAt, roleName }; + const { id, emailAddress, createdAt, role, roleName } = invitation; + return { id, emailAddress, createdAt, role, roleName }; } diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts index f009f02ed2b..3a294795f9b 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.types.ts @@ -8,27 +8,17 @@ export interface OrganizationProfileInvitation { roleLabel: string; } -export interface InvitationsTableSort { - column: 'email' | 'invitedAt' | 'roleLabel'; - direction: 'ascending' | 'descending'; -} - export interface InvitationsTableTabViewProps { invitations: OrganizationProfileInvitation[]; totalCount: number; page: number; - pageSize?: number; - searchValue?: string; + pageSize: number; isLoading: boolean; isFetching?: boolean; isError?: boolean; onRetry?: () => void; onPageChange: (page: number) => void; - onPageSizeChange?: (pageSize: number) => void; - onSearchChange?: (value: string) => void; onInvite?: MouseEventHandler; onRevoke?: (id: string) => void | Promise; onBulkAction?: (ids: string[]) => void; - sort?: InvitationsTableSort | null; - onSortChange?: (sort: InvitationsTableSort | null) => void; } diff --git a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx index a40fcd5d3a9..47e91e1c39d 100644 --- a/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/invitations-table-tab.view.tsx @@ -1,5 +1,5 @@ import * as stylex from '@stylexjs/stylex'; -import { useMemo, useRef } from 'react'; +import { type Ref, useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { ActionMenu } from '../../components/action-menu'; @@ -21,52 +21,41 @@ import { tableTabStyles } from './table-tab.styles'; const getRowId = (invitation: OrganizationProfileInvitation) => invitation.id; -// eslint-disable-next-line sonarjs/cognitive-complexity -- The invitation table renders independent states and optional controls in one view. export function InvitationsTableTabView({ invitations, onInvite, onRevoke, totalCount, page, - pageSize = 10, - searchValue = '', - onSearchChange, + pageSize, onPageChange, - onPageSizeChange, onBulkAction, - sort, - onSortChange, isLoading, isFetching = false, isError = false, onRetry, }: InvitationsTableTabViewProps) { const m = useMessages('invitationsTableTab'); - const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); - const query = searchValue.trim(); - const searchInput = useRef(null); const inviteButton = useRef(null); const tableContainer = useRef(null); const removalFocus = useListRemovalFocus({ ids: invitations.map(getRowId), onRemove: onRevoke, - fallback: () => inviteButton.current ?? searchInput.current ?? tableContainer.current, + fallback: () => inviteButton.current ?? tableContainer.current, }); const revokeDialog = useMemo(() => Confirmation.createHandle(), []); - const { table, sortHeader, pagination } = useServerDataTable({ + const { table, pagination } = useServerDataTable({ data: invitations, totalCount, getRowId, - sortableColumns: ['email', 'invitedAt', 'roleLabel'], - sort, - onSortChange, + sortableColumns: [], page, pageSize, onPageChange, - onPageSizeChange, - searchValue, - onSearchChange: onSearchChange ?? (() => {}), + searchValue: '', + onSearchChange: () => {}, }); + const columnCount = 3 + Number(Boolean(onRevoke)) + Number(Boolean(onBulkAction)); return ( <>
- {onSearchChange || onInvite ? ( + {onInvite ? ( - {onSearchChange ? ( - - ) : null} - {onInvite ? ( - - ) : null} + ) : null} - {isError && table.rows.length > 0 ? ( -

- {m.loadError} {onRetry ? : null} -

- ) : null} ) : null} - {m.email} - {m.invitedAt} - {m.roleLabel} + {m.email} + {m.invitedAt} + {m.roleLabel} {onRevoke ? ( {m.actions} @@ -127,91 +100,16 @@ export function InvitationsTableTabView({ ) : null} - - {isLoading ? ( - - - - {m.loading} - - - ) : isError && table.rows.length === 0 ? ( - - - - {m.loadError} - {onRetry ? ( - - - - ) : null} - - - ) : table.rows.length === 0 ? ( - - - - {query ? m.empty : m.noInvitations} - - {query ? fill(m.emptyDescription, { query }) : m.noInvitationsDescription} - - - - ) : ( - table.rows.map(row => ( - - {onBulkAction ? ( - - ) : null} - - - - - {row.original.imageUrl ? ( - - ) : null} - - - - - {row.original.email} - - - - {row.original.invitedAtLabel} - {row.original.roleLabel} - {onRevoke ? ( - - revokeDialog.open(row.original), - }, - ]} - /> - - ) : null} - - )) - )} - + revokeDialog.open(invitation) : undefined} + registerTrigger={removalFocus.registerTrigger} + /> {pagination ? ( ); } + +function InvitationsTableBody({ + rows, + columnCount, + isLoading, + isError, + onRetry, + hasBulkAction, + onRevokeClick, + registerTrigger, +}: { + rows: Array<{ + id: string; + original: OrganizationProfileInvitation; + getIsSelected: () => boolean; + toggleSelected: () => void; + }>; + columnCount: number; + isLoading: boolean; + isError: boolean; + onRetry?: () => void; + hasBulkAction: boolean; + onRevokeClick?: (invitation: OrganizationProfileInvitation) => void; + registerTrigger: (id: string) => Ref; +}) { + const m = useMessages('invitationsTableTab'); + if (isLoading) { + return ( + + + + + {m.loading} + + + + ); + } + if (isError) { + return ( + + + + + {m.loadError} + {onRetry ? ( + + + + ) : null} + + + + ); + } + if (rows.length === 0) { + return ( + + + + + {m.noInvitations} + {m.noInvitationsDescription} + + + + ); + } + return ( + + {rows.map(row => ( + + {hasBulkAction ? ( + + ) : null} + + + + + {row.original.imageUrl ? ( + + ) : null} + + + + + {row.original.email} + + + + {row.original.invitedAtLabel} + {row.original.roleLabel} + {onRevokeClick ? ( + + onRevokeClick(row.original), + }, + ]} + /> + + ) : null} + + ))} + + ); +} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index fab72af5a14..58f3be32aa6 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -32,6 +32,7 @@ export const errorMessages: { readonly generic: string } & Readonly(null); - const query = searchValue.trim().toLowerCase(); - const filtered = items.filter(item => item.email.toLowerCase().includes(query)); - const sorted = sort - ? [...filtered].sort((a, b) => { - const comparison = - sort.column === 'invitedAt' ? a.invitedAt - b.invitedAt : a[sort.column].localeCompare(b[sort.column]); - return sort.direction === 'ascending' ? comparison : -comparison; - }) - : filtered; - const currentPage = Math.min(page, Math.max(1, Math.ceil(sorted.length / pageSize))); + const pageSize = 10; + const currentPage = Math.min(page, Math.max(1, Math.ceil(items.length / pageSize))); return { - invitations: sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize).map(item => ({ + invitations: items.slice((currentPage - 1) * pageSize, currentPage * pageSize).map(item => ({ ...item, invitedAtLabel: new Intl.DateTimeFormat(locale, { month: 'short', @@ -62,25 +48,12 @@ export function useInvitationsTableFixture({ proposed = false, empty = false } = timeZone: 'UTC', }).format(item.invitedAt), })), - totalCount: sorted.length, + totalCount: items.length, page: currentPage, pageSize, - searchValue, isLoading: false, - onSearchChange: value => { - setSearchValue(value); - setPage(1); - }, onPageChange: setPage, - onPageSizeChange: setPageSize, onRevoke: id => setItems(current => current.filter(item => item.id !== id)), onBulkAction: proposed ? () => undefined : undefined, - sort, - onSortChange: proposed - ? next => { - setSort(next); - setPage(1); - } - : undefined, }; } diff --git a/packages/swingset/src/stories/organization-profile-members-panel.mdx b/packages/swingset/src/stories/organization-profile-members-panel.mdx index 89c0bad6b64..0e5a47d3e01 100644 --- a/packages/swingset/src/stories/organization-profile-members-panel.mdx +++ b/packages/swingset/src/stories/organization-profile-members-panel.mdx @@ -2,11 +2,11 @@ import * as Stories from './organization-profile-members-panel.stories'; # OrganizationProfileMembersPanel -The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members, Invitations, and Requests tabs with controlled tables. Each table uses the shared Table toolbar and search field. +The organization profile’s Members panel owns the page title and tab navigation. These examples show its Members, Invitations, and Requests tabs with controlled tables. The tables page on the server ten rows at a time, without sorting or a page-size control, following the API keys table. The Members table has a search field; the Invitations table does not, because the API cannot search invitations. Removing a member opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. -Revoking an invitation opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful removal, focus moves to a surviving row action or a toolbar control. +Revoking an invitation opens a confirmation dialog. The dialog awaits the action callback and keeps failures visible. After a successful revocation, focus moves to a surviving row action, the Invite button, or the Invitations table. Accepting or declining a request runs immediately. Both row actions are held while the callback is pending, and failures appear beside the actions so the user can retry. After a successful removal, focus moves to a surviving row action or the search field. @@ -37,7 +37,7 @@ The Members table shows the user, joined date, role, and optional row actions. T ### Proposed -Sorting and selection are enabled through optional callbacks, following the API keys table. The caller sorts before pagination. The current user and deprovisioned members cannot be selected, removed, or have their role changed. Selection clears when search, sorting, or pagination changes. The bulk callback enables selection; no bulk action toolbar is rendered. +Selection is enabled through an optional callback. The current user and deprovisioned members cannot be selected, removed, or have their role changed. Selection clears when search or pagination changes, and after a removal. The bulk callback enables selection; no bulk action toolbar is rendered. From ede77f6f8660dbe4b8cfda9a6ba2293f95ac9dab Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 12:17:09 -0600 Subject: [PATCH 6/6] test(mosaic): mirror FAPI invitation validation and cover aligned behavior Reject invalid invitation statuses and revoking a non-pending invitation in the fake FAPI, as clerk_go does. Cover localized role rows, the not-pending revoke error, duplicate confirms, and the table error state, and mark the deferred invite flow. --- .../feature/fake-fapi/organization-members.ts | 32 +++++++- .../invitations-table-tab.view.test.tsx | 58 +++++-------- ...ation-profile-invitations.feature.test.tsx | 81 +++++++++++++++++-- 3 files changed, 124 insertions(+), 47 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts index 9f41deec130..76ff4bbd128 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts @@ -3,6 +3,8 @@ import { http, HttpResponse } from 'msw'; import type { FakeFapiState } from '../fake-fapi'; import { envelope, missing, rejectUnknownParams } from './shared'; +const INVITATION_STATUSES = ['pending', 'accepted', 'revoked', 'expired']; + export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: string) => string) { return [ http.get(fapiUrl('/v1/organizations/:organizationId/memberships'), ({ params, request }) => { @@ -50,6 +52,21 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: return rejected; } const statuses = url.searchParams.getAll('status'); + const invalidStatus = statuses.find(status => !INVITATION_STATUSES.includes(status)); + if (invalidStatus) { + return HttpResponse.json( + { + errors: [ + { + code: 'form_param_value_invalid', + message: `${invalidStatus} is not a valid value for status.`, + meta: { param_name: 'status' }, + }, + ], + }, + { status: 422 }, + ); + } const matching = state.organizationInvitations.filter( invitation => invitation.organization_id === params.organizationId && @@ -63,8 +80,19 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: const invitation = state.organizationInvitations.find( item => item.organization_id === params.organizationId && item.id === params.invitationId, ); - if (!invitation) { - return missing(); + if (!invitation || invitation.status !== 'pending') { + return HttpResponse.json( + { + errors: [ + { + code: 'organization_invitation_not_pending', + message: 'not pending', + long_message: "The organization invitation is not in the 'pending' status.", + }, + ], + }, + { status: 404 }, + ); } const revoked = { ...invitation, status: 'revoked' as const }; state.organizationInvitations = state.organizationInvitations.map(item => diff --git a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx index 237a32556b7..24af77caa8e 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/invitations-table-tab.view.test.tsx @@ -11,9 +11,8 @@ function propsFor(overrides: Partial = {}): Invita invitations: [{ id: 'invite-1', email: 'ada@example.com', invitedAtLabel: 'Sep 1, 2026', roleLabel: 'Admin' }], totalCount: 1, page: 1, - searchValue: '', + pageSize: 10, isLoading: false, - onSearchChange: vi.fn(), onPageChange: vi.fn(), ...overrides, }; @@ -56,7 +55,7 @@ describe('InvitationsTableTabView', () => { }, ); - it('distinguishes loading, an empty invitation list, and an empty search', () => { + it('chooses loading, retained rows, an empty list, and the error state from the supplied state', () => { const { props, rerender } = renderView({ invitations: [], totalCount: 0, isLoading: true }); expect(screen.getByRole('status')).toHaveTextContent('Loading invitations'); rerender( @@ -67,60 +66,43 @@ describe('InvitationsTableTabView', () => { /> , ); - expect(screen.getByText('No pending invitations')).toBeVisible(); - rerender( - - - , - ); - expect(screen.getByRole('status')).toHaveTextContent('No invitations found'); + expect(screen.getByRole('status')).toHaveTextContent('No pending invitations'); rerender( , ); expect(screen.getByText('ada@example.com')).toBeVisible(); expect(screen.getByRole('table')).toHaveAttribute('aria-busy', 'true'); - }); - it('connects invitation search, sorting, and paging while clearing the old selection', async () => { - const user = userEvent.setup(); - const { props, rerender } = renderView({ - totalCount: 21, - onBulkAction: vi.fn(), - onSortChange: vi.fn(), - onPageSizeChange: vi.fn(), - }); - await user.click(screen.getByRole('checkbox', { name: 'Select ada@example.com' })); - await user.click(screen.getByRole('button', { name: 'Invited' })); - expect(props.onSortChange).toHaveBeenCalledWith({ column: 'invitedAt', direction: 'ascending' }); - expect(screen.getByRole('checkbox', { name: 'Select ada@example.com' })).not.toBeChecked(); rerender( , ); - await user.click(screen.getByRole('button', { name: 'Invited' })); - expect(props.onSortChange).toHaveBeenLastCalledWith({ column: 'invitedAt', direction: 'descending' }); - await user.type(screen.getByRole('searchbox', { name: 'Search invitations' }), 'A'); - expect(props.onSearchChange).toHaveBeenCalledWith('A'); + expect(screen.queryByText('ada@example.com')).toBeNull(); + expect(within(screen.getByRole('table')).getByRole('alert')).toHaveTextContent('Unable to load invitations'); + expect(screen.getAllByRole('button', { name: 'Try again' })).toHaveLength(1); + }); + + it('forwards paging without search, sorting, or page-size controls', async () => { + const user = userEvent.setup(); + const { props } = renderView({ totalCount: 21, onBulkAction: vi.fn() }); + await user.click(screen.getByRole('checkbox', { name: 'Select ada@example.com' })); + expect(screen.getByRole('checkbox', { name: 'Select ada@example.com' })).toBeChecked(); await user.click(screen.getByRole('button', { name: 'Next invitations page' })); expect(props.onPageChange).toHaveBeenCalledWith(2); - await user.click(screen.getByRole('combobox', { name: /^Results per page/ })); - await user.click(screen.getByRole('option', { name: '20', exact: true })); - expect(props.onPageSizeChange).toHaveBeenCalledWith(20); - expect(props.onPageChange).toHaveBeenLastCalledWith(1); + expect(screen.queryByRole('searchbox')).toBeNull(); + expect(screen.queryByRole('combobox', { name: /^Results per page/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Invited' })).toBeNull(); }); + it('routes invite and withholds unavailable actions', async () => { const user = userEvent.setup(); const { props, rerender } = renderView({ onInvite: vi.fn(), onRevoke: vi.fn() }); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx index 3b1192550a1..da9f4e31602 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invitations.feature.test.tsx @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiMembership, fapiOrganization, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; import { OrganizationProfileMembersPanel } from '../organization-profile-members-panel'; const organization = fapiOrganization({ id: 'org_invitations', name: 'Acme' }); @@ -38,6 +39,7 @@ describe('connected organization invitations', () => { const requests = holdRequests('get', '/v1/organizations/:organizationId/invitations'); await renderWithClerk(); expect(await screen.findByRole('tab', { name: 'Members' })).toBeVisible(); + await waitFor(() => expect(screen.queryByText('Loading members')).toBeNull()); expect(screen.queryByRole('tab', { name: 'Invitations' })).toBeNull(); expect(requests.requests).toHaveLength(0); requests.release(); @@ -120,15 +122,48 @@ describe('connected organization invitations', () => { const revoke = holdRequests('post', '/v1/organizations/:organizationId/invitations/:invitationId/revoke'); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); await waitFor(() => expect(revoke.requests).toHaveLength(1)); - revoke.fail('invitation_rejected', 'Invitation rejected'); - expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toBeVisible(); + revoke.fail('network_error', 'Unavailable'); + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( + 'Unable to reach the server. Check your connection and try again.', + ); expect(fapi.organizationInvitations[0]?.status).toBe('pending'); const retry = serve(['org:sys_memberships:manage']); await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); await waitFor(() => expect(retry.organizationInvitations[0]?.status).toBe('revoked')); }); - it('keeps remaining rows and offers retry when refresh fails after revoke', async () => { + it('keeps the dialog open with the server message when the invitation is no longer pending', async () => { + const fapi = serve(['org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + fapi.organizationInvitations = [{ ...invitation, status: 'accepted' }]; + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); + expect(await within(screen.getByRole('alertdialog')).findByRole('alert')).toHaveTextContent( + 'This invitation is no longer pending.', + ); + expect(fapi.organizationInvitations[0]?.status).toBe('accepted'); + }); + + it('sends one revoke request when confirm is pressed repeatedly', async () => { + serve(['org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Manage ada@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Revoke invitation' })); + const revoke = holdRequests('post', '/v1/organizations/:organizationId/invitations/:invitationId/revoke'); + const confirm = within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' }); + await user.click(confirm); + await waitFor(() => expect(revoke.requests).toHaveLength(1)); + confirm.click(); + confirm.click(); + revoke.release(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); + expect(revoke.requests).toHaveLength(1); + }); + + it('replaces rows with the table error state and retries when refreshing after a revoke fails', async () => { const fapi = serve(['org:sys_memberships:manage']); const other = { ...invitation, id: 'orginv_other', email_address: 'other@example.com' }; fapi.organizationInvitations.push(other); @@ -140,19 +175,49 @@ describe('connected organization invitations', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Revoke invitation' })); await waitFor(() => expect(refresh.requests).toHaveLength(1)); refresh.fail('network_error', 'Unavailable'); - expect(await screen.findByText('Unable to load invitations', {}, { timeout: 12_000 })).toBeVisible(); + await waitFor(() => expect(screen.getByText('Unable to load invitations')).toBeVisible(), { timeout: 12_000 }); await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull()); expect(fapi.organizationInvitations.find(item => item.id === invitation.id)?.status).toBe('revoked'); - expect(screen.getByText('other@example.com')).toBeVisible(); - expect(screen.queryByText('ada@example.com')).toBeNull(); + expect(screen.queryByText('other@example.com')).toBeNull(); + const table = within(screen.getByRole('table', { name: 'Invitations' })); + expect(table.getByRole('alert')).toHaveTextContent('Unable to load invitations'); const retry = serve(['org:sys_memberships:manage']); retry.organizationInvitations = [other]; - await user.click(screen.getByRole('button', { name: 'Try again' })); + await user.click(table.getByRole('button', { name: 'Try again' })); await waitFor(() => expect(screen.queryByText('Unable to load invitations')).toBeNull()); expect(screen.getByText('other@example.com')).toBeVisible(); expect(screen.queryByText('ada@example.com')).toBeNull(); }, 20_000); + it('shows localized role names in invitation rows', async () => { + const fapi = serve(['org:sys_memberships:manage']); + fapi.organizationInvitations.push({ + ...invitation, + id: 'orginv_admin', + email_address: 'admin@example.com', + role: 'org:admin', + role_name: 'Admin', + }); + await renderWithClerk( + + + , + ); + const table = within(await screen.findByRole('table', { name: 'Invitations' })); + expect(await table.findByRole('cell', { name: 'Administrateur' })).toBeVisible(); + expect(table.getByRole('cell', { name: 'Membre' })).toBeVisible(); + expect(table.queryByRole('cell', { name: 'Admin' })).toBeNull(); + }); + + it('shows the invitation role name for a custom role', async () => { + const fapi = serve(['org:sys_memberships:manage']); + fapi.organizationInvitations = [{ ...invitation, role: 'org:billing', role_name: 'Billing' }]; + await renderWithClerk(); + expect(await screen.findByRole('cell', { name: 'Billing' })).toBeVisible(); + }); + it('returns to the previous page after revoking the last invitation on a later page', async () => { const fapi = serve(['org:sys_memberships:manage']); fapi.organizationInvitations = [ @@ -263,4 +328,6 @@ describe('connected organization invitations', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); expect(screen.queryByText('ada@example.com')).toBeNull(); }); + + it.todo('invites members from the Invitations tab'); });