diff --git a/.changeset/quiet-members-list.md b/.changeset/quiet-members-list.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-members-list.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index ebf0fb0b109..2738946e767 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -42,6 +42,7 @@ Most problems a feature hits already have a shared answer. Find the row for your | Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) | | A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | | A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | +| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) | | Current time for display labels | `useNow` + `MosaicProvider` ([#10098](https://github.com/clerk/javascript/pull/10098), once available) | | Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | | An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` | diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index de409b5e0bd..9935aa83a46 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -9,6 +9,7 @@ import type { OrganizationMembershipJSON, OrganizationSuggestionJSON, PhoneNumberJSON, + RoleJSON, SessionJSON, SessionWithActivitiesJSON, UserJSON, @@ -20,6 +21,7 @@ import { setupWorker } from 'msw/browser'; import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise'; import { createMfaState, type FakeMfaState, mfaHandlers } from './fake-fapi/mfa'; +import { organizationMemberHandlers } from './fake-fapi/organization-members'; import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys'; import { envelope, error, findSession, missing, updateUser, VERIFICATION_CODE } from './fake-fapi/shared'; import { @@ -54,6 +56,8 @@ export interface FakeFapiState { client: ClientJSON; country?: string; memberships: OrganizationMembershipJSON[]; + roles: RoleJSON[]; + hasRoleSetMigration: boolean; invitations: UserOrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; apiKeys: ApiKeyJSON[]; @@ -286,6 +290,8 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { environment: fapiEnvironment(), client: fapiClient(), memberships: [], + roles: [], + hasRoleSetMigration: false, invitations: [], suggestions: [], apiKeys: [], @@ -306,6 +312,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { let identifications = 0; worker.use( + ...organizationMemberHandlers(state, fapiUrl), ...verificationHandlers(state, fapiUrl), ...enterpriseHandlers(state, fapiUrl), ...passkeyHandlers(state, fapiUrl, passkeys), diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts new file mode 100644 index 00000000000..d4654ca2e56 --- /dev/null +++ b/packages/mosaic/src/__tests__/feature/fake-fapi/organization-members.ts @@ -0,0 +1,71 @@ +import { http, HttpResponse } from 'msw'; + +import type { FakeFapiState } from '../fake-fapi'; +import { envelope, missing } from './shared'; + +export function organizationMemberHandlers(state: FakeFapiState, fapiUrl: (path: string) => string) { + return [ + http.get(fapiUrl('/v1/organizations/:organizationId/memberships'), ({ params, request }) => { + const url = new URL(request.url); + const query = url.searchParams.get('query')?.toLowerCase(); + if (query === '') { + return HttpResponse.json( + { errors: [{ code: 'form_param_missing', message: 'Enter query.', meta: { param_name: 'query' } }] }, + { status: 422 }, + ); + } + const matching = state.memberships.filter(member => { + if (member.organization.id !== params.organizationId) { + return false; + } + if (!query) { + return true; + } + const user = member.public_user_data; + return [user?.first_name, user?.last_name, user?.identifier].some(value => + value?.toLowerCase().includes(query), + ); + }); + const offset = Number(url.searchParams.get('offset') ?? 0); + const limit = Number(url.searchParams.get('limit') ?? 10); + return envelope({ data: matching.slice(offset, offset + limit), total_count: matching.length }, null); + }), + http.get(fapiUrl('/v1/organizations/:organizationId/roles'), ({ params }) => { + const organizationExists = state.memberships.some(member => member.organization.id === params.organizationId); + return organizationExists + ? envelope( + { data: state.roles, total_count: state.roles.length, has_role_set_migration: state.hasRoleSetMigration }, + null, + ) + : missing(); + }), + http.post(fapiUrl('/v1/organizations/:organizationId/memberships/:userId'), async ({ params, request }) => { + const member = state.memberships.find( + item => item.organization.id === params.organizationId && item.public_user_data?.user_id === params.userId, + ); + if (!member) { + return missing(); + } + const method = new URL(request.url).searchParams.get('_method'); + if (method === 'DELETE') { + state.memberships = state.memberships.filter(item => item.id !== member.id); + return envelope(member, null); + } + if (method !== 'PATCH') { + return missing(); + } + const body = new URLSearchParams(await request.text()); + const role = body.get('role'); + if (!role) { + return HttpResponse.json( + { errors: [{ code: 'form_param_missing', message: 'Role is required' }] }, + { status: 422 }, + ); + } + const selected = state.roles.find(item => item.key === role); + const updated = { ...member, role, role_name: selected?.name ?? role }; + state.memberships = state.memberships.map(item => (item.id === member.id ? updated : item)); + return envelope(updated, null); + }), + ]; +} diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts b/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts index 842547d6a1c..456ed7cb808 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.controller.ts @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import { useNow } from '../../hooks/use-now'; import { useLocale } from '../../localization'; -import { formatDate, formatRelativeTime } from './api-keys-table.format'; +import { formatDate } from '../../utils/format-date'; +import { formatRelativeTime } from './api-keys-table.format'; import { useAPIKeysTableModel } from './api-keys-table.model'; import type { APIKey, APIKeyRecord, APIKeysTableMessages } from './api-keys-table.types'; import { useCreateAPIKeyController } from './create-api-key.controller'; diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.format.ts b/packages/mosaic/src/features/api-keys/api-keys-table.format.ts index d4766a1174f..f9c3b36c564 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.format.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.format.ts @@ -33,10 +33,6 @@ export function getExpirationDate(expiration: APIKeyExpiration, now: Date): Date return date; } -export function formatDate(date: Date, locale: string): string { - return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric' }).format(date); -} - export function formatRelativeTime(date: Date, locale: string, now: Date): string { const seconds = Math.min(0, Math.round((date.getTime() - now.getTime()) / 1000)); const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); diff --git a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts index 7b243b2bcdf..5bbba9f4f19 100644 --- a/packages/mosaic/src/features/api-keys/create-api-key.controller.ts +++ b/packages/mosaic/src/features/api-keys/create-api-key.controller.ts @@ -4,7 +4,8 @@ import { useRef, useState } from 'react'; import { useForm } from '../../components/form'; import { useNow } from '../../hooks/use-now'; import { useLocale } from '../../localization'; -import { formatDate, getExpirationDate } from './api-keys-table.format'; +import { formatDate } from '../../utils/format-date'; +import { getExpirationDate } from './api-keys-table.format'; import type { APIKeysTableMessages } from './api-keys-table.types'; import type { CreateAPIKeyDialogProps, CreateAPIKeyValues } from './create-api-key.dialog'; 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 new file mode 100644 index 00000000000..d7ec1dd297d --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.controller.test.tsx @@ -0,0 +1,108 @@ +import { act, renderHook, waitFor } 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) { + return { + rows: [], + loadRoles, + totalCount: 0, + page: 1, + isLoading: false, + isFetching: false, + isError: false, + retry: vi.fn(), + fetchPage: vi.fn(), + changeRole: vi.fn((_id: string, _role: string, _roles: MembersRoles) => Promise.resolve()), + remove: vi.fn((_id: string) => Promise.resolve()), + }; +} + +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); + 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))); + 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)); + 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 { + const { result } = renderHook(() => useMembersTableSearchController()); + act(() => result.current.onSearchChange('ada')); + await act(() => vi.advanceTimersByTimeAsync(500)); + expect(result.current.query).toBe('ada'); + act(() => result.current.onSearchChange('')); + expect(result.current.query).toBe(''); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx index 29c22fa3d29..ab39b280b8c 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/members-table-tab.view.test.tsx @@ -35,6 +35,7 @@ function propsFor(overrides: Partial = {}): MembersTab ], totalCount: 2, page: 1, + pageSize: 10, searchValue: '', isLoading: false, onSearchChange: vi.fn(), @@ -117,55 +118,31 @@ describe('MembersTableTabView', () => { const { props } = renderView({ totalCount: 25, onBulkAction: vi.fn(), - onSortChange: vi.fn(), - onPageSizeChange: vi.fn(), }); await user.click(screen.getByRole('checkbox', { name: 'Select all members on this page' })); expect(screen.getByRole('checkbox', { name: 'Select Ada Lovelace' })).not.toBeChecked(); expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked(); - await user.click(screen.getByRole('button', { name: 'Joined' })); - expect(props.onSortChange).toHaveBeenCalledWith({ column: 'joinedAt', direction: 'ascending' }); - expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).not.toBeChecked(); await user.type(screen.getByRole('searchbox', { name: 'Search members' }), 'G'); expect(props.onSearchChange).toHaveBeenCalledWith('G'); await user.click(screen.getByRole('button', { name: 'Next members page' })); expect(props.onPageChange).toHaveBeenCalledWith(2); - await user.click(screen.getByRole('combobox', { name: /^Results per page/ })); - await user.click(screen.getByRole('option', { name: '20', exact: true })); - expect(props.onPageSizeChange).toHaveBeenCalledWith(20); - expect(props.onPageChange).toHaveBeenLastCalledWith(1); + expect(screen.queryByRole('combobox', { name: /^Results per page/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Joined' })).toBeNull(); }); - it.each>([ - { page: 2 }, - { pageSize: 20 }, - { searchValue: 'Grace' }, - { sort: { column: 'role', direction: 'ascending' } }, - { sort: { column: 'name', direction: 'descending' } }, - ])('clears selection when the caller changes result state: %j', async change => { + it('clears selection when a removal is confirmed', async () => { const user = userEvent.setup(); - const { props, rerender } = renderView({ onBulkAction: vi.fn(), sort: { column: 'name', direction: 'ascending' } }); - await user.click(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })); - rerender( - - - , - ); - expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked(); - rerender( - - - , - ); - expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).not.toBeChecked(); + const member = propsFor().members[1]; + renderView({ + members: [member, { ...member, id: 'linus', name: 'Linus Torvalds' }], + onBulkAction: vi.fn(), + onRemove: vi.fn().mockResolvedValue(undefined), + }); + await user.click(screen.getByRole('checkbox', { name: 'Select Linus Torvalds' })); + await user.click(screen.getByRole('button', { name: 'Manage Grace Hopper' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove from organization' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' })); + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Select Linus Torvalds' })).not.toBeChecked()); }); - it('does not retain hidden selection for protected members within a Shift-click range', async () => { const user = userEvent.setup(); const member = propsFor().members[1]; @@ -194,6 +171,13 @@ describe('MembersTableTabView', () => { expect(screen.getByRole('checkbox', { name: 'Select Deprovisioned' })).not.toBeChecked(); }); + it('marks deprovisioned members as inactive', () => { + const member = propsFor().members[1]; + renderView({ members: [member, { ...member, id: 'gone', name: 'Gone', isDeprovisioned: true }], totalCount: 2 }); + expect(screen.getByRole('row', { name: /Gone/ })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('row', { name: /Grace Hopper/ })).not.toHaveAttribute('aria-disabled'); + }); + it('routes invite and role changes while withholding protected member actions', async () => { const user = userEvent.setup(); const { props } = renderView({ onRemove: vi.fn(), onChangeRole: vi.fn(), onInvite: vi.fn() }); diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.feature.test.tsx new file mode 100644 index 00000000000..667ec1c9bfe --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile-members-panel.feature.test.tsx @@ -0,0 +1,371 @@ +import type { 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, vi } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiMembership, fapiOrganization, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { MosaicLocalizationProvider, resolveLocalization } from '../../../localization'; +import { OrganizationProfileMembersPanel } from '../organization-profile-members-panel'; + +const organization = fapiOrganization({ id: 'org_1', name: 'Acme' }); +const bob = fapiMembership(organization, { + id: 'orgmem_bob', + public_user_data: { + user_id: 'user_2', + first_name: 'Bob', + last_name: 'Smith', + identifier: 'bob@example.com', + image_url: '', + has_image: false, + }, +}); +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' }; + +function serve(permissions: string[] = ['org:sys_memberships:read']) { + const currentMembership = fapiMembership(organization, { permissions }); + const alice = fapiUser({ id: 'user_1', organization_memberships: [currentMembership] }); + return serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: organization.id })]), + memberships: [currentMembership, bob], + roles: [adminRole, memberRole], + }); +} + +describe('OrganizationProfileMembersPanel', () => { + it('shows active organization members from Clerk', async () => { + serve(); + + await renderWithClerk(); + + expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(screen.getByText('bob@example.com')).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Manage Bob Smith' })).toBeNull(); + }); + + it('does not load roles for a member who cannot change them', async () => { + serve(); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + + await renderWithClerk(); + + expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(roles.requests).toHaveLength(0); + roles.release(); + }); + + it('does not fetch or show the list without read permission', async () => { + serve([]); + const request = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + const { container } = await renderWithClerk(); + expect(container).toBeEmptyDOMElement(); + expect(request.requests).toHaveLength(0); + expect(roles.requests).toHaveLength(0); + request.release(); + roles.release(); + }); + + it('loads members with an empty search and restores them after clearing a search', async () => { + serve(); + await renderWithClerk(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + + const user = userEvent.setup(); + await user.type(screen.getByRole('searchbox', { name: 'Search members' }), 'Nobody'); + expect(await screen.findByText('No members found')).toBeVisible(); + expect(screen.queryByText('Bob Smith')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Clear search' })); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(screen.getByRole('searchbox', { name: 'Search members' })).toHaveValue(''); + expect(screen.queryByText('Unable to load members')).toBeNull(); + }); + + it('searches after the input settles and keeps rows while a new page loads', async () => { + serve(); + await renderWithClerk(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + const user = userEvent.setup(); + const request = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + await user.type(screen.getByRole('searchbox', { name: 'Search members' }), ' Smith '); + expect(screen.getByText('Bob Smith')).toBeVisible(); + await waitFor(() => expect(request.requests).toHaveLength(1)); + expect(new URL(request.requests[0]?.url ?? '').searchParams.get('query')).toBe('Smith'); + expect(screen.getByText('Bob Smith')).toBeVisible(); + request.release(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + }); + + it('changes a member role and removes the member after confirmation', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })); + const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + await waitFor(() => expect(fapi.memberships.find(item => item.id === bob.id)?.role).toBe('org:admin')); + await waitFor(() => expect(members.requests).toHaveLength(1)); + expect(screen.getByRole('button', { name: 'Manage Bob Smith' })).toBeDisabled(); + members.release(); + await user.click(await screen.findByRole('button', { name: 'Manage Bob Smith' })); + await user.click(await screen.findByRole('menuitem', { name: 'Remove from organization' })); + const dialog = await screen.findByRole('alertdialog', { name: 'Remove Bob Smith?' }); + await user.click(within(dialog).getByRole('button', { name: 'Remove from organization' })); + await waitFor(() => expect(fapi.memberships.some(item => item.id === bob.id)).toBe(false)); + await waitFor(() => expect(screen.queryByText('Bob Smith')).toBeNull()); + }); + + it('keeps members visible without a roles notice when roles fail', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + const { clerk, rerender } = await renderWithClerk(
); + const activeOrganization = clerk.organization; + if (!activeOrganization) { + throw new Error('Expected an active organization'); + } + const getRoles = vi.spyOn(activeOrganization, 'getRoles'); + rerender(); + await waitFor(() => expect(roles.requests).toHaveLength(1)); + roles.fail('roles_unavailable', 'Roles unavailable'); + await act(async () => { + await expect(getRoles.mock.results[0]?.value).rejects.toThrow(); + }); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Try again' })).toBeNull(); + expect(getRoles).toHaveBeenCalledTimes(1); + }); + + it('warns about role migration while retaining removal', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + fapi.hasRoleSetMigration = true; + await renderWithClerk(); + const migration = await screen.findByText(/role migration is in progress/); + expect(migration.closest('[role="status"]')).toHaveAttribute('data-color', 'warning'); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage Bob Smith' })).toBeVisible(); + }); + + it('loads fresh roles after switching sessions in the same organization', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + const currentUser = fapi.client.sessions[0]?.user; + if (!currentUser) { + throw new Error('Expected a signed-in user'); + } + fapi.client = fapiClient([ + ...fapi.client.sessions, + fapiSession({ id: 'sess_2', user: currentUser, last_active_organization_id: organization.id }), + ]); + fapi.hasRoleSetMigration = true; + const { clerk } = await renderWithClerk(); + expect(await screen.findByText(/role migration is in progress/)).toBeVisible(); + + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + fapi.hasRoleSetMigration = false; + await act(() => clerk.setActive({ session: 'sess_2' })); + await waitFor(() => expect(roles.requests).toHaveLength(1)); + expect(screen.queryByText(/role migration is in progress/)).toBeNull(); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + roles.release(); + expect(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeVisible(); + }); + + it('keeps members visible and role editing unavailable while roles load', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + const roles = holdRequests('get', '/v1/organizations/:organizationId/roles'); + await renderWithClerk(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + await waitFor(() => expect(roles.requests).toHaveLength(1)); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + expect(screen.getByRole('button', { name: 'Manage Bob Smith' })).toBeVisible(); + roles.release(); + expect(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeVisible(); + }); + + it('shows the membership role label when the role is absent from available options', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + fapi.memberships[1] = fapiMembership(organization, { ...bob, role: 'org:retired', role_name: 'Retired' }); + await renderWithClerk(); + expect(await screen.findByText('Retired')).toBeVisible(); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + }); + + it('retries a failed member load', async () => { + serve(); + const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + await renderWithClerk(); + await waitFor(() => expect(members.requests).toHaveLength(1)); + members.fail('network_error', 'Unavailable'); + await waitFor(() => expect(screen.getByText('Unable to load members')).toBeVisible(), { timeout: 12_000 }); + serve(); + await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' })); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + }, 20_000); + + it('replaces rows with the table error state and retries when refreshing the current page fails', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await renderWithClerk(); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + const members = holdRequests('get', '/v1/organizations/:organizationId/memberships'); + const user = userEvent.setup(); + await user.click(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + await waitFor(() => expect(members.requests).toHaveLength(1)); + members.fail('network_error', 'Unavailable'); + await waitFor(() => expect(screen.getByText('Unable to load members')).toBeVisible(), { + timeout: 12_000, + }); + expect(screen.queryByText('Bob Smith')).toBeNull(); + const table = within(screen.getByRole('table', { name: 'Members' })); + expect(table.getByRole('alert')).toHaveTextContent('Unable to load members'); + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await user.click(table.getByRole('button', { name: 'Try again' })); + await waitFor(() => expect(screen.queryByText('Unable to load members')).toBeNull()); + expect(screen.getByText('Bob Smith')).toBeVisible(); + }, 20_000); + + it('does not offer mutation controls for the current or deprovisioned member', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + fapi.memberships[0] = fapiMembership(organization, { + id: 'orgmem_self', + public_user_data: { + user_id: 'user_1', + first_name: 'Alice', + last_name: '', + identifier: 'alice@example.com', + image_url: '', + has_image: false, + }, + }); + fapi.memberships[1] = fapiMembership(organization, { + ...bob, + public_user_data: { + ...bob.public_user_data, + deprovisioned: true, + user_id: 'user_2', + first_name: 'Bob', + last_name: 'Smith', + identifier: 'bob@example.com', + image_url: '', + has_image: false, + }, + }); + await renderWithClerk(); + expect(await screen.findByText('Alice')).toBeVisible(); + expect(await screen.findByRole('combobox', { name: /^Change role for Alice/ })).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Manage Alice' })).toBeNull(); + expect(screen.queryByRole('combobox', { name: /^Change role for Bob Smith/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage Bob Smith' })).toBeNull(); + }); + + it('returns to the previous page after removing its last member', async () => { + const fapi = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + fapi.memberships = [ + fapi.memberships[0] ?? fapiMembership(organization), + ...Array.from({ length: 9 }, (_, index) => + fapiMembership(organization, { + id: `orgmem_extra_${index}`, + public_user_data: { + user_id: `user_extra_${index}`, + first_name: `Extra${index}`, + last_name: '', + identifier: `extra${index}@example.com`, + image_url: '', + has_image: false, + }, + }), + ), + bob, + ]; + await renderWithClerk(); + const user = userEvent.setup(); + await user.click(await screen.findByRole('button', { name: 'Next members page' })); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Manage Bob Smith' })); + await user.click(await screen.findByRole('menuitem', { name: 'Remove from organization' })); + await user.click( + within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Remove from organization' }), + ); + await waitFor(() => expect(fapi.memberships.some(item => item.id === bob.id)).toBe(false)); + expect(await screen.findByText('Extra0')).toBeVisible(); + expect(screen.queryByText('Bob Smith')).toBeNull(); + }); + + it('locks role actions while updating and keeps a failed change available to retry', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await renderWithClerk(); + const user = userEvent.setup(); + const role = await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ }); + const update = holdRequests('post', '/v1/organizations/:organizationId/memberships/:userId'); + await user.click(role); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + await waitFor(() => expect(update.requests).toHaveLength(1)); + expect(role).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Manage Bob Smith' })).toBeDisabled(); + update.fail('role_rejected', 'Role rejected by server'); + expect(await screen.findByRole('alert')).toHaveTextContent('Role rejected by server'); + expect(screen.getByRole('alert')).toHaveAttribute('data-color', 'negative'); + const retry = serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await user.click(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })); + await user.click(await screen.findByRole('option', { name: 'Admin' })); + await waitFor(() => expect(retry.memberships.find(item => item.id === bob.id)?.role).toBe('org:admin')); + }); + + it('shows localized role names in the role list', async () => { + serve(['org:sys_memberships:read', 'org:sys_memberships:manage']); + await renderWithClerk( + + + , + ); + const user = userEvent.setup(); + await user.click(await screen.findByRole('combobox', { name: /^Change role for Bob Smith/ })); + expect(await screen.findByRole('option', { name: 'Administrateur' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Membre' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Admin' })).toBeNull(); + }); + + it('resets the search and rows when the active organization changes', async () => { + const other = fapiOrganization({ id: 'org_2', name: 'Other' }); + const first = fapiMembership(organization, { permissions: ['org:sys_memberships:read'] }); + const second = fapiMembership(other, { permissions: ['org:sys_memberships:read'] }); + const alice = fapiUser({ id: 'user_1', organization_memberships: [first, second] }); + const carol = fapiMembership(other, { + id: 'orgmem_carol', + public_user_data: { + user_id: 'user_3', + first_name: 'Carol', + last_name: 'Jones', + identifier: 'carol@example.com', + image_url: '', + has_image: false, + }, + }); + serveFapi({ + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: organization.id })]), + memberships: [first, bob, second, carol], + }); + const { clerk } = await renderWithClerk(); + await userEvent.setup().type(await screen.findByRole('searchbox', { name: 'Search members' }), 'Bob'); + expect(await screen.findByText('Bob Smith')).toBeVisible(); + await act(() => clerk.setActive({ organization: other.id })); + expect(await screen.findByText('Carol Jones')).toBeVisible(); + expect(screen.queryByText('Bob Smith')).toBeNull(); + expect(screen.getByRole('searchbox', { name: 'Search members' })).toHaveValue(''); + }); +}); 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 new file mode 100644 index 00000000000..97e3058c8fd --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.controller.ts @@ -0,0 +1,77 @@ +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) { + 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 }) => ({ + ...member, + roleLabel: roleNames[member.role] ?? member.roleLabel, + joinedAtLabel: formatDate(joinedAt, locale), + })), + roles: roles?.roles.map(role => ({ value: role.key, label: roleNames[role.key] ?? role.name })) ?? [], + pageSize: model.pageSize, + totalCount: model.totalCount, + page: model.page, + onPageChange: model.fetchPage, + isLoading: model.isLoading, + isFetching: model.isFetching, + isError: model.isError, + onRetry: model.retry, + hasRoleSetMigration: roles?.hasRoleSetMigration ?? false, + isChangingRole: roleAction.isPending, + roleError: roleAction.error, + onChangeRole: + model.changeRole && roles && !roles.hasRoleSetMigration + ? (id: string, role: string) => void roleAction.run(id, () => model.changeRole?.(id, role, roles)) + : undefined, + onRemove: model.remove, + }; +} + +export function useMembersTableSearchController() { + const [searchValue, setSearchValue] = useState(''); + const query = useDebouncedAsync(searchValue, value => Promise.resolve(value.trim()), { delayMs: 500 }); + return { searchValue, onSearchChange: setSearchValue, query: searchValue.trim() === '' ? '' : (query.data ?? '') }; +} diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts index 2deba14b749..9e0d285e4cc 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.messages.ts @@ -12,6 +12,10 @@ export const membersTableTabMessages = { noMembers: 'No members yet', noMembersDescription: 'Invite people to add them to this organization.', loading: 'Loading members', + loadError: 'Unable to load members', + retry: 'Try again', + roleChangeError: 'Unable to change this member’s role. Please try again.', + roleSetMigration: 'Roles cannot be changed while this organization’s role migration is in progress.', selectAll: 'Select all members on this page', select: 'Select {name}', manage: 'Manage {name}', 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 new file mode 100644 index 00000000000..377b65190f0 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.model.ts @@ -0,0 +1,117 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useOrganization, useSession } from '@clerk/shared/react'; +import type { OrganizationMembershipResource } from '@clerk/shared/types'; +import { useEffect, useRef } from 'react'; + +import type { MembersRoles, OrganizationProfileMember } from './members-table-tab.types'; + +const PAGE_SIZE = 10; +const READ_PERMISSION = 'org:sys_memberships:read'; +const MANAGE_PERMISSION = 'org:sys_memberships:manage'; + +export function useMembersTableAccessModel() { + const { isLoaded, organization } = useOrganization(); + const { isLoaded: isSessionLoaded, session } = useSession(); + if (!isLoaded || !isSessionLoaded) { + return { status: 'loading' as const }; + } + if (!organization || !session?.checkAuthorization({ permission: READ_PERMISSION })) { + return { status: 'hidden' as const }; + } + return { status: 'ready' as const, organizationId: organization.id, sessionId: session.id }; +} + +export function useMembersTableModel(query: string) { + const { session } = useSession(); + const canRead = session?.checkAuthorization({ permission: READ_PERMISSION }) ?? false; + const canManage = session?.checkAuthorization({ permission: MANAGE_PERMISSION }) ?? false; + const { organization, memberships } = useOrganization({ + memberships: canRead ? { pageSize: PAGE_SIZE, keepPreviousData: true, query: query || undefined } : undefined, + }); + const fetchPage = useRef(memberships?.fetchPage); + fetchPage.current = memberships?.fetchPage; + useEffect(() => { + fetchPage.current?.(1); + }, [query]); + const currentUserId = session?.user.id; + const rows = (memberships?.data ?? []) + .filter(member => member.organization.id === organization?.id) + .map(member => toMember(member, currentUserId)); + + const findManageable = (id: string) => { + const member = memberships?.data?.find(item => item.id === id && item.organization.id === organization?.id); + if ( + !canManage || + !member || + !member.publicUserData?.userId || + member.publicUserData.userId === currentUserId || + member.publicUserData.deprovisioned + ) { + throw new ClerkRuntimeError('This member cannot be managed.', { code: 'member_unavailable' }); + } + return member; + }; + + 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, + isLoading: memberships?.isLoading ?? true, + isFetching: memberships?.isFetching ?? false, + isError: memberships?.isError ?? false, + retry: () => memberships?.revalidate?.(), + fetchPage: (page: number) => memberships?.fetchPage?.(page), + changeRole: canManage + ? async (id: string, role: string, roles: MembersRoles) => { + if (roles.hasRoleSetMigration || !roles.roles.some(option => option.key === role)) { + throw new ClerkRuntimeError('This role is unavailable.', { code: 'role_unavailable' }); + } + await findManageable(id).update({ role }); + await memberships?.revalidate?.(); + } + : undefined, + remove: canManage + ? async (id: string) => { + const lastOnPage = (memberships?.data?.length ?? 0) === 1 && (memberships?.page ?? 1) > 1; + await findManageable(id).destroy(); + await memberships?.revalidate?.(); + if (lastOnPage) { + memberships?.fetchPage?.((memberships?.page ?? 1) - 1); + } + } + : undefined, + }; +} + +function toMember( + member: OrganizationMembershipResource, + currentUserId: string | undefined, +): Omit & { joinedAt: Date } { + const user = member.publicUserData; + const name = [user?.firstName, user?.lastName].filter(Boolean).join(' '); + return { + id: member.id, + name: name || user?.identifier || member.id, + email: user?.identifier ?? '', + imageUrl: user?.imageUrl, + joinedAt: member.createdAt, + role: member.role, + roleLabel: member.roleName, + isCurrentUser: Boolean(user?.userId && user.userId === currentUserId), + isDeprovisioned: user?.deprovisioned, + isBanned: user?.banned, + }; +} diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts index 6b3ee08455e..85f3d871191 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.styles.ts @@ -3,5 +3,6 @@ import * as stylex from '@stylexjs/stylex'; import { space } from '../../tokens.stylex'; export const styles = stylex.create({ + deprovisionedRow: { opacity: 0.5 }, name: { gap: space['2'], alignItems: 'center', display: 'flex' }, }); diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts index 45796c17521..8276ce9ee9b 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.types.ts @@ -1,5 +1,13 @@ +import type { RoleResource } from '@clerk/shared/types'; import type { MouseEventHandler } from 'react'; +export type MemberRole = Pick; + +export interface MembersRoles { + roles: MemberRole[]; + hasRoleSetMigration: boolean; +} + export interface OrganizationProfileMember { id: string; name: string; @@ -13,27 +21,24 @@ export interface OrganizationProfileMember { isBanned?: boolean; } -export interface MembersTableSort { - column: 'name' | 'joinedAt' | 'role'; - direction: 'ascending' | 'descending'; -} - export interface MembersTableTabViewProps { members: OrganizationProfileMember[]; roles: { value: string; label: string }[]; totalCount: number; page: number; - pageSize?: number; + pageSize: number; searchValue: string; isLoading: boolean; isFetching?: boolean; + isError?: boolean; + onRetry?: () => void; + hasRoleSetMigration?: boolean; + isChangingRole?: boolean; + roleError?: string; onPageChange: (page: number) => void; - onPageSizeChange?: (pageSize: number) => void; onSearchChange: (value: string) => void; onInvite?: MouseEventHandler; onRemove?: (id: string) => void | Promise; onChangeRole?: (id: string, role: string) => void; onBulkAction?: (ids: string[]) => void; - sort?: MembersTableSort | null; - onSortChange?: (sort: MembersTableSort | null) => void; } diff --git a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx index 15bf9fd51db..648241b9523 100644 --- a/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/members-table-tab.view.tsx @@ -1,10 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { useEffect, useMemo, useRef } from 'react'; +import { type Ref, useMemo, useRef } from 'react'; import { Confirmation } from '../../blocks/confirmation'; import { ActionMenu } from '../../components/action-menu'; import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; +import { Banner } from '../../components/banner'; import { Button } from '../../components/button'; import { EmptyState } from '../../components/empty-state'; import { Icon } from '../../components/icon'; @@ -33,16 +34,18 @@ export function MembersTableTabView({ onChangeRole, totalCount, page, - pageSize = 10, + pageSize, searchValue, onSearchChange, onPageChange, - onPageSizeChange, onBulkAction, - sort, - onSortChange, isLoading, isFetching = false, + isError = false, + onRetry, + hasRoleSetMigration = false, + isChangingRole = false, + roleError, }: MembersTableTabViewProps) { const m = useMessages('membersTableTab'); const searchInput = useRef(null); @@ -53,28 +56,18 @@ export function MembersTableTabView({ fallback: () => inviteButton.current ?? searchInput.current, }); const removeDialog = useMemo(() => Confirmation.createHandle(), []); - const { table, sortHeader, pagination } = useServerDataTable({ + const { table, pagination } = useServerDataTable({ data: members, totalCount, getRowId, isRowSelectable: canManageMember, - sortableColumns: ['name', 'joinedAt', 'role'], - sort, - onSortChange, + sortableColumns: [], page, pageSize, onPageChange, - onPageSizeChange, searchValue, onSearchChange, }); - const resetSelection = useRef(table.setRowSelection); - useEffect(() => { - resetSelection.current = table.setRowSelection; - }, [table.setRowSelection]); - useEffect(() => { - resetSelection.current({}); - }, [page, pageSize, searchValue, sort?.column, sort?.direction]); const columnCount = 3 + Number(Boolean(onRemove)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); return ( @@ -98,6 +91,10 @@ export function MembersTableTabView({ ) : null} + ) : null} - {m.name} - {m.joinedAt} - {m.role} + {m.name} + {m.joinedAt} + {m.role} {onRemove ? ( {m.actions} @@ -122,117 +119,20 @@ export function MembersTableTabView({ ) : null} - - {isLoading ? ( - - - - {m.loading} - - - ) : table.rows.length === 0 ? ( - - - - {query ? m.empty : m.noMembers} - - {query ? fill(m.emptyDescription, { query }) : m.noMembersDescription} - - - - ) : ( - table.rows.map(row => { - const member = row.original; - return ( - - {onBulkAction ? ( - - ) : null} - - - - - {member.imageUrl ? ( - - ) : null} - - - - - - {member.name} - {member.isCurrentUser ? ( - {m.you} - ) : member.isDeprovisioned ? ( - {m.deprovisioned} - ) : member.isBanned ? ( - {m.banned} - ) : null} - - {member.email} - - - - {member.joinedAtLabel} - - {onChangeRole && !member.isDeprovisioned ? ( - { - if (value) { - onChangeRole(member.id, value); - } - }} - > - - - - ) : ( - member.roleLabel - )} - - {onRemove ? ( - - {!canManageMember(member) ? null : ( - removeDialog.open(member), - }, - ]} - /> - )} - - ) : null} - - ); - }) - )} - + removeDialog.open(member) : undefined} + registerTrigger={removalFocus.registerTrigger} + /> {pagination ? ( removalFocus.remove(member.id)} + onConfirm={member => { + table.setRowSelection({}); + return removalFocus.remove(member.id); + }} errorFallback={m.removeError} finalFocus={removalFocus.finalFocus} /> @@ -259,3 +162,223 @@ export function MembersTableTabView({ ); } + +function MembersTableNotices({ + hasRoleSetMigration, + roleError, +}: Pick) { + const m = useMessages('membersTableTab'); + return ( + <> + {hasRoleSetMigration ? ( + + {m.roleSetMigration} + + ) : null} + {roleError ? ( + + {roleError} + + ) : null} + + ); +} + +function MembersTableBody({ + rows, + columnCount, + query, + isLoading, + isError, + onRetry, + roles, + hasBulkAction, + onChangeRole, + isChangingRole, + onRemoveClick, + registerTrigger, +}: { + rows: Array<{ original: OrganizationProfileMember; getIsSelected: () => boolean; toggleSelected: () => void }>; + columnCount: number; + query: string; + isLoading: boolean; + isError: boolean; + onRetry?: () => void; + roles: MembersTableTabViewProps['roles']; + hasBulkAction: boolean; + onChangeRole?: MembersTableTabViewProps['onChangeRole']; + isChangingRole: boolean; + onRemoveClick?: (member: OrganizationProfileMember) => void; + registerTrigger: (id: string) => Ref; +}) { + const m = useMessages('membersTableTab'); + if (isLoading) { + return ( + + + + + {m.loading} + + + + ); + } + if (isError) { + return ( + + + + + {m.loadError} + {onRetry ? ( + + + + ) : null} + + + + ); + } + if (rows.length === 0) { + return ( + + + + + {query ? m.empty : m.noMembers} + + {query ? fill(m.emptyDescription, { query }) : m.noMembersDescription} + + + + + ); + } + return ( + + {rows.map(row => ( + onRemoveClick(row.original) : undefined} + triggerRef={registerTrigger(row.original.id)} + /> + ))} + + ); +} + +function MemberRow({ + member, + roles, + selected, + onToggleSelected, + onChangeRole, + isChangingRole, + onRemoveClick, + triggerRef, +}: { + member: OrganizationProfileMember; + roles: MembersTableTabViewProps['roles']; + selected: boolean; + onToggleSelected?: () => void; + onChangeRole?: MembersTableTabViewProps['onChangeRole']; + isChangingRole: boolean; + onRemoveClick?: () => void; + triggerRef: Ref; +}) { + const m = useMessages('membersTableTab'); + const manageable = canManageMember(member); + return ( + + {onToggleSelected ? ( + + ) : null} + + + + + {member.imageUrl ? ( + + ) : null} + + + + + + {member.name} + {member.isCurrentUser ? ( + {m.you} + ) : member.isDeprovisioned ? ( + {m.deprovisioned} + ) : member.isBanned ? ( + {m.banned} + ) : null} + + {member.email} + + + + {member.joinedAtLabel} + + {onChangeRole && !member.isDeprovisioned && roles.some(role => role.value === member.role) ? ( + value && onChangeRole(member.id, value)} + > + + + + ) : ( + member.roleLabel + )} + + {onRemoveClick ? ( + + {manageable ? ( + + ) : null} + + ) : null} + + ); +} 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 new file mode 100644 index 00000000000..d3e223779c4 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +import { useMembersTableController, useMembersTableSearchController } from './members-table-tab.controller'; +import { useMembersTableAccessModel, useMembersTableModel } from './members-table-tab.model'; +import { OrganizationProfileMembersPanelView } from './organization-profile-members-panel.view'; + +export function OrganizationProfileMembersPanel({ fallback }: { fallback?: ReactNode }) { + const access = useMembersTableAccessModel(); + if (access.status === 'loading') { + return fallback ?? null; + } + if (access.status === 'hidden') { + return null; + } + return ; +} + +function ConnectedMembersPanel() { + const search = useMembersTableSearchController(); + const model = useMembersTableModel(search.query); + const members = useMembersTableController(model); + return ; +} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 03481fa7476..fab72af5a14 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -32,6 +32,7 @@ export const errorMessages: { readonly generic: string } & Readonly> = { + admin: 'Admin', + basic_member: 'Member', + guest_member: 'Guest', +}; diff --git a/packages/mosaic/src/utils/format-date.ts b/packages/mosaic/src/utils/format-date.ts new file mode 100644 index 00000000000..eb13db7acee --- /dev/null +++ b/packages/mosaic/src/utils/format-date.ts @@ -0,0 +1,3 @@ +export function formatDate(date: Date, locale: string): string { + return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric' }).format(date); +} diff --git a/packages/swingset/src/app/(clerk)/live/organization-members/page.tsx b/packages/swingset/src/app/(clerk)/live/organization-members/page.tsx new file mode 100644 index 00000000000..f9105057234 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/organization-members/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { OrganizationProfileMembersPanel } from '@clerk/mosaic/features/organization-profile/organization-profile-members-panel'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useOrganization, useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function OrganizationMembersLivePage() { + const { isLoaded, isSignedIn } = useUser(); + const { organization } = useOrganization(); + + return ( + +
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + and pick an active organization. +

+ ) : null} + {isLoaded && isSignedIn && !organization ? ( +

Pick an active organization to see its members.

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 2e291d764aa..92f81ba5105 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -19,6 +19,7 @@ const liveRoutes = new Map([ ['/user-profile/user-profile-enterprise-accounts-section', '/live/enterprise-accounts'], ['/user-profile/user-profile-danger-section', '/live/user-danger'], ['/organization-profile/organization-profile-general-panel', '/live/organization-general'], + ['/organization-profile/organization-profile-members-panel', '/live/organization-members'], ['/organization-profile/organization-profile-danger-section', '/live/organization-danger'], ['/user-profile/user-profile-passkeys-section', '/live/passkeys'], ['/user-profile/user-profile-web3wallets-section', '/live/web3-wallets'], diff --git a/packages/swingset/src/stories/fixtures/members-table-tab.ts b/packages/swingset/src/stories/fixtures/members-table-tab.ts index 4f732eaf059..5a457ba206b 100644 --- a/packages/swingset/src/stories/fixtures/members-table-tab.ts +++ b/packages/swingset/src/stories/fixtures/members-table-tab.ts @@ -1,5 +1,4 @@ import type { - MembersTableSort, MembersTableTabViewProps, OrganizationProfileMember, } from '@clerk/mosaic/features/organization-profile/members-table-tab.types'; @@ -48,20 +47,12 @@ export function useMembersTableFixture({ proposed = false, empty = false } = {}) ); const [items, setItems] = useState(empty ? [] : seed); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const pageSize = 10; const [searchValue, setSearchValue] = useState(''); - const [sort, setSort] = useState(null); const query = searchValue.trim().toLowerCase(); const filtered = items.filter(item => `${item.name} ${item.email}`.toLowerCase().includes(query)); - const sorted = sort - ? [...filtered].sort((a, b) => { - const comparison = - sort.column === 'joinedAt' ? a.joinedAt - b.joinedAt : a[sort.column].localeCompare(b[sort.column]); - return sort.direction === 'ascending' ? comparison : -comparison; - }) - : filtered; - const currentPage = Math.min(page, Math.max(1, Math.ceil(sorted.length / pageSize))); - const members: OrganizationProfileMember[] = sorted + const currentPage = Math.min(page, Math.max(1, Math.ceil(filtered.length / pageSize))); + const members: OrganizationProfileMember[] = filtered .slice((currentPage - 1) * pageSize, currentPage * pageSize) .map(item => ({ ...item, @@ -76,7 +67,7 @@ export function useMembersTableFixture({ proposed = false, empty = false } = {}) return { members, roles, - totalCount: sorted.length, + totalCount: filtered.length, page: currentPage, pageSize, searchValue, @@ -86,16 +77,8 @@ export function useMembersTableFixture({ proposed = false, empty = false } = {}) setPage(1); }, onPageChange: setPage, - onPageSizeChange: setPageSize, onChangeRole: (id, role) => setItems(current => current.map(item => (item.id === id ? { ...item, role } : item))), onRemove: id => setItems(current => current.filter(item => item.id !== id)), onBulkAction: proposed ? () => undefined : undefined, - sort, - onSortChange: proposed - ? next => { - setSort(next); - setPage(1); - } - : undefined, }; }