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..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 @@ -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,395 @@ 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'); + 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(); - it.todo('invites members from the Invitations tab'); + 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('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'] }); + 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 () => { + 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.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', + 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-invite-members.dialog.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-invite-members.dialog.test.tsx index d82c2b9dceb..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,15 +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.'); - 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 new file mode 100644 index 00000000000..9d4a4f05f56 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.controller.test.tsx @@ -0,0 +1,39 @@ +import { act, renderHook } 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(); + }); +}); 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..dbd65b809e4 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-invite-members.controller.ts @@ -0,0 +1,85 @@ +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 }); + // 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 }); + }, + }); + + 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