Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e56c7a1
feat(shared): add internal organization roles hook
austincalvelage Oct 8, 2026
377d22d
feat(mosaic): connect organization members list
austincalvelage Oct 8, 2026
5917469
feat(swingset): add live organization members page
austincalvelage Oct 8, 2026
e70761f
refactor(mosaic): keep member roles fetching local
austincalvelage Oct 8, 2026
83ba72f
test(mosaic): await members role controls before interaction
austincalvelage Oct 9, 2026
dafff8e
fix(mosaic): keep member actions mounted during role changes and loca…
austincalvelage Oct 9, 2026
37744a2
refactor(mosaic): drop members table sort and page-size controls the …
austincalvelage Oct 9, 2026
e1f8bce
fix(mosaic): omit empty search query from member list requests
austincalvelage Oct 9, 2026
a3c06e4
fix(mosaic): load member roles only for managers and show table notic…
alexcarpenter Oct 9, 2026
80a0a59
fix(mosaic): render member notices as banners
austincalvelage Oct 9, 2026
7a23d7c
fix(mosaic): defer member banners until actions are supported
austincalvelage Oct 9, 2026
e8a49c5
fix(mosaic): show member fetch errors inside the table
austincalvelage Oct 9, 2026
64f9208
fix(mosaic): align member notices with legacy behavior
austincalvelage Oct 9, 2026
db67960
fix(mosaic): show deprovisioned members as inactive rows
austincalvelage Oct 9, 2026
6dd7283
fix(mosaic): apply a cleared member search immediately
austincalvelage Oct 9, 2026
5093999
refactor(mosaic): clear member selection on removal instead of prop e…
austincalvelage Oct 9, 2026
f62e5de
refactor(mosaic): move formatDate to shared utils
austincalvelage Oct 9, 2026
574274d
Merge branch 'main' into austin/members-tab-wire-up
austincalvelage Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/quiet-members-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
7 changes: 7 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
OrganizationMembershipJSON,
OrganizationSuggestionJSON,
PhoneNumberJSON,
RoleJSON,
SessionJSON,
SessionWithActivitiesJSON,
UserJSON,
Expand All @@ -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 {
Expand Down Expand Up @@ -54,6 +56,8 @@ export interface FakeFapiState {
client: ClientJSON;
country?: string;
memberships: OrganizationMembershipJSON[];
roles: RoleJSON[];
hasRoleSetMigration: boolean;
invitations: UserOrganizationInvitationJSON[];
suggestions: OrganizationSuggestionJSON[];
apiKeys: ApiKeyJSON[];
Expand Down Expand Up @@ -286,6 +290,8 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
environment: fapiEnvironment(),
client: fapiClient(),
memberships: [],
roles: [],
hasRoleSetMigration: false,
invitations: [],
suggestions: [],
apiKeys: [],
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}),
];
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MembersRoles>) {
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<MembersRoles>();
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<MembersRoles>();
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<MembersRoles>();
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();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function propsFor(overrides: Partial<MembersTableTabViewProps> = {}): MembersTab
],
totalCount: 2,
page: 1,
pageSize: 10,
searchValue: '',
isLoading: false,
onSearchChange: vi.fn(),
Expand Down Expand Up @@ -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<Partial<MembersTableTabViewProps>>([
{ 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(
<MosaicProvider>
<MembersTableTabView
{...props}
sort={{ column: 'name', direction: 'ascending' }}
isFetching
/>
</MosaicProvider>,
);
expect(screen.getByRole('checkbox', { name: 'Select Grace Hopper' })).toBeChecked();
rerender(
<MosaicProvider>
<MembersTableTabView
{...props}
{...change}
/>
</MosaicProvider>,
);
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];
Expand Down Expand Up @@ -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() });
Expand Down
Loading
Loading