From 9dc64b084ffaf8254b4ea0a8fa153748d63b94d8 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 14:25:44 -0600 Subject: [PATCH 1/5] feat(mosaic): connect organization invite members dialog Wire the members panel Invite action to organization.inviteMembers. Roles load once per panel and feed both role changes and the invite picker. The fake FAPI now mirrors invitation create and bulk create. --- .changeset/mosaic-invite-members-dialog.md | 2 + .../feature/fake-fapi/organization-members.ts | 149 ++++++- .../members-table-tab.controller.test.tsx | 76 +--- ...ation-profile-invitations.feature.test.tsx | 406 +++++++++++++++++- ...-profile-members-panel.controller.test.tsx | 59 +++ ...nization-profile-requests.feature.test.tsx | 10 +- .../invitations-table-tab.model.ts | 54 ++- .../members-table-tab.controller.ts | 33 +- .../members-table-tab.model.ts | 12 - ...ation-profile-invite-members.controller.ts | 84 ++++ ...nization-profile-invite-members.dialog.tsx | 4 +- ...zation-profile-members-panel.controller.ts | 35 ++ ...rganization-profile-members-panel.model.ts | 22 + .../organization-profile-members-panel.tsx | 39 +- .../src/localization/errors.messages.ts | 13 + .../organization-profile-members-panel.mdx | 2 +- 16 files changed, 855 insertions(+), 145 deletions(-) create mode 100644 .changeset/mosaic-invite-members-dialog.md create mode 100644 packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile-members-panel.controller.ts diff --git a/.changeset/mosaic-invite-members-dialog.md b/.changeset/mosaic-invite-members-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-invite-members-dialog.md @@ -0,0 +1,2 @@ +--- +--- 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 77dffe47759..b2495f2046e 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts @@ -1,11 +1,150 @@ +import type { OrganizationInvitationJSON } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; import type { FakeFapiState } from '../fake-fapi'; import { fapiMembership } from '../fapi'; -import { envelope, missing, rejectUnknownParams } from './shared'; +import { envelope, missing, rejectUnknownParams, requestUser } from './shared'; const INVITATION_STATUSES = ['pending', 'accepted', 'revoked', 'expired']; const MEMBERSHIP_REQUEST_STATUSES = ['pending', 'accepted', 'rejected']; +const MAX_BULK_SIZE = 50; +const INVITATION_PARAMS = ['email_address', 'role', 'notify']; + +function clerkErrors(status: number, ...errors: Array>) { + return HttpResponse.json({ errors }, { status }); +} + +async function createInvitations( + state: FakeFapiState, + organizationId: string, + request: Request, +): Promise { + const body = new URLSearchParams(await request.text()); + const unknown = [...body.keys()].find(key => !key.startsWith('_') && !INVITATION_PARAMS.includes(key)); + if (unknown) { + return clerkErrors(422, { + code: 'form_param_unknown', + message: `${unknown} is not a valid parameter for this request.`, + meta: { param_name: unknown }, + }); + } + const emailAddresses = body.getAll('email_address'); + const role = body.get('role'); + const notify = body.get('notify'); + for (const [name, value] of [ + ['email_address', emailAddresses[0]], + ['role', role], + ] as const) { + if (!value) { + return clerkErrors(422, { + code: 'form_param_missing', + message: `${name} must be included.`, + meta: { param_name: name }, + }); + } + } + if (notify !== null && notify !== 'true' && notify !== 'false') { + return clerkErrors(422, { + code: 'form_param_format_invalid', + message: 'notify must be a boolean.', + meta: { param_name: 'notify' }, + }); + } + const organization = state.memberships.find(item => item.organization.id === organizationId)?.organization; + if (!organization) { + return missing(); + } + const invalid = emailAddresses.filter(email => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)); + if (invalid.length > 0) { + return clerkErrors(422, { + code: 'form_param_format_invalid', + message: 'invalid email addresses', + long_message: `The following email addresses are invalid: ${invalid.join(', ')}`, + meta: { param_name: 'email_address', email_addresses: invalid }, + }); + } + if (emailAddresses.length > MAX_BULK_SIZE) { + return clerkErrors(400, { + code: 'bulk_size_exceeded', + message: 'bulk size exceeded', + long_message: `Parameters exceed the maximum allowed bulk processing size of ${MAX_BULK_SIZE}.`, + }); + } + const selectedRole = state.roles.find(item => item.key === role); + if (!selectedRole) { + return clerkErrors(404, { + code: 'resource_not_found', + message: 'not found', + long_message: 'Organization role not found', + meta: { param_name: 'role' }, + }); + } + const inviter = requestUser(state, request)?.organization_memberships?.find( + item => item.organization.id === organizationId, + ); + if (!inviter) { + return clerkErrors(403, { + code: 'not_a_member_in_organization', + message: 'not a member', + long_message: + 'Current user is not a member of the organization. Only organization members can perform this action.', + }); + } + if (!inviter.permissions.includes('org:sys_memberships:manage')) { + return clerkErrors(403, { + code: 'missing_organization_permission', + message: 'missing permission', + long_message: 'Current user is missing an organization permission.', + meta: { permissions: ['org:sys_memberships:manage'] }, + }); + } + const pending = state.organizationInvitations.filter( + item => item.organization_id === organizationId && item.status === 'pending', + ); + const normalized = emailAddresses.map(email => email.toLowerCase()); + const replaced = pending.filter(item => normalized.includes(item.email_address.toLowerCase())); + const members = state.memberships.filter(item => item.organization.id === organizationId); + const member = normalized.find(email => + members.some(item => item.public_user_data?.identifier?.toLowerCase() === email), + ); + if (member) { + return clerkErrors(400, { + code: 'already_a_member_in_organization', + message: 'already a member', + long_message: `${member} is already a member of the organization.`, + }); + } + const maxAllowed = organization.max_allowed_memberships; + if (maxAllowed > 0 && members.length + pending.length + (normalized.length - replaced.length) > maxAllowed) { + return clerkErrors(403, { + code: 'organization_membership_quota_exceeded', + message: 'organization membership quota exceeded', + long_message: `You have reached your limit of ${maxAllowed} organization memberships, including outstanding invitations.`, + }); + } + const now = Date.now(); + const created = normalized.map( + (email, index): OrganizationInvitationJSON => ({ + object: 'organization_invitation', + id: `orginv_${now}_${state.organizationInvitations.length + index}`, + email_address: email, + organization_id: organizationId, + public_metadata: {}, + status: 'pending', + role: selectedRole.key, + role_name: selectedRole.name, + created_at: now, + updated_at: now, + }), + ); + state.organizationInvitations = [ + ...created, + ...state.organizationInvitations.map(item => + replaced.includes(item) ? { ...item, status: 'revoked' as const } : item, + ), + ]; + return created; +} export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: string) => string) { return [ @@ -143,6 +282,14 @@ export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: ); return envelope(rejected, null); }), + http.post(fapiUrl('/v1/organizations/:organizationId/invitations'), async ({ params, request }) => { + const created = await createInvitations(state, String(params.organizationId), request); + return created instanceof Response ? created : envelope(created[0] ?? null, null); + }), + http.post(fapiUrl('/v1/organizations/:organizationId/invitations/bulk'), async ({ params, request }) => { + const created = await createInvitations(state, String(params.organizationId), request); + return created instanceof Response ? created : envelope(created, 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, diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.controller.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.controller.test.tsx index d7ec1dd297d..6e6e734009a 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.controller.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.controller.test.tsx @@ -1,16 +1,15 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { deferred } from '../../../__tests__/async'; import { useMembersTableController, useMembersTableSearchController } from '../members-table-tab.controller'; import type { MembersRoles } from '../members-table-tab.types'; -function createModel(loadRoles: () => Promise) { +function createModel() { return { rows: [], - loadRoles, totalCount: 0, page: 1, + pageSize: 10, isLoading: false, isFetching: false, isError: false, @@ -21,77 +20,30 @@ function createModel(loadRoles: () => Promise) { }; } -describe('members table role loading', () => { - it('loads once, holds role editing until ready, and uses server labels', async () => { - const request = deferred(); - const model = createModel(vi.fn(() => request.promise)); - const { result, rerender } = renderHook(() => useMembersTableController(model)); - - expect(model.loadRoles).toHaveBeenCalledTimes(1); +describe('members table roles', () => { + it('holds role editing until roles arrive and uses server labels', async () => { + const model = createModel(); + const { result, rerender } = renderHook(({ roles }) => useMembersTableController(model, roles), { + initialProps: { roles: null as MembersRoles | null }, + }); expect(result.current.onChangeRole).toBeUndefined(); - rerender(); - expect(model.loadRoles).toHaveBeenCalledTimes(1); const roles = { roles: [{ key: 'org:reader', name: 'Reader' }], hasRoleSetMigration: false }; - await act(() => Promise.resolve(request.resolve(roles))); + rerender({ roles }); expect(result.current.roles).toEqual([{ value: 'org:reader', label: 'Reader' }]); - expect(result.current.onChangeRole).toBeDefined(); await act(() => Promise.resolve(result.current.onChangeRole?.('member_1', 'org:reader'))); expect(model.changeRole).toHaveBeenCalledWith('member_1', 'org:reader', roles); }); - it('keeps role editing unavailable after a failed role load', async () => { - const first = deferred(); - const model = createModel(vi.fn(() => first.promise)); - const { result, rerender } = renderHook(() => useMembersTableController(model)); - - expect(model.loadRoles).toHaveBeenCalledTimes(1); - await act(() => Promise.resolve(first.reject(new Error('failed')))); - expect(result.current.roles).toEqual([]); - expect(result.current.onChangeRole).toBeUndefined(); - rerender(); - expect(model.loadRoles).toHaveBeenCalledTimes(1); - }); - - it('keeps migration roles visible but prevents role changes', async () => { - const model = createModel(() => - Promise.resolve({ - roles: [{ key: 'org:admin', name: 'Admin' }], - hasRoleSetMigration: true, - }), - ); - const { result } = renderHook(() => useMembersTableController(model)); - - await waitFor(() => expect(result.current.roles).toHaveLength(1)); + it('keeps migration roles visible but prevents role changes', () => { + const roles = { roles: [{ key: 'org:admin', name: 'Admin' }], hasRoleSetMigration: true }; + const { result } = renderHook(() => useMembersTableController(createModel(), roles)); + expect(result.current.roles).toHaveLength(1); expect(result.current.hasRoleSetMigration).toBe(true); expect(result.current.onChangeRole).toBeUndefined(); }); - it.each(['resolve', 'reject'] as const)('drops an unresolved %s after unmount', async outcome => { - const oldRequest = deferred(); - const oldModel = createModel(() => oldRequest.promise); - const { unmount } = renderHook(() => useMembersTableController(oldModel)); - unmount(); - - const newModel = createModel(() => - Promise.resolve({ roles: [{ key: 'org:new', name: 'New' }], hasRoleSetMigration: false }), - ); - const { result } = renderHook(() => useMembersTableController(newModel)); - await waitFor(() => expect(result.current.roles).toEqual([{ value: 'org:new', label: 'New' }])); - - await act(() => { - if (outcome === 'resolve') { - oldRequest.resolve({ roles: [{ key: 'org:old', name: 'Old' }], hasRoleSetMigration: false }); - } else { - oldRequest.reject(new Error('old request failed')); - } - return Promise.resolve(); - }); - expect(result.current.roles).toEqual([{ value: 'org:new', label: 'New' }]); - expect(result.current.onChangeRole).toBeDefined(); - }); - it('applies a cleared search without waiting for the debounce', async () => { vi.useFakeTimers(); try { 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 dcde748955b..1c05061c38f 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 @@ -1,10 +1,18 @@ -import type { OrganizationInvitationJSON } from '@clerk/shared/types'; +import type { OrganizationInvitationJSON, RoleJSON } 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 { userEvent as browserEvent } from 'vitest/browser'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiMembership, fapiOrganization, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEnvironment, + 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'; @@ -63,24 +71,21 @@ describe('connected organization invitations', () => { expect(await screen.findByText('ada@example.com')).toBeVisible(); }); - it('loads pending invitations for managers without fetching members or roles', async () => { + it('loads pending invitations for managers without fetching members', 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 () => { @@ -332,6 +337,393 @@ describe('connected organization invitations', () => { expect(screen.queryByRole('alertdialog')).toBeNull(); expect(screen.queryByText('ada@example.com')).toBeNull(); }); +}); + +const adminRole: RoleJSON = { + object: 'role', + id: 'role_admin', + key: 'org:admin', + name: 'Admin', + description: '', + permissions: [], + created_at: 0, + updated_at: 0, +}; +const memberRole: RoleJSON = { ...adminRole, id: 'role_member', key: 'org:member', name: 'Member' }; +const bob = fapiMembership(organization, { + id: 'orgmem_bob', + public_user_data: { + user_id: 'user_bob', + first_name: 'Bob', + last_name: 'Smith', + identifier: 'bob@example.com', + image_url: '', + has_image: false, + }, +}); +const MANAGE = ['org:sys_memberships:read', 'org:sys_memberships:manage']; + +function serveInvite({ + permissions = MANAGE, + roles = [adminRole, memberRole], + defaultRole = null, + maxAllowedMemberships = 0, +}: { + permissions?: string[]; + roles?: RoleJSON[]; + defaultRole?: string | null; + maxAllowedMemberships?: number; +} = {}) { + const org = { ...organization, max_allowed_memberships: maxAllowedMemberships }; + const membership = fapiMembership(org, { permissions }); + const user = fapiUser({ id: 'user_invitations', organization_memberships: [membership] }); + return serveFapi({ + environment: fapiEnvironment({ + organization_settings: { + ...fapiEnvironment().organization_settings, + domains: { enabled: false, enrollment_modes: [], default_role: defaultRole }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_invitations', user, last_active_organization_id: org.id })]), + memberships: [membership, { ...bob, organization: org }], + organizationInvitations: [invitation], + roles, + }); +} + +async function openInviteDialog() { + const user = userEvent.setup(); + await user.click(await screen.findByRole('tab', { name: 'Invitations' })); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + await user.click(await screen.findByRole('button', { name: 'Invite' })); + const dialog = within(await screen.findByRole('dialog', { name: 'Invite members' })); + return { user, dialog }; +} + +async function addEmails(dialog: ReturnType, ...emailAddresses: string[]) { + const user = userEvent.setup(); + await user.type(dialog.getByRole('textbox', { name: 'Email' }), `${emailAddresses.join(',')}{Enter}`); +} + +describe('inviting members', () => { + it('invites members from the Invitations tab with a role chosen from the keyboard', async () => { + const fapi = serveInvite(); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await addEmails(dialog, 'grace@example.com', ' linus@example.com', 'grace@example.com'); + expect(dialog.getAllByRole('listitem').map(item => item.textContent)).toEqual([ + 'grace@example.com', + 'linus@example.com', + ]); + const send = dialog.getByRole('button', { name: 'Send invites' }); + expect(send).toHaveAttribute('aria-disabled', 'true'); + + dialog.getByRole('combobox', { name: /Role/ }).focus(); + await browserEvent.keyboard('{ArrowDown}'); + const admin = await screen.findByRole('option', { name: 'Admin' }); + await waitFor(() => expect(admin).toHaveFocus()); + await browserEvent.keyboard('{ArrowDown}'); + await waitFor(() => expect(screen.getByRole('option', { name: 'Member' })).toHaveFocus()); + await browserEvent.keyboard('{Enter}'); + await waitFor(() => expect(dialog.getByRole('combobox', { name: /Role/ })).toHaveTextContent('Member')); + + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + await user.click(send); + await waitFor(() => expect(bulk.requests).toHaveLength(1)); + const body = new URLSearchParams(await bulk.requests[0]?.text()); + expect(body.getAll('email_address')).toEqual(['grace@example.com', 'linus@example.com']); + expect(body.get('role')).toBe('org:member'); + expect(body.has('notify')).toBe(false); + bulk.release(); + + await waitFor(() => expect(screen.getByRole('heading', { name: 'Invitations sent' })).toBeVisible()); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull()); + const table = within(screen.getByRole('table', { name: 'Invitations' })); + expect(await table.findByText('grace@example.com')).toBeVisible(); + expect(table.getByText('linus@example.com')).toBeVisible(); + expect( + fapi.organizationInvitations + .filter(item => item.status === 'pending') + .map(item => [item.email_address, item.role]), + ).toEqual([ + ['grace@example.com', 'org:member'], + ['linus@example.com', 'org:member'], + ['ada@example.com', 'org:member'], + ]); + await waitFor(() => expect(screen.getByRole('button', { name: 'Invite' })).toHaveFocus()); + }); + + it.each([ + ['the organization default role', { defaultRole: 'org:admin' }, 'Admin'], + ['the only role', { roles: [memberRole] }, 'Member'], + ])('starts with %s', async (_, options, label) => { + serveInvite(options); + await renderWithClerk(); + const { dialog } = await openInviteDialog(); + expect(dialog.getByRole('combobox', { name: /Role/ })).toHaveTextContent(label); + await addEmails(dialog, 'grace@example.com'); + expect(dialog.getByRole('button', { name: 'Send invites' })).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('ignores a default role that is not in the role list', async () => { + serveInvite({ defaultRole: 'org:missing' }); + await renderWithClerk(); + const { dialog } = await openInviteDialog(); + expect(dialog.getByRole('combobox', { name: /Role/ })).toHaveTextContent('Select a role'); + }); + + it('lists localized role names and falls back to the server name for custom roles', async () => { + serveInvite({ roles: [adminRole, { ...adminRole, id: 'role_billing', key: 'org:billing', name: 'Billing' }] }); + await renderWithClerk( + + + , + ); + const { user, dialog } = await openInviteDialog(); + await user.click(dialog.getByRole('combobox', { name: /Role/ })); + expect(await screen.findByRole('option', { name: 'Administrateur' })).toBeVisible(); + expect(screen.getByRole('option', { name: 'Billing' })).toBeVisible(); + expect(screen.queryByRole('option', { name: 'Admin' })).toBeNull(); + }); + + it('keeps the default role and locks the picker during a role set migration', async () => { + const fapi = serveInvite({ defaultRole: 'org:member' }); + fapi.hasRoleSetMigration = true; + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + const picker = dialog.getByRole('combobox', { name: /Role/ }); + expect(picker).toHaveTextContent('Member'); + expect(picker).toHaveAttribute('data-disabled'); + await addEmails(dialog, 'grace@example.com'); + await user.click(dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => + expect(fapi.organizationInvitations.find(item => item.email_address === 'grace@example.com')?.role).toBe( + 'org:member', + ), + ); + }); - it.todo('invites members from the Invitations tab'); + it('offers Invite on every tab to managers', async () => { + const fapi = serveInvite(); + fapi.environment = fapiEnvironment({ + organization_settings: { + ...fapiEnvironment().organization_settings, + domains: { enabled: true, enrollment_modes: [], default_role: null }, + }, + }); + await renderWithClerk(); + const user = userEvent.setup(); + for (const tab of ['Members', 'Invitations', 'Requests']) { + await user.click(await screen.findByRole('tab', { name: tab })); + expect(await within(screen.getByRole('tabpanel')).findByRole('button', { name: 'Invite' })).toBeVisible(); + } + }); + + it('does not offer Invite without the manage permission', async () => { + serveInvite({ permissions: ['org:sys_memberships:read'] }); + await renderWithClerk(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + }); + + it('does not offer Invite when roles fail to load', async () => { + serveInvite(); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + await renderWithClerk(); + await waitFor(() => expect(roles.requests).toHaveLength(1)); + roles.fail('network_error', 'Unavailable'); + await userEvent.setup().click(await screen.findByRole('tab', { name: 'Invitations' })); + expect(await screen.findByText('ada@example.com')).toBeVisible(); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Manage ada@example.com' })).toBeVisible()); + expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + }); + + it('replaces a pending invitation for the same address', async () => { + const fapi = serveInvite({ defaultRole: 'org:admin' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await addEmails(dialog, 'ada@example.com'); + await user.click(dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Invitations sent' })).toBeVisible()); + expect(fapi.organizationInvitations.map(item => [item.email_address, item.status, item.role])).toEqual([ + ['ada@example.com', 'pending', 'org:admin'], + ['ada@example.com', 'revoked', 'org:member'], + ]); + const table = within(screen.getByRole('table', { name: 'Invitations' })); + await waitFor(() => expect(table.getByRole('cell', { name: 'Admin' })).toBeVisible()); + expect(table.getAllByText('ada@example.com')).toHaveLength(1); + }); + + it.each([ + [ + 'an existing member', + 'bob@example.com', + 'Some of these email addresses already belong to members of this organization.', + ], + ['an address the server cannot parse', 'grace@@example.com', 'Email address must be a valid email address.'], + ])('keeps the dialog open and marks %s as rejected', async (_, rejected, message) => { + const fapi = serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await addEmails(dialog, 'linus@example.com', rejected); + const send = dialog.getByRole('button', { name: 'Send invites' }); + await user.click(send); + await waitFor(() => expect(dialog.getByRole('alert')).toHaveTextContent(message)); + const tags = dialog.getAllByRole('listitem'); + expect(tags.find(tag => tag.textContent === rejected)).toHaveAttribute('data-invalid'); + expect(tags.find(tag => tag.textContent === 'linus@example.com')).not.toHaveAttribute('data-invalid'); + expect(send).toHaveAttribute('aria-disabled', 'true'); + expect(fapi.organizationInvitations).toHaveLength(1); + + await user.click(dialog.getByRole('button', { name: `Remove ${rejected}` })); + await user.click(send); + await waitFor(() => expect(fapi.organizationInvitations).toHaveLength(2)); + expect(fapi.organizationInvitations[0]?.email_address).toBe('linus@example.com'); + }); + + it.each([ + [ + 'the membership quota is reached', + (fapi: ReturnType) => fapi, + { maxAllowedMemberships: 3 }, + 'You have reached your limit of organization memberships, including outstanding invitations.', + ], + [ + 'the selected role was deleted', + (fapi: ReturnType) => { + fapi.roles = [adminRole]; + return fapi; + }, + {}, + 'This role is no longer available.', + ], + [ + 'the manage permission was removed', + (fapi: ReturnType) => { + const session = fapi.client.sessions[0]; + const membership = session?.user.organization_memberships?.[0]; + if (!session || !membership) { + throw new Error('Expected a signed-in member'); + } + fapi.client = fapiClient([ + { ...session, user: { ...session.user, organization_memberships: [{ ...membership, permissions: [] }] } }, + ]); + return fapi; + }, + {}, + 'You do not have permission to perform this action.', + ], + ])('shows the server error in the dialog when %s', async (_, change, options, message) => { + const fapi = serveInvite({ defaultRole: 'org:member', ...options }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + change(fapi); + await addEmails(dialog, 'grace@example.com', 'linus@example.com'); + await user.click(dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => expect(dialog.getByRole('alert')).toHaveTextContent(message)); + expect(fapi.organizationInvitations).toHaveLength(1); + expect(dialog.getAllByRole('listitem').some(tag => tag.hasAttribute('data-invalid'))).toBe(false); + expect(screen.queryByRole('heading', { name: 'Invitations sent' })).toBeNull(); + }); + + it('sends one request and stays open while the invite is pending', async () => { + serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const { user, dialog } = await openInviteDialog(); + await addEmails(dialog, 'grace@example.com'); + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + const send = dialog.getByRole('button', { name: 'Send invites' }); + await user.click(send); + await waitFor(() => expect(bulk.requests).toHaveLength(1)); + send.click(); + await user.keyboard('{Escape}'); + expect(screen.getByRole('dialog', { name: 'Invite members' })).toBeVisible(); + bulk.release(); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull()); + expect(bulk.requests).toHaveLength(1); + }); + + it('clears the draft, rejected addresses and error after closing and reopening', async () => { + serveInvite({ defaultRole: 'org:member' }); + await renderWithClerk(); + const first = await openInviteDialog(); + await addEmails(first.dialog, 'bob@example.com'); + await first.user.click(first.dialog.getByRole('combobox', { name: /Role/ })); + await first.user.click(await screen.findByRole('option', { name: 'Admin' })); + await first.user.click(first.dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => + expect(first.dialog.getByRole('alert')).toHaveTextContent( + 'Some of these email addresses already belong to members of this organization.', + ), + ); + await first.user.click(first.dialog.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull()); + + await first.user.click(screen.getByRole('button', { name: 'Invite' })); + const dialog = within(await screen.findByRole('dialog', { name: 'Invite members' })); + expect(dialog.queryAllByRole('listitem')).toHaveLength(0); + expect(dialog.getByRole('alert')).toHaveTextContent(''); + expect(dialog.getByRole('combobox', { name: /Role/ })).toHaveTextContent('Member'); + await addEmails(dialog, 'bob@example.com'); + expect(dialog.getByRole('listitem')).not.toHaveAttribute('data-invalid'); + }); + + it.each([ + [ + 'switching organizations', + async (clerk: Awaited>['clerk']) => { + await clerk.setActive({ organization: 'org_other' }); + }, + 2, + ], + [ + 'switching sessions', + async (clerk: Awaited>['clerk']) => { + await clerk.setActive({ session: 'sess_second' }); + }, + 2, + ], + [ + 'signing out', + async (clerk: Awaited>['clerk']) => { + await clerk.signOut(); + }, + 1, + ], + ])('drops a held invite after %s', async (_, change, invitationCount) => { + const otherOrganization = fapiOrganization({ id: 'org_other', name: 'Other' }); + const firstMembership = fapiMembership(organization, { permissions: MANAGE }); + const secondMembership = fapiMembership(otherOrganization, { permissions: MANAGE }); + const user = fapiUser({ id: 'user_invitations', organization_memberships: [firstMembership, secondMembership] }); + const fapi = serveFapi({ + environment: fapiEnvironment({ + organization_settings: { + ...fapiEnvironment().organization_settings, + domains: { enabled: false, enrollment_modes: [], default_role: 'org:member' }, + }, + }), + client: fapiClient([ + fapiSession({ id: 'sess_invitations', user, last_active_organization_id: organization.id }), + fapiSession({ id: 'sess_second', user, last_active_organization_id: organization.id }), + ]), + memberships: [firstMembership, secondMembership], + organizationInvitations: [invitation], + roles: [adminRole, memberRole], + }); + const { clerk } = await renderWithClerk(); + const { user: actor, dialog } = await openInviteDialog(); + await addEmails(dialog, 'grace@example.com'); + const bulk = holdRequests('post', '/v1/organizations/:organizationId/invitations/bulk'); + await actor.click(dialog.getByRole('button', { name: 'Send invites' })); + await waitFor(() => expect(bulk.requests).toHaveLength(1)); + await act(() => change(clerk)); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull()); + bulk.release(); + await waitFor(() => expect(fapi.organizationInvitations).toHaveLength(invitationCount)); + await act(() => new Promise(resolve => setTimeout(resolve, 300))); + expect(screen.queryByRole('heading', { name: 'Invitations sent' })).toBeNull(); + expect(screen.queryByRole('dialog', { name: 'Invite members' })).toBeNull(); + }); }); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx new file mode 100644 index 00000000000..78a7925642e --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx @@ -0,0 +1,59 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../../__tests__/async'; +import type { MembersRoles } from '../members-table-tab.types'; +import { useMembersPanelRolesController } from '../organization-profile-members-panel.controller'; + +describe('members panel role loading', () => { + it('loads once and returns the roles when ready', async () => { + const request = deferred(); + const loadRoles = vi.fn(() => request.promise); + const { result, rerender } = renderHook(() => useMembersPanelRolesController(loadRoles)); + + expect(loadRoles).toHaveBeenCalledTimes(1); + expect(result.current).toBeNull(); + rerender(); + expect(loadRoles).toHaveBeenCalledTimes(1); + + const roles = { roles: [{ key: 'org:reader', name: 'Reader' }], hasRoleSetMigration: false }; + await act(() => Promise.resolve(request.resolve(roles))); + expect(result.current).toEqual(roles); + }); + + it('stays unavailable after a failed role load', async () => { + const request = deferred(); + const loadRoles = vi.fn(() => request.promise); + const { result, rerender } = renderHook(() => useMembersPanelRolesController(loadRoles)); + + await act(() => Promise.resolve(request.reject(new Error('failed')))); + expect(result.current).toBeNull(); + rerender(); + expect(loadRoles).toHaveBeenCalledTimes(1); + }); + + it('does not load without a loader', () => { + const { result } = renderHook(() => useMembersPanelRolesController(undefined)); + expect(result.current).toBeNull(); + }); + + it.each(['resolve', 'reject'] as const)('drops an unresolved %s after unmount', async outcome => { + const oldRequest = deferred(); + const { unmount } = renderHook(() => useMembersPanelRolesController(() => oldRequest.promise)); + unmount(); + + const fresh = { roles: [{ key: 'org:new', name: 'New' }], hasRoleSetMigration: false }; + const { result } = renderHook(() => useMembersPanelRolesController(() => Promise.resolve(fresh))); + await waitFor(() => expect(result.current).toEqual(fresh)); + + await act(() => { + if (outcome === 'resolve') { + oldRequest.resolve({ roles: [{ key: 'org:old', name: 'Old' }], hasRoleSetMigration: false }); + } else { + oldRequest.reject(new Error('old request failed')); + } + return Promise.resolve(); + }); + expect(result.current).toEqual(fresh); + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-requests.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-requests.feature.test.tsx index 6f0f2d49420..91515e0da59 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-requests.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-requests.feature.test.tsx @@ -91,10 +91,9 @@ describe('connected organization requests', () => { expect(await screen.findByText('ada@example.com')).toBeVisible(); }); - it('declines a request without fetching Members or roles in manage-only mode', async () => { + it('declines a request without fetching Members in manage-only mode', async () => { const fapi = serve(['org:sys_memberships:manage']); const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); - const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); await renderWithClerk(); const user = userEvent.setup(); await user.click(await screen.findByRole('tab', { name: 'Requests' })); @@ -103,15 +102,12 @@ describe('connected organization requests', () => { await waitFor(() => expect(fapi.organizationMembershipRequests[0]?.status).toBe('rejected')); expect(await screen.findByText('No pending requests')).toBeVisible(); expect(members.requests).toHaveLength(0); - expect(roles.requests).toHaveLength(0); members.release(); - roles.release(); }); - it('accepts in manage-only mode without requesting Members or roles', async () => { + it('accepts in manage-only mode without requesting Members', async () => { const fapi = serve(['org:sys_memberships:manage']); const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); - const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); await renderWithClerk(); const user = userEvent.setup(); await user.click(await screen.findByRole('tab', { name: 'Requests' })); @@ -119,9 +115,7 @@ describe('connected organization requests', () => { await waitFor(() => expect(fapi.organizationMembershipRequests[0]?.status).toBe('accepted')); expect(await screen.findByText('No pending requests')).toBeVisible(); expect(members.requests).toHaveLength(0); - expect(roles.requests).toHaveLength(0); members.release(); - roles.release(); }); it('shows an initial load error and retries', async () => { 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 c4de9ec38cf..9ead312ee98 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 @@ -1,11 +1,22 @@ -import { ClerkRuntimeError } from '@clerk/shared/error'; -import { useOrganization, useSession } from '@clerk/shared/react'; -import type { OrganizationInvitationResource } from '@clerk/shared/types'; +import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { useClerk, useOrganization, useSession } from '@clerk/shared/react'; +import type { ClerkAPIError, OrganizationInvitationResource } from '@clerk/shared/types'; + +import { save } from '../../utils/errors'; +import type { MembersRoles } from './members-table-tab.types'; const PAGE_SIZE = 10; const MANAGE_PERMISSION = 'org:sys_memberships:manage'; +export interface InviteMembersInput { + emailAddresses: string[]; + role: string; + roles: MembersRoles; + onRejected: (emailAddresses: string[]) => void; +} + export function useInvitationsTableModel() { + const clerk = useClerk(); const { session } = useSession(); const canManage = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; const { organization, invitations } = useOrganization({ @@ -41,9 +52,46 @@ export function useInvitationsTableModel() { } } : undefined, + invite: canManage + ? ({ emailAddresses, role, roles, onRejected }: InviteMembersInput) => + save(async () => { + const organizationId = organization?.id; + const sessionId = session?.id; + if (!organization || !roles.roles.some(option => option.key === role)) { + throw new ClerkRuntimeError('This role is unavailable.', { code: 'role_unavailable' }); + } + // TODO: Add session reverification for invitations; surface API errors until then. + try { + await organization.inviteMembers({ emailAddresses, role }); + } catch (cause) { + onRejected(rejectedEmailAddresses(cause, emailAddresses)); + throw cause; + } + if (clerk.organization?.id !== organizationId || clerk.session?.id !== sessionId) { + throw new ClerkRuntimeError('The active organization changed.', { code: 'organization_changed' }); + } + await invitations?.revalidate?.(); + }) + : undefined, }; } +function rejectedEmailAddresses(cause: unknown, submitted: string[]) { + if (!isClerkAPIResponseError(cause)) { + return []; + } + const isRejected = (email: string) => + cause.errors.some( + (error: ClerkAPIError) => + [...(error.meta?.emailAddresses ?? []), ...(error.meta?.identifiers ?? [])].some( + value => value.toLowerCase() === email.toLowerCase(), + ) || + (error.code === 'already_a_member_in_organization' && + (error.longMessage ?? '').toLowerCase().startsWith(`${email.toLowerCase()} `)), + ); + return submitted.filter(isRejected); +} + function toInvitation(invitation: OrganizationInvitationResource) { const { id, emailAddress, createdAt, role, roleName } = invitation; return { id, emailAddress, createdAt, role, roleName }; diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.controller.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.controller.ts index 97e3058c8fd..7c6feb50a90 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.controller.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.controller.ts @@ -3,46 +3,15 @@ import { useState } from 'react'; import { useDebouncedAsync } from '../../hooks/use-debounced-async'; import { usePendingAction } from '../../hooks/use-pending-action'; import { useLocale, useMessages } from '../../localization'; -import { setup } from '../../machine/setup'; -import type { DoneInvokeEvent } from '../../machine/types'; -import { useMachine } from '../../machine/use-machine'; import { formatDate } from '../../utils/format-date'; import type { useMembersTableModel } from './members-table-tab.model'; import type { MembersRoles } from './members-table-tab.types'; -interface RolesContext { - loadRoles: (() => Promise) | undefined; - result: MembersRoles | null; -} - -const { createMachine, assign, fromPromise } = setup(); - -const rolesMachine = createMachine({ - id: 'membersRoles', - initial: context => (context.loadRoles ? 'loading' : 'unavailable'), - context: { loadRoles: undefined, result: null }, - states: { - unavailable: {}, - loading: { - invoke: fromPromise(context => context.loadRoles?.() ?? Promise.resolve(null), { - onDone: { - target: 'ready', - actions: assign>((_, event) => ({ result: event.output })), - }, - onError: { target: 'unavailable' }, - }), - }, - ready: {}, - }, -}); - -export function useMembersTableController(model: ReturnType) { +export function useMembersTableController(model: ReturnType, roles: MembersRoles | null) { const locale = useLocale(); const messages = useMessages('membersTableTab'); const roleNames = useMessages('roles'); const roleAction = usePendingAction({ errorFallback: messages.roleChangeError }); - const [rolesState] = useMachine(rolesMachine, { context: { loadRoles: model.loadRoles } }); - const roles = rolesState.value === 'ready' ? rolesState.context.result : null; return { members: model.rows.map(({ joinedAt, ...member }) => ({ 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 f8a2278e091..7bcfbb5e682 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 @@ -42,18 +42,6 @@ export function useMembersTableModel(query: string) { return { rows, - loadRoles: canManage - ? async (): Promise => { - if (!organization) { - throw new ClerkRuntimeError('Roles are unavailable.', { code: 'role_unavailable' }); - } - const result = await organization.getRoles({ pageSize: 20 }); - return { - roles: result.data.map(({ key, name }) => ({ key, name })), - hasRoleSetMigration: result.has_role_set_migration ?? false, - }; - } - : undefined, pageSize: PAGE_SIZE, totalCount: memberships?.count ?? 0, page: memberships?.page ?? 1, diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts new file mode 100644 index 00000000000..4f20a7305b4 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts @@ -0,0 +1,84 @@ +import type { MouseEvent } from 'react'; +import { useRef, useState } from 'react'; + +import { useForm } from '../../components/form'; +import { useToastManager } from '../../components/toast'; +import { useMessages } from '../../localization'; +import type { InviteMembersInput } from './invitations-table-tab.model'; +import type { MembersRoles } from './members-table-tab.types'; +import type { OrganizationProfileInviteMembersDialogProps } from './organization-profile-invite-members.dialog'; + +interface InviteMembersValues { + emailAddresses: string[]; + role: string | null; +} + +export interface InviteMembersControllerOptions { + roles: MembersRoles | null; + defaultRole: string | null; + invite: ((input: InviteMembersInput) => Promise) | undefined; +} + +export function useInviteMembersController({ roles, defaultRole, invite }: InviteMembersControllerOptions) { + const m = useMessages('organizationProfileInviteMembers'); + const roleNames = useMessages('roles'); + const toast = useToastManager(); + const trigger = useRef(null); + const [open, setOpen] = useState(false); + const [rejectedEmailAddresses, setRejectedEmailAddresses] = useState([]); + const initialRole = roles ? resolveDefaultRole(roles, defaultRole) : null; + + const form = useForm({ + initialValues: { emailAddresses: [], role: initialRole }, + errorFallback: m.error, + onSubmit: async ({ emailAddresses, role }) => { + if (!invite || !roles || role === null) { + return; + } + await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses }); + setOpen(false); + toast.add({ type: 'success', label: m.sent }); + }, + }); + + if (!invite || !roles) { + return { onInvite: undefined, inviteDialog: undefined }; + } + + const onOpenChange = (next: boolean) => { + if (form.isSubmitting) { + return; + } + form.reset({ emailAddresses: [], role: initialRole }); + setRejectedEmailAddresses([]); + setOpen(next); + }; + + const inviteDialog: OrganizationProfileInviteMembersDialogProps = { + open, + onOpenChange, + finalFocus: trigger, + emailAddresses: form.values.emailAddresses, + onEmailAddressesChange: emailAddresses => form.setValue('emailAddresses', emailAddresses), + rejectedEmailAddresses, + roles: roles.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })), + role: form.values.role, + onRoleChange: role => form.setValue('role', role), + isRoleDisabled: roles.hasRoleSetMigration, + isPending: form.isSubmitting, + error: form.error ?? null, + onSubmit: form.submit, + }; + + const onInvite = (event: MouseEvent) => { + trigger.current = event.currentTarget; + onOpenChange(true); + }; + + return { onInvite, inviteDialog }; +} + +function resolveDefaultRole({ roles }: MembersRoles, defaultRole: string | null) { + const candidate = defaultRole ?? (roles.length === 1 ? roles[0]?.key : undefined); + return roles.some(role => role.key === candidate) ? (candidate ?? null) : null; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.dialog.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.dialog.tsx index 3c3afc98325..b7d33a2c9ee 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.dialog.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.dialog.tsx @@ -24,6 +24,7 @@ export interface OrganizationProfileInviteMembersDialogProps { roles: SelectItem[]; role: string | null; onRoleChange: (role: string | null) => void; + isRoleDisabled?: boolean; isPending: boolean; error: string | null; onSubmit: () => void | Promise; @@ -39,6 +40,7 @@ export function OrganizationProfileInviteMembersDialog({ roles, role, onRoleChange, + isRoleDisabled = false, isPending, error, onSubmit, @@ -113,7 +115,7 @@ export function OrganizationProfileInviteMembersDialog({ {m.roleLabel} Promise) | undefined; + result: MembersRoles | null; +} + +const { createMachine, assign, fromPromise } = setup(); + +const rolesMachine = createMachine({ + id: 'membersRoles', + initial: context => (context.loadRoles ? 'loading' : 'unavailable'), + context: { loadRoles: undefined, result: null }, + states: { + unavailable: {}, + loading: { + invoke: fromPromise(context => context.loadRoles?.() ?? Promise.resolve(null), { + onDone: { + target: 'ready', + actions: assign>((_, event) => ({ result: event.output })), + }, + onError: { target: 'unavailable' }, + }), + }, + ready: {}, + }, +}); + +export function useMembersPanelRolesController(loadRoles: (() => Promise) | undefined) { + const [state] = useMachine(rolesMachine, { context: { loadRoles } }); + return state.value === 'ready' ? state.context.result : null; +} 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 index 569989205a2..a1e8e6c144f 100644 --- 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 @@ -1,6 +1,8 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { useOrganization, useSession } from '@clerk/shared/react'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import type { MembersRoles } from './members-table-tab.types'; const READ_PERMISSION = 'org:sys_memberships:read'; const MANAGE_PERMISSION = 'org:sys_memberships:manage'; @@ -27,3 +29,23 @@ export function useMembersPanelAccessModel() { canManageRequests, }; } + +export function useMembersPanelRolesModel(canManage: boolean) { + const { organization } = useOrganization(); + const environment = useMosaicEnvironment(); + return { + defaultRole: environment?.organizationSettings.domains.defaultRole ?? null, + loadRoles: canManage + ? async (): Promise => { + if (!organization) { + throw new ClerkRuntimeError('Roles are unavailable.', { code: 'role_unavailable' }); + } + const result = await organization.getRoles({ pageSize: 20 }); + return { + roles: result.data.map(({ key, name }) => ({ key, name })), + hasRoleSetMigration: result.has_role_set_migration ?? false, + }; + } + : undefined, + }; +} 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 8147a39f483..b742a172ce8 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 @@ -4,7 +4,11 @@ import { useInvitationsTableController } from './invitations-table-tab.controlle import { useInvitationsTableModel } from './invitations-table-tab.model'; import { useMembersTableController, useMembersTableSearchController } from './members-table-tab.controller'; import { useMembersTableModel } from './members-table-tab.model'; -import { useMembersPanelAccessModel } from './organization-profile-members-panel.model'; +import type { MembersRoles } from './members-table-tab.types'; +import { useInviteMembersController } from './organization-profile-invite-members.controller'; +import { useMembersPanelRolesController } from './organization-profile-members-panel.controller'; +import { useMembersPanelAccessModel, useMembersPanelRolesModel } from './organization-profile-members-panel.model'; +import type { OrganizationProfileMembersPanelViewProps } from './organization-profile-members-panel.view'; import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; import { useRequestsTableController } from './requests-table-tab.controller'; import { useRequestsTableModel } from './requests-table-tab.model'; @@ -36,41 +40,40 @@ function ConnectedMembersPanel({ canManageInvitations: boolean; canManageRequests: boolean; }) { + const rolesModel = useMembersPanelRolesModel(canManageInvitations); + const roles = useMembersPanelRolesController(rolesModel.loadRoles); const model = useInvitationsTableModel(); const invitations = useInvitationsTableController(model); + const invite = useInviteMembersController({ roles, defaultRole: rolesModel.defaultRole, invite: model.invite }); const requestsModel = useRequestsTableModel(canManageRequests); const requests = useRequestsTableController(requestsModel); + const panel = { + invitations: canManageInvitations ? invitations : undefined, + requests: canManageRequests ? requests : undefined, + ...invite, + }; if (canReadMembers) { return ( ); } - return ( - - ); + return ; } function ConnectedReadableMembersPanel({ - invitations, - requests, -}: { - invitations?: ReturnType; - requests?: ReturnType; -}) { + roles, + ...panel +}: Omit & { roles: MembersRoles | null }) { const search = useMembersTableSearchController(); const model = useMembersTableModel(search.query); - const members = useMembersTableController(model); + const members = useMembersTableController(model, roles); return ( ); } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 9a3d7bccd0c..d4dff125961 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,10 +1,12 @@ export const errorMessages: { readonly generic: string } & Readonly> = { generic: 'Something went wrong. Please try again.', + already_a_member_in_organization: 'Some of these email addresses already belong to members of this organization.', action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", active_device_unavailable: 'This device is no longer available. Please try again.', avatar_file_count_exceeded: 'Only one file can be uploaded at a time.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + bulk_size_exceeded: 'You can invite up to 50 email addresses at a time.', captcha_unavailable: 'Sign up unsuccessful due to failed bot validation. Please refresh the page to try again or reach out to support for more assistance.', connected_account_unavailable: 'This connected account is no longer available.', @@ -12,6 +14,7 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Fri, 9 Oct 2026 14:36:07 -0600 Subject: [PATCH 2/5] test(mosaic): assert readers never request roles for Invite --- .../organization-profile-invitations.feature.test.tsx | 3 +++ 1 file changed, 3 insertions(+) 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 1c05061c38f..a4cd7101f7d 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 @@ -523,9 +523,12 @@ describe('inviting members', () => { it('does not offer Invite without the manage permission', async () => { serveInvite({ permissions: ['org:sys_memberships:read'] }); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); await renderWithClerk(); expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(roles.requests).toHaveLength(0); expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull(); + roles.release(); }); it('does not offer Invite when roles fail to load', async () => { From adc875e010e17b60430c15f3271824a57594c5ff Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 15:27:48 -0600 Subject: [PATCH 3/5] test(mosaic): tighten invite dialog and roles controller tests --- ...ion-profile-invite-members.dialog.test.tsx | 3 +++ ...-profile-members-panel.controller.test.tsx | 22 +------------------ 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx index d82c2b9dceb..e13a27621d0 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx @@ -81,6 +81,9 @@ describe('OrganizationProfileInviteMembersDialog', () => { }); expect(screen.getByRole('alert')).toHaveTextContent('ada@clerk.dev is already a member.'); + const [preston, ada] = screen.getAllByRole('listitem'); + expect(preston).not.toHaveAttribute('data-invalid'); + expect(ada).toHaveAttribute('data-invalid', ''); expect(screen.getByRole('button', { name: 'Send invites' })).toHaveAttribute('aria-disabled', 'true'); }); }); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx index 78a7925642e..9d4a4f05f56 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { deferred } from '../../../__tests__/async'; @@ -36,24 +36,4 @@ describe('members panel role loading', () => { const { result } = renderHook(() => useMembersPanelRolesController(undefined)); expect(result.current).toBeNull(); }); - - it.each(['resolve', 'reject'] as const)('drops an unresolved %s after unmount', async outcome => { - const oldRequest = deferred(); - const { unmount } = renderHook(() => useMembersPanelRolesController(() => oldRequest.promise)); - unmount(); - - const fresh = { roles: [{ key: 'org:new', name: 'New' }], hasRoleSetMigration: false }; - const { result } = renderHook(() => useMembersPanelRolesController(() => Promise.resolve(fresh))); - await waitFor(() => expect(result.current).toEqual(fresh)); - - await act(() => { - if (outcome === 'resolve') { - oldRequest.resolve({ roles: [{ key: 'org:old', name: 'Old' }], hasRoleSetMigration: false }); - } else { - oldRequest.reject(new Error('old request failed')); - } - return Promise.resolve(); - }); - expect(result.current).toEqual(fresh); - }); }); From 4b89d2f8107dbbf91645942c7bb2181d2a969341 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 15:55:46 -0600 Subject: [PATCH 4/5] chore(mosaic): mark invite success after unmount as a known gap --- .../organization-profile-invitations.feature.test.tsx | 3 +++ .../organization-profile-invite-members.controller.ts | 1 + 2 files changed, 4 insertions(+) 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 a4cd7101f7d..91c07407f2d 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 @@ -673,6 +673,9 @@ describe('inviting members', () => { expect(dialog.getByRole('listitem')).not.toHaveAttribute('data-invalid'); }); + it.todo('drops the success toast after the panel unmounts during an invite'); + it.todo('drops the success toast after a session switch while the list revalidates'); + it.each([ [ 'switching organizations', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts index 4f20a7305b4..dbd65b809e4 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts @@ -36,6 +36,7 @@ export function useInviteMembersController({ roles, defaultRole, invite }: Invit return; } await invite({ emailAddresses, role, roles, onRejected: setRejectedEmailAddresses }); + // TODO: Skip closing and the success toast when the panel unmounted or its identity changed during the invite or the list revalidation; useForm has no success hook that is dropped on unmount. setOpen(false); toast.add({ type: 'success', label: m.sent }); }, From 91567901908b8516ce2ec0104f03c496a5c5e948 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 9 Oct 2026 16:57:14 -0600 Subject: [PATCH 5/5] test(mosaic): remove redundant invite dialog coverage --- ...ation-profile-invitations.feature.test.tsx | 6 +--- ...ion-profile-invite-members.dialog.test.tsx | 30 ------------------- 2 files changed, 1 insertion(+), 35 deletions(-) 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 91c07407f2d..99bd92d8bc3 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 @@ -410,11 +410,7 @@ describe('inviting members', () => { const fapi = serveInvite(); await renderWithClerk(); const { user, dialog } = await openInviteDialog(); - await addEmails(dialog, 'grace@example.com', ' linus@example.com', 'grace@example.com'); - expect(dialog.getAllByRole('listitem').map(item => item.textContent)).toEqual([ - 'grace@example.com', - 'linus@example.com', - ]); + await addEmails(dialog, 'grace@example.com', 'linus@example.com'); const send = dialog.getByRole('button', { name: 'Send invites' }); expect(send).toHaveAttribute('aria-disabled', 'true'); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx index e13a27621d0..5f67850edbd 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx @@ -43,22 +43,6 @@ function renderDialog(props: HarnessProps = {}) { } describe('OrganizationProfileInviteMembersDialog', () => { - it('turns typed emails into tags and submits them', async () => { - const user = userEvent.setup(); - const onSubmit = vi.fn(); - renderDialog({ onSubmit }); - - const submit = screen.getByRole('button', { name: 'Send invites' }); - expect(submit).toHaveAttribute('aria-disabled', 'true'); - - await user.type(screen.getByRole('textbox', { name: 'Email' }), 'preston@clerk.dev,nate@clerk.dev{Enter}'); - expect(screen.getByRole('button', { name: 'Remove preston@clerk.dev' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Remove nate@clerk.dev' })).toBeInTheDocument(); - - await user.click(submit); - expect(onSubmit).toHaveBeenCalledOnce(); - }); - it('blocks submitting until malformed emails are removed', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); @@ -72,18 +56,4 @@ describe('OrganizationProfileInviteMembersDialog', () => { await user.click(screen.getByRole('button', { name: 'Send invites' })); expect(onSubmit).toHaveBeenCalledOnce(); }); - - it('marks emails the server rejected and shows its error', () => { - renderDialog({ - initialEmailAddresses: ['preston@clerk.dev', 'ada@clerk.dev'], - rejectedEmailAddresses: ['ada@clerk.dev'], - error: 'ada@clerk.dev is already a member.', - }); - - expect(screen.getByRole('alert')).toHaveTextContent('ada@clerk.dev is already a member.'); - const [preston, ada] = screen.getAllByRole('listitem'); - expect(preston).not.toHaveAttribute('data-invalid'); - expect(ada).toHaveAttribute('data-invalid', ''); - expect(screen.getByRole('button', { name: 'Send invites' })).toHaveAttribute('aria-disabled', 'true'); - }); });