);
}
-function memberships() {
- return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]');
-}
-
-function invitations() {
- return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]');
-}
-
-function activeOrganization() {
- return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null');
-}
-
describe('useUserButtonController', () => {
- it('is loading until the user, session, and organization are all loaded', () => {
- isUserLoaded = false;
- const { rerender } = render();
- expect(screen.getByTestId('status')).toHaveTextContent('loading');
-
- isUserLoaded = true;
- isSessionLoaded = false;
- rerender();
+ it('passes loading and hidden through until the model is ready', () => {
+ const { rerender } = render();
expect(screen.getByTestId('status')).toHaveTextContent('loading');
- isSessionLoaded = true;
- isOrgLoaded = false;
- rerender();
- expect(screen.getByTestId('status')).toHaveTextContent('loading');
- });
-
- // Every instance-level answer the surface needs — organizations, single-session, forced
- // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready
- // without it would mean guessing at all three and rearranging once it lands.
- it('is loading until the environment has hydrated', () => {
- environmentHydrated = false;
- const { rerender } = render();
- expect(screen.getByTestId('status')).toHaveTextContent('loading');
-
- environmentHydrated = true;
- rerender();
- expect(screen.getByTestId('status')).toHaveTextContent('ready');
- });
-
- it('reports whether the instance has organizations at all', () => {
- render();
- expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true');
-
- cleanup();
- organizationsEnabled = false;
- render();
- expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false');
- });
-
- it('is hidden when loaded but there is no active user', () => {
- user = null;
- render();
+ rerender();
expect(screen.getByTestId('status')).toHaveTextContent('hidden');
- });
- it('maps the active account and prefers first+last > username > email for the name', () => {
- const { rerender } = render();
+ rerender();
expect(screen.getByTestId('status')).toHaveTextContent('ready');
- expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith');
- expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1');
-
- user = { ...(user as FakeUser), firstName: null, lastName: null };
- rerender();
- expect(screen.getByTestId('active-name')).toHaveTextContent('alice');
-
- user = { ...user, username: null };
- rerender();
- expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com');
});
- it('identifies the active account by username, then email, then phone, then wallet', () => {
- const { rerender } = render();
- expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice');
-
- user = { ...(user as FakeUser), username: null };
- rerender();
- expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com');
-
- user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } };
- rerender();
- expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100');
+ it('forces user mode when organizations are disabled, whatever mode was asked for', () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('user');
- user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } };
- rerender();
- expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc');
+ rerender(
+ ,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('organization');
});
- it('describes the active organization whole, and null in personal mode', () => {
- const { rerender } = render();
- expect(activeOrganization()).toMatchObject({
- kind: 'membership',
- organizationId: 'org_1',
- name: 'Acme',
- imageUrl: 'https://img/acme',
- membersCount: 3,
- });
+ it('runs a model action through the machine and keys the affordance', async () => {
+ const onSelectOrganization = vi.fn(() => Promise.resolve());
+ render();
- organization = null;
- rerender();
- expect(activeOrganization()).toBeNull();
- });
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('select-org'));
- it('names the active organization from the organization itself, not the membership list', () => {
- userMemberships = list([], 0, false, true);
- render();
+ expect(onSelectOrganization).toHaveBeenCalledWith('org_1');
+ await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1'));
- expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' });
+ await act(async () => {
+ await tick();
+ });
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
+ expect(screen.getByTestId('pending')).toHaveTextContent('');
});
- it('reports the organization list as loading until every one of its three parts has landed', () => {
- const { rerender } = render();
- expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false');
+ it('closes immediately on a hand-off and leaves the model action to run', () => {
+ const onManageAccount = vi.fn();
+ render();
- userSuggestions = list([], 0, false, true);
- rerender();
- expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true');
- });
-
- it('derives hasOrganizations from the membership count, not the array length', () => {
- userMemberships = list([membership('org_1', 'Acme', 3)], 0);
- const { rerender } = render();
- expect(screen.getByTestId('has-orgs')).toHaveTextContent('false');
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('manage-account'));
- userMemberships = list([], 5);
- rerender();
- expect(screen.getByTestId('has-orgs')).toHaveTextContent('true');
+ expect(onManageAccount).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- // Waiting on the list would open a workspace section under every personal-only account, then
- // take it away again.
- it('answers hasOrganizations from the user resource before any list has loaded', () => {
- userMemberships = list([], 0, false, true);
- user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] };
- render();
+ it('closes the popover before a custom menu action runs', () => {
+ const onClick = vi.fn();
+ render(
+ ,
+ );
- expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true');
- expect(screen.getByTestId('has-orgs')).toHaveTextContent('true');
- });
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('Documentation'));
- it('carries only sessions in additionalSessions, excluding the active one', () => {
- render();
- expect(screen.getByTestId('additional')).toHaveTextContent('sess_2');
- expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1');
+ expect(onClick).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => {
- render();
-
- const rows = memberships();
- expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 });
+ it('starts closed and opens and closes', () => {
+ render();
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
- const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]');
- expect(suggestions[0]).toMatchObject({
- kind: 'suggestion',
- id: 'sug_1',
- organizationId: 'org_2',
- name: 'Beta',
- status: 'pending',
- });
-
- expect(invitations()[0]).toMatchObject({
- kind: 'invitation',
- id: 'inv_1',
- organizationId: 'org_3',
- organizationName: 'Gamma',
- status: 'pending',
- });
- });
-
- it('lists invitations still open to the account, dropping the revoked and expired ones', () => {
- userInvitations = list(
- [
- acceptable('inv_1', 'org_3', 'Gamma'),
- acceptable('inv_2', 'org_4', 'Delta', 'accepted'),
- acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'),
- acceptable('inv_4', 'org_6', 'Zeta', 'expired'),
- ],
- 4,
- );
- render();
+ fireEvent.click(screen.getByText('open'));
+ expect(screen.getByTestId('open')).toHaveTextContent('true');
- expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']);
+ fireEvent.click(screen.getByText('close'));
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- it('reports more to page in when any of the three lists has a next page', () => {
- const { rerender } = render();
- expect(screen.getByTestId('has-more')).toHaveTextContent('false');
- expect(screen.getByTestId('paging-ref')).toHaveTextContent('true');
+ it('holds the popup open when an action fails, even one that would have closed it', async () => {
+ const onSelectOrganization = vi.fn(() => Promise.reject(new Error('cannot switch')));
+ render();
- userSuggestions = list([], 0, true);
- rerender();
- expect(screen.getByTestId('has-more')).toHaveTextContent('true');
- });
-
- it('offers inviting members only with the manage-memberships permission', () => {
- const { rerender } = render();
- expect(screen.getByTestId('can-invite')).toHaveTextContent('true');
- expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' });
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('select-org'));
- checkAuthorization.mockReturnValue(false);
- rerender();
- expect(screen.getByTestId('can-invite')).toHaveTextContent('false');
+ await act(async () => {
+ await tick();
+ });
+ expect(screen.getByTestId('open')).toHaveTextContent('true');
+ expect(screen.getByTestId('pending')).toHaveTextContent('');
});
- it('selects an organization via setActive, with no redirect unless one is configured', () => {
- const { rerender } = render();
+ it('lets the row be clicked again after a failure', async () => {
+ const onSelectOrganization = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(undefined);
+ render();
+ fireEvent.click(screen.getByText('open'));
fireEvent.click(screen.getByText('select-org'));
- expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
-
- rerender();
- fireEvent.click(screen.getByText('select-org'));
- expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' });
+ await act(async () => {
+ await tick();
+ });
- rerender( `/o/${org.name}`} />);
fireEvent.click(screen.getByText('select-org'));
- expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' });
- });
-
- // `null` is Clerk's own name for the personal workspace, and there is no organization for
- // `afterSelectOrganizationUrl` to resolve against.
- it('selects the personal workspace by clearing the active organization', () => {
- render();
+ expect(onSelectOrganization).toHaveBeenCalledTimes(2);
- fireEvent.click(screen.getByText('select-personal'));
- expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined });
- });
-
- it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => {
- const { rerender } = render();
-
- fireEvent.click(screen.getByText('select-personal'));
- expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' });
-
- rerender( `/u/${u.username}`} />);
- fireEvent.click(screen.getByText('select-personal'));
- expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' });
+ await act(async () => {
+ await tick();
+ });
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- // The two are configured apart, so routing the personal workspace leaves the organizations alone.
- it('keeps the personal redirect off the organizations', () => {
- render();
+ it('refuses a second action while one is in flight', async () => {
+ const pending = deferred();
+ const onSelectOrganization = vi.fn(() => pending.promise);
+ const onSwitchSession = vi.fn(() => Promise.resolve());
+ render();
+ fireEvent.click(screen.getByText('open'));
fireEvent.click(screen.getByText('select-org'));
- expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
- });
-
- // An instance that requires an organization has no personal workspace: clerk-js refuses
- // `setActive({ organization: null })` outright there, so offering the switch would offer nothing.
- it('reports no personal workspace where the instance forces an organization', () => {
- const { rerender } = render();
- expect(screen.getByTestId('hide-personal')).toHaveTextContent('false');
-
- forceOrganizationSelection = true;
- rerender();
- expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
- });
-
- // An app whose organizations are the whole product withholds it itself. The instance setting is
- // the other way in, and neither one can be talked out of it by the other.
- it('lets the app withhold the personal workspace on an instance that allows one', () => {
- const { rerender } = render();
- expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
-
- forceOrganizationSelection = true;
- rerender();
- expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
- });
-
- it('switches sessions and routes each sign out to the URL that matches what is left', () => {
- const { rerender } = render();
+ await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('select-org:org_1'));
fireEvent.click(screen.getByText('switch'));
- expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' }));
-
- // Another account stays signed in, so this is a single sign out, not a full one.
- fireEvent.click(screen.getByText('sign-out-one'));
- expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' });
-
- fireEvent.click(screen.getByText('sign-out-all'));
- expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' });
-
- signedInSessions = signedInSessions.slice(0, 1);
- rerender();
- fireEvent.click(screen.getByText('sign-out-one'));
- expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' });
- });
-
- // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and
- // strands the account, so the switch hands `setActive` a callback that answers both cases.
- it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => {
- render();
- fireEvent.click(screen.getByText('switch'));
-
- expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) });
- const navigateOnSetActive = setActive.mock.calls[0][0].navigate;
- const decorateUrl = vi.fn((url: string) => url);
+ expect(onSwitchSession).not.toHaveBeenCalled();
await act(async () => {
- await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl });
+ pending.resolve(undefined);
+ await tick();
});
- expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in'));
- expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization'));
-
- await act(async () => {
- await navigateOnSetActive({ session: { currentTask: null }, decorateUrl });
- });
- expect(navigate).toHaveBeenCalledWith('/after-switch');
- // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it.
- expect(decorateUrl).toHaveBeenCalledWith('/after-switch');
- });
-
- // An instance can restrict who may open an organization, and a user at their creation limit is
- // restricted the same way. Offering the action anyway lands them on a page that turns them away.
- it('drops create-organization for a user who cannot open one', () => {
- const { rerender } = render();
- expect(screen.getByTestId('can-create-org')).toHaveTextContent('true');
-
- user = { ...(user as FakeUser), createOrganizationEnabled: false };
- rerender();
- expect(screen.getByTestId('can-create-org')).toHaveTextContent('false');
- });
-
- it('drops sign-out-all and add-account in single-session mode', () => {
- singleSessionMode = true;
- render();
- expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false');
- expect(screen.getByTestId('can-add-account')).toHaveTextContent('false');
});
- // An instance that has paid the branding off carries none of it, and the environment is the only
- // place that answer lives.
- it('carries the branding the instance is on, not the branding everyone gets', () => {
- render();
- expect(screen.getByTestId('branded')).toHaveTextContent('true');
+ it('refuses an action while the popup is closed', () => {
+ const onSelectOrganization = vi.fn(() => Promise.resolve());
+ render();
- cleanup();
- branded = false;
- render();
- expect(screen.getByTestId('branded')).toHaveTextContent('false');
- });
-
- // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic
- // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays.
- it('opens the profile modals for manage-account and manage-org', () => {
- render();
-
- fireEvent.click(screen.getByText('manage-account'));
- expect(openUserProfile).toHaveBeenCalled();
-
- fireEvent.click(screen.getByText('manage-org'));
- expect(openOrganizationProfile).toHaveBeenCalled();
+ fireEvent.click(screen.getByText('select-org'));
- expect(navigate).not.toHaveBeenCalled();
+ expect(onSelectOrganization).not.toHaveBeenCalled();
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- // An app that mounts the button inside its own dialog or popover puts a portal root around it, and
- // the modal has to land there too or it renders behind the surface that opened it.
- it('opens the profile modals into the portal root the app configured', () => {
- render();
+ it('abandons an action dismissed mid-flight rather than reopening on its result', async () => {
+ const pending = deferred();
+ const onSwitchSession = vi.fn(() => pending.promise);
+ render();
- fireEvent.click(screen.getByText('manage-account'));
- expect(openUserProfile).toHaveBeenCalledWith({ getContainer });
-
- fireEvent.click(screen.getByText('manage-org'));
- expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer });
- });
-
- // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass
- // alongside it. The two are resolved apart, so routing one profile leaves the other a modal.
- it('navigates to a profile URL when one is given, and only for that profile', () => {
- render();
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('switch'));
+ await waitFor(() => expect(screen.getByTestId('pending')).toHaveTextContent('switch:sess_2'));
- fireEvent.click(screen.getByText('manage-account'));
- expect(navigate).toHaveBeenCalledWith('/account');
- expect(openUserProfile).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByText('close'));
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
- fireEvent.click(screen.getByText('manage-org'));
- expect(openOrganizationProfile).toHaveBeenCalled();
+ await act(async () => {
+ pending.resolve(undefined);
+ await tick();
+ });
+ expect(screen.getByTestId('open')).toHaveTextContent('false');
});
- it('navigates to an organization profile URL when one is given', () => {
- render();
-
- fireEvent.click(screen.getByText('manage-org'));
+ it('holds the surface on the model the action started from until it settles', async () => {
+ const pending = deferred();
+ const onSwitchSession = vi.fn(() => pending.promise);
+ const { rerender } = render();
- expect(navigate).toHaveBeenCalledWith('/settings');
- expect(openOrganizationProfile).not.toHaveBeenCalled();
- });
+ fireEvent.click(screen.getByText('open'));
+ fireEvent.click(screen.getByText('switch'));
- // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept,
- // so passing both has to resolve the same as passing the URL alone.
- it('accepts an explicit navigation mode alongside a URL', () => {
- render(
+ rerender(
,
);
- fireEvent.click(screen.getByText('manage-org'));
-
- expect(navigate).toHaveBeenCalledWith('/settings');
- expect(openOrganizationProfile).not.toHaveBeenCalled();
- });
-
- // Invite opens its own modal rather than following manage-org: there is no invite page to route
- // to, so an app that routes organization management to its own page still gets the form here.
- it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => {
- render();
-
- fireEvent.click(screen.getByText('invite-members'));
-
- expect(openInviteMembers).toHaveBeenCalledWith({ getContainer });
- expect(navigate).not.toHaveBeenCalled();
- });
-
- // Creating an organization resolves like the two profiles do: a modal unless a URL routes
- // instead. Adding an account always leaves, since signing in cannot happen inside the popover.
- it('opens the create-organization modal into the portal root, and navigates for add-account', () => {
- render();
-
- fireEvent.click(screen.getByText('create-org'));
- expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer });
- expect(navigate).not.toHaveBeenCalled();
-
- fireEvent.click(screen.getByText('add-account'));
- expect(navigate).toHaveBeenCalledWith('/sign-in');
- });
-
- it('navigates to a create-organization URL when one is given', () => {
- render();
-
- fireEvent.click(screen.getByText('create-org'));
-
- expect(navigate).toHaveBeenCalledWith('/new-org');
- expect(openCreateOrganization).not.toHaveBeenCalled();
- });
-
- // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit
- // `navigation` asks for.
- it('falls back to the clerk create-organization URL for an explicit navigation mode', () => {
- render();
-
- fireEvent.click(screen.getByText('create-org'));
+ expect(screen.getByTestId('active-org')).toHaveTextContent('');
+ expect(screen.getByTestId('open')).toHaveTextContent('true');
- expect(navigate).toHaveBeenCalledWith('/create-org');
- expect(openCreateOrganization).not.toHaveBeenCalled();
- });
-
- it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => {
- render();
-
- // Accepting an invitation joins the organization, so the membership list is stale too.
- const invitation = userInvitations.data[0] as ReturnType;
await act(async () => {
- fireEvent.click(screen.getByText('accept-invitation'));
+ pending.resolve(undefined);
+ await tick();
});
- expect(invitation.accept).toHaveBeenCalledTimes(1);
- expect(userInvitations.revalidate).toHaveBeenCalledTimes(1);
- expect(userMemberships.revalidate).toHaveBeenCalledTimes(1);
- // A suggestion only files a request an admin has yet to approve, so nothing has been joined.
- const suggestion = userSuggestions.data[0] as ReturnType;
- await act(async () => {
- fireEvent.click(screen.getByText('accept-suggestion'));
- });
- expect(suggestion.accept).toHaveBeenCalledTimes(1);
- expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1);
- expect(userMemberships.revalidate).toHaveBeenCalledTimes(1);
+ expect(screen.getByTestId('active-org')).toHaveTextContent('org_1');
+ expect(screen.getByTestId('open')).toHaveTextContent('true');
});
});
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
new file mode 100644
index 00000000000..0e38e876e03
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
@@ -0,0 +1,592 @@
+import type * as SharedReact from '@clerk/shared/react';
+import type { CustomPage } from '@clerk/shared/types';
+import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { MosaicProvider } from '../../MosaicProvider';
+import type { UserButtonProps } from '../user-button';
+import { UserButton } from '../user-button';
+
+// End-to-end wiring test for the connected UserButton: it renders the real view through the real
+// model and controller against a mocked Clerk, then drives the real popover DOM. Unlike the model
+// test (model -> Clerk), this proves the layers compose, including what closes the popover:
+// selecting a workspace closes on success in the machine, and anything that opens a modal or
+// navigates closes before it hands off.
+
+interface FakeUser {
+ id: string;
+ firstName: string | null;
+ lastName: string | null;
+ username: string | null;
+ primaryEmailAddress: { emailAddress: string } | null;
+ imageUrl: string;
+ organizationMemberships: unknown[];
+ createOrganizationEnabled: boolean;
+}
+
+interface FakeSession {
+ id: string;
+ user: FakeUser;
+}
+
+interface FakeList {
+ data: unknown[];
+ count: number;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ revalidate: ReturnType;
+}
+
+let isUserLoaded: boolean;
+let isSessionLoaded: boolean;
+let isOrgLoaded: boolean;
+let user: FakeUser | null;
+let session: { id: string; checkAuthorization: ReturnType } | null;
+let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null;
+let userMemberships: FakeList;
+let userInvitations: FakeList;
+let userSuggestions: FakeList;
+let signedInSessions: FakeSession[];
+let pagingRef: ReturnType;
+let singleSessionMode: boolean;
+let organizationsEnabled: boolean;
+
+let setActive: ReturnType;
+let signOut: ReturnType;
+let navigate: ReturnType;
+let openUserProfile: ReturnType;
+let openOrganizationProfile: ReturnType;
+let openCreateOrganization: ReturnType;
+let openInviteMembers: ReturnType;
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUser: () => ({ isLoaded: isUserLoaded, user }),
+ useSession: () => ({ isLoaded: isSessionLoaded, session }),
+ useOrganization: () => ({ isLoaded: isOrgLoaded, organization }),
+ useClerk: () => ({
+ navigate,
+ setActive,
+ signOut,
+ openUserProfile,
+ openOrganizationProfile,
+ openCreateOrganization,
+ openInviteMembers,
+ buildUserProfileUrl: () => '/user-profile',
+ buildOrganizationProfileUrl: () => '/org-profile',
+ buildCreateOrganizationUrl: () => '/create-org',
+ buildSignInUrl: () => '/sign-in',
+ buildAfterSignOutUrl: () => '/after-sign-out',
+ buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out',
+ client: { signedInSessions },
+ __internal_environment: {
+ displayConfig: { afterSwitchSessionUrl: '/after-switch' },
+ authConfig: { singleSessionMode },
+ organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection: false },
+ commerceSettings: { billing: { user: { enabled: false } } },
+ apiKeysSettings: { user_api_keys_enabled: false },
+ },
+ }),
+ };
+});
+
+// Stubbed at the same seam as the controller test: the in-view helper is the controller's whole
+// fetch boundary, so `ref` doubles as the assertion that the paging sentinel mounted.
+vi.mock('../../../hooks/useOrganizationListInView', () => ({
+ useOrganizationListInView: () => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef }),
+}));
+
+function acceptable(id: string, orgId: string, orgName: string, status: 'pending' | 'accepted' = 'pending') {
+ return {
+ id,
+ status,
+ accept: vi.fn().mockResolvedValue(undefined),
+ publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' },
+ };
+}
+
+function membership(orgId: string, name: string, membersCount: number) {
+ return { organization: { id: orgId, name, imageUrl: '', membersCount } };
+}
+
+function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList {
+ return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) };
+}
+
+/** A promise whose settling is controlled by the test, to hold an async action in flight. */
+function createDeferred() {
+ let resolve: () => void = () => {};
+ let reject: (reason?: unknown) => void = () => {};
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+beforeEach(() => {
+ isUserLoaded = true;
+ isSessionLoaded = true;
+ isOrgLoaded = true;
+ user = {
+ id: 'user_1',
+ firstName: 'Alice',
+ lastName: 'Smith',
+ username: 'alice',
+ primaryEmailAddress: { emailAddress: 'alice@example.com' },
+ imageUrl: 'https://img/alice',
+ organizationMemberships: [{ id: 'orgmem_1' }],
+ createOrganizationEnabled: true,
+ };
+ session = { id: 'sess_1', checkAuthorization: vi.fn().mockReturnValue(true) };
+ organization = { id: 'org_1', name: 'Acme', imageUrl: '', membersCount: 3 };
+ userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2);
+ userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1);
+ userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1);
+ pagingRef = vi.fn();
+ singleSessionMode = false;
+ organizationsEnabled = true;
+ signedInSessions = [
+ { id: 'sess_1', user },
+ {
+ id: 'sess_2',
+ user: {
+ id: 'user_2',
+ firstName: 'Bob',
+ lastName: 'Jones',
+ username: null,
+ primaryEmailAddress: { emailAddress: 'bob@example.com' },
+ imageUrl: 'https://img/bob',
+ organizationMemberships: [],
+ createOrganizationEnabled: true,
+ },
+ },
+ ];
+ setActive = vi.fn().mockResolvedValue(undefined);
+ signOut = vi.fn().mockResolvedValue(undefined);
+ navigate = vi.fn().mockResolvedValue(undefined);
+ openUserProfile = vi.fn();
+ openOrganizationProfile = vi.fn();
+ openCreateOrganization = vi.fn();
+ openInviteMembers = vi.fn();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+function renderUserButton(props: UserButtonProps = {}) {
+ return render(
+
+ {/* The button portals its popup out, so this host holds only what it renders in place. */}
+
+
+
+ ,
+ );
+}
+
+const host = () => screen.getByTestId('host');
+const trigger = () => screen.getByRole('button', { name: /Open account menu/ });
+const popup = () => screen.queryByRole('dialog', { name: 'Account' });
+const spinner = () => popup()?.querySelector('.cl-spinner') ?? null;
+
+async function open() {
+ const act = userEvent.setup();
+ await act.click(trigger());
+ expect(popup()).toBeInTheDocument();
+ return act;
+}
+
+// Alice has a username, so that is what identifies her row; Bob has none and falls back to email.
+const accountMenu = () => screen.getByRole('button', { name: 'Actions for alice' });
+
+/** Opens the `⋯` on the active account's row and clicks one of its actions. */
+async function accountAction(act: ReturnType, label: string) {
+ await act.click(accountMenu());
+ await act.click(await screen.findByRole('menuitem', { name: label }));
+}
+
+describe('UserButton (connected)', () => {
+ // Nothing stands in for the button before Clerk answers, in any mode: until it does, a signed-out
+ // visitor is indistinguishable from a session still resolving, so a placeholder here would be
+ // promising a button to people who never get one.
+ describe.each(['combined', 'organization', 'user'] as const)('in %s mode', mode => {
+ it('renders nothing while Clerk is still loading', () => {
+ isUserLoaded = false;
+ renderUserButton({ mode });
+ expect(host()).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when nobody is signed in', () => {
+ user = null;
+ renderUserButton({ mode });
+ expect(host()).toBeEmptyDOMElement();
+ });
+
+ // Organizations off at the instance is the same answer whatever mode asked for: the button is
+ // the account's. An org-only surface would otherwise render its own empty shell, since the
+ // clerk-js mount boundary that withholds `` never runs for this one.
+ it('leaves organizations out entirely when the instance has them disabled', async () => {
+ organizationsEnabled = false;
+ renderUserButton({ mode });
+
+ // The account heads the surface, rather than the organization that is active regardless.
+ expect(screen.getByRole('button', { name: 'Open account menu for Alice Smith' })).toBeInTheDocument();
+ await open();
+
+ for (const name of ['Acme', 'Other', 'Beta', 'Gamma', 'Personal account']) {
+ expect(screen.queryByText(name)).toBeNull();
+ }
+ expect(screen.queryByRole('button', { name: 'Create organization' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+
+ // Everything the account itself carries is still on offer.
+ expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument();
+ });
+ });
+
+ it('renders the trigger and keeps the popover closed until clicked', () => {
+ renderUserButton();
+ expect(trigger()).toBeInTheDocument();
+ expect(popup()).toBeNull();
+ });
+
+ it('opens the popover on trigger click', async () => {
+ renderUserButton();
+ await open();
+
+ expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument();
+ expect(accountMenu()).toBeInTheDocument();
+ });
+
+ it('selecting an organization calls setActive without a redirect by default and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('leaving the active organization for the personal workspace clears it', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Personal account' }));
+
+ expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined });
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('drops the personal workspace where the app hides it, leaving the organizations', async () => {
+ renderUserButton({ hidePersonal: true });
+ await open();
+
+ expect(screen.queryByText('Personal account')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Other' })).toBeInTheDocument();
+ });
+
+ // `mode` is the view's own prop; this only proves the connected component hands it down, since
+ // the account-only surface is otherwise indistinguishable from an account with no organizations.
+ it('forwards mode to the view, so an account-only surface lists no organizations', async () => {
+ renderUserButton({ mode: 'user' });
+ await open();
+
+ expect(screen.queryByRole('button', { name: 'Other' })).toBeNull();
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toBeInTheDocument();
+ });
+
+ it('switching to another account calls setActive with the session and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'bob@example.com' }));
+
+ expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) });
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('signing out of the active account calls signOut with its session id', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Sign out');
+
+ // Another account stays signed in, so this is a single sign out, not a full one.
+ expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_1', redirectUrl: '/after-single-sign-out' });
+ });
+
+ it('signing out of all accounts calls signOut with the after-sign-out url', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Sign out of all accounts' }));
+
+ expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' });
+ });
+
+ it('accepting an invitation accepts it, revalidates, and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+ const invitation = userInvitations.data[0] as ReturnType;
+
+ await act.click(screen.getByRole('button', { name: 'Accept' }));
+
+ await waitFor(() => expect(invitation.accept).toHaveBeenCalledTimes(1));
+ expect(userInvitations.revalidate).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('accepting a suggestion accepts it, revalidates, and stays open', async () => {
+ renderUserButton();
+ const act = await open();
+ const suggestion = userSuggestions.data[0] as ReturnType;
+
+ await act.click(screen.getByRole('button', { name: 'Join' }));
+
+ await waitFor(() => expect(suggestion.accept).toHaveBeenCalledTimes(1));
+ expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('drops add-account and sign-out-of-all in single-session mode', async () => {
+ singleSessionMode = true;
+ signedInSessions = signedInSessions.slice(0, 1);
+ renderUserButton();
+ const act = await open();
+
+ expect(screen.queryByRole('button', { name: 'Sign out of all accounts' })).toBeNull();
+ expect(screen.queryByLabelText('Account actions')).toBeNull();
+ await act.click(accountMenu());
+ expect(screen.queryByRole('menuitem', { name: 'Add account' })).toBeNull();
+ });
+
+ it('managing the account opens the UserProfile modal and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+
+ expect(openUserProfile).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ // A custom action is the app's to run, and whatever it opens takes over from here, so the popover
+ // goes with it the way it does for managing an account.
+ it('running a custom menu item calls back and closes the popover', async () => {
+ const onClick = vi.fn();
+ renderUserButton({ customMenuItems: [{ id: 'terms', label: 'Terms of service', onClick }] });
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Terms of service' }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to
+ // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The
+ // popover has closed by then, so this also covers the portals outliving what opened them.
+ it('renders a custom page into the element the opened profile hands back', async () => {
+ renderUserButton({
+ userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:
Terms body
}] },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages).toHaveLength(1);
+ expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+
+ // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes.
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ reactAct(() => {
+ customPages[0].mount(el);
+ });
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('opens the profile with its pages in the order it was given', async () => {
+ renderUserButton({
+ userProfileProps: {
+ customPages: [{ label: 'Terms', path: 'terms', content:
Terms body
}],
+ pageOrder: ['account', 'terms'],
+ },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
+ });
+
+ it('inviting members opens the InviteMembers modal and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Invite' }));
+
+ expect(openInviteMembers).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('creating an organization opens the modal and closes the popover', async () => {
+ renderUserButton();
+ const act = await open();
+
+ await accountAction(act, 'Create organization');
+
+ expect(openCreateOrganization).toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('creating an organization navigates instead when a URL routes it', async () => {
+ renderUserButton({ createOrganizationUrl: '/new-org' });
+ const act = await open();
+
+ await accountAction(act, 'Create organization');
+
+ expect(navigate).toHaveBeenCalledWith('/new-org');
+ expect(openCreateOrganization).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('leaves create-organization out of the account menu for a user who cannot open one', async () => {
+ user = { ...(user as FakeUser), createOrganizationEnabled: false };
+ renderUserButton();
+ const act = await open();
+ await act.click(accountMenu());
+
+ expect(await screen.findByRole('menuitem', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.queryByRole('menuitem', { name: 'Create organization' })).toBeNull();
+ });
+
+ it('spins the clicked affordance and stands every other one down while an action is in flight', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+
+ // Every one of these is a network round trip, so there is nothing to debounce: the click gets
+ // its spinner in the same pass rather than after a delay window.
+ expect(spinner()).toBeInTheDocument();
+ // A stood-down row stays a button, and `aria-disabled` rather than natively disabled so it
+ // keeps its place in the tab order. Dropping it to a static row would remount it, and with it
+ // the avatar it carries.
+ expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('button', { name: 'bob@example.com' })).toHaveAttribute('aria-disabled', 'true');
+ expect(popup()).toBeInTheDocument();
+
+ deferred.resolve();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ // `setActive` swaps the active organization mid-flight. See `frozen` in the machine.
+ it('holds the surface on the data it started with until the action settles', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+ organization = { id: 'org_9', name: 'Other', imageUrl: '', membersCount: 1 };
+
+ // Any re-render now reads the swapped organization; the surface must not follow it.
+ await waitFor(() => expect(spinner()).toBeInTheDocument());
+ const surface = popup();
+ if (!surface) {
+ throw new Error('expected the popover to be open');
+ }
+ // Still the organization the surface opened on: heading it and listed under it, unclickable.
+ expect(within(surface).getAllByText('Acme')).toHaveLength(2);
+ expect(screen.queryByRole('button', { name: 'Acme' })).toBeNull();
+
+ deferred.resolve();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('spins inside the join button while a suggestion is being joined', async () => {
+ const deferred = createDeferred();
+ const suggestion = userSuggestions.data[0] as ReturnType;
+ suggestion.accept.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Join' }));
+
+ const join = screen.getByRole('button', { name: 'Join' });
+ expect(join).toHaveAttribute('aria-busy', 'true');
+ expect(within(join).getByRole('progressbar')).toBeInTheDocument();
+
+ deferred.resolve();
+ await waitFor(() => expect(spinner()).toBeNull());
+ expect(popup()).toBeInTheDocument();
+ });
+
+ it('keeps the popover open and clears busy state when an action rejects', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+ expect(spinner()).toBeInTheDocument();
+
+ deferred.reject(new Error('setActive failed'));
+
+ await waitFor(() => expect(spinner()).toBeNull(), { timeout: 2000 });
+ expect(popup()).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled();
+ });
+
+ // The spinner is held up for a minimum so it cannot flicker off. That hold is for a surface still
+ // on screen, so an action that closes the surface must not carry it: reopening inside the window
+ // would otherwise find the popup spinning over rows that are all stood down, for nothing.
+ it('reopens ready to use after an action that closed it', async () => {
+ const deferred = createDeferred();
+ setActive.mockReturnValueOnce(deferred.promise);
+ renderUserButton();
+ const act = await open();
+
+ await act.click(screen.getByRole('button', { name: 'Other' }));
+ expect(spinner()).toBeInTheDocument();
+
+ deferred.resolve();
+ await waitFor(() => expect(popup()).toBeNull());
+
+ await act.click(trigger());
+
+ expect(spinner()).toBeNull();
+ expect(screen.getByRole('button', { name: 'Sign out of all accounts' })).toBeEnabled();
+ });
+
+ // The view decides whether to mount the sentinel at all; this is the wiring that carries the
+ // in-view ref from the paginated lists, through the controller, to it.
+ it('hands the paging sentinel to the in-view ref when a list has a next page', async () => {
+ userMemberships = list([membership('org_1', 'Acme', 3)], 1, true);
+ renderUserButton();
+ await open();
+
+ expect(pagingRef).toHaveBeenCalledWith(expect.any(HTMLElement));
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx
new file mode 100644
index 00000000000..c541932e966
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx
@@ -0,0 +1,805 @@
+import type * as SharedReact from '@clerk/shared/react';
+import { useOrganization } from '@clerk/shared/react';
+import type { CustomPage } from '@clerk/shared/types';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView';
+import type { UserButtonModelOptions } from '../user-button.model';
+import { useUserButtonModel } from '../user-button.model';
+
+interface FakeUser {
+ id: string;
+ firstName: string | null;
+ lastName: string | null;
+ username: string | null;
+ primaryEmailAddress: { emailAddress: string } | null;
+ primaryPhoneNumber?: { phoneNumber: string } | null;
+ primaryWeb3Wallet?: { web3Wallet: string } | null;
+ imageUrl: string;
+ organizationMemberships: unknown[];
+ createOrganizationEnabled: boolean;
+}
+
+interface FakeSession {
+ id: string;
+ user: FakeUser;
+}
+
+interface FakeList {
+ data: unknown[];
+ count: number;
+ hasNextPage: boolean;
+ isLoading: boolean;
+ revalidate: ReturnType;
+}
+
+let isUserLoaded: boolean;
+let isSessionLoaded: boolean;
+let isOrgLoaded: boolean;
+let user: FakeUser | null;
+let session: { id: string; checkAuthorization: ReturnType } | null;
+let organization: { id: string; name: string; imageUrl: string; membersCount: number } | null;
+let userMemberships: FakeList;
+let userInvitations: FakeList;
+let userSuggestions: FakeList;
+let signedInSessions: FakeSession[];
+let pagingRef: (element: HTMLElement | null) => void;
+let singleSessionMode: boolean;
+let branded: boolean;
+let forceOrganizationSelection: boolean;
+let organizationsEnabled: boolean;
+// False stands for the window before clerk-js has hydrated it, which the model has to sit out.
+let environmentHydrated: boolean;
+
+// Built per read rather than once, so a test setting any of the flags above is answered by it.
+function environment() {
+ return environmentHydrated
+ ? {
+ displayConfig: { afterSwitchSessionUrl: '/after-switch', branded },
+ authConfig: { singleSessionMode },
+ organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection },
+ }
+ : null;
+}
+
+let setActive: ReturnType;
+let signOut: ReturnType;
+let navigate: ReturnType;
+let openUserProfile: ReturnType;
+let openOrganizationProfile: ReturnType;
+let openCreateOrganization: ReturnType;
+let openInviteMembers: ReturnType;
+let checkAuthorization: ReturnType;
+let getContainer: () => HTMLElement | null;
+
+vi.mock('@clerk/shared/react', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useUser: () => ({ isLoaded: isUserLoaded, user }),
+ useSession: () => ({ isLoaded: isSessionLoaded, session }),
+ useOrganization: vi.fn(() => ({ isLoaded: isOrgLoaded, organization })),
+ // Stubbed with a sentinel so the assertion is that this exact function reaches Clerk, rather
+ // than that some function did.
+ usePortalRoot: () => getContainer,
+ useClerk: () => ({
+ navigate,
+ setActive,
+ signOut,
+ openUserProfile,
+ openOrganizationProfile,
+ openCreateOrganization,
+ openInviteMembers,
+ buildUserProfileUrl: () => '/user-profile',
+ buildOrganizationProfileUrl: () => '/org-profile',
+ buildCreateOrganizationUrl: () => '/create-org',
+ buildSignInUrl: () => '/sign-in',
+ buildAfterSignOutUrl: () => '/after-sign-out',
+ buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-sign-out',
+ client: { signedInSessions },
+ __internal_environment: environment(),
+ }),
+ };
+});
+
+// The model reads its three paginated lists through the shared in-view helper, so the fetch
+// boundary is stubbed there rather than at `useOrganizationList`.
+vi.mock('../../../hooks/useOrganizationListInView', () => ({
+ useOrganizationListInView: vi.fn(() => ({ userMemberships, userInvitations, userSuggestions, ref: pagingRef })),
+}));
+
+function acceptable(
+ id: string,
+ orgId: string,
+ orgName: string,
+ status: 'pending' | 'accepted' | 'revoked' | 'expired' = 'pending',
+) {
+ return {
+ id,
+ status,
+ accept: vi.fn().mockResolvedValue(undefined),
+ publicOrganizationData: { id: orgId, name: orgName, imageUrl: '' },
+ };
+}
+
+function membership(orgId: string, name: string, membersCount: number) {
+ return { organization: { id: orgId, name, imageUrl: '', membersCount } };
+}
+
+function list(data: unknown[], count: number, hasNextPage = false, isLoading = false): FakeList {
+ return { data, count, hasNextPage, isLoading, revalidate: vi.fn().mockResolvedValue(undefined) };
+}
+
+beforeEach(() => {
+ isUserLoaded = true;
+ isSessionLoaded = true;
+ isOrgLoaded = true;
+ user = {
+ id: 'user_1',
+ firstName: 'Alice',
+ lastName: 'Smith',
+ username: 'alice',
+ primaryEmailAddress: { emailAddress: 'alice@example.com' },
+ imageUrl: 'https://img/alice',
+ organizationMemberships: [],
+ createOrganizationEnabled: true,
+ };
+ session = { id: 'sess_1', checkAuthorization: (checkAuthorization = vi.fn().mockReturnValue(true)) };
+ organization = { id: 'org_1', name: 'Acme', imageUrl: 'https://img/acme', membersCount: 3 };
+ userMemberships = list([membership('org_1', 'Acme', 3), membership('org_9', 'Other', 1)], 2);
+ userInvitations = list([acceptable('inv_1', 'org_3', 'Gamma')], 1);
+ userSuggestions = list([acceptable('sug_1', 'org_2', 'Beta')], 1);
+ pagingRef = vi.fn();
+ singleSessionMode = false;
+ branded = true;
+ forceOrganizationSelection = false;
+ organizationsEnabled = true;
+ environmentHydrated = true;
+ signedInSessions = [
+ { id: 'sess_1', user: user },
+ {
+ id: 'sess_2',
+ user: {
+ id: 'user_2',
+ firstName: 'Bob',
+ lastName: 'Jones',
+ username: null,
+ primaryEmailAddress: { emailAddress: 'bob@example.com' },
+ imageUrl: 'https://img/bob',
+ organizationMemberships: [],
+ createOrganizationEnabled: true,
+ },
+ },
+ ];
+ setActive = vi.fn().mockResolvedValue(undefined);
+ signOut = vi.fn().mockResolvedValue(undefined);
+ navigate = vi.fn().mockResolvedValue(undefined);
+ openUserProfile = vi.fn();
+ openOrganizationProfile = vi.fn();
+ openCreateOrganization = vi.fn();
+ openInviteMembers = vi.fn();
+ getContainer = () => null;
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+function Harness({ customPages, ...options }: UserButtonModelOptions & { customPages?: CustomPage[] } = {}) {
+ const c = useUserButtonModel(options, customPages);
+ if (c.status !== 'ready') {
+ return ;
+ }
+ return (
+
+ );
+}
+
+function memberships() {
+ return JSON.parse(screen.getByTestId('memberships').textContent ?? '[]');
+}
+
+function invitations() {
+ return JSON.parse(screen.getByTestId('invitations').textContent ?? '[]');
+}
+
+function activeOrganization() {
+ return JSON.parse(screen.getByTestId('active-org').textContent ?? 'null');
+}
+
+describe('useUserButtonModel', () => {
+ it('is loading until the user, session, and organization are all loaded', () => {
+ isUserLoaded = false;
+ const { rerender } = render();
+ expect(screen.getByTestId('status')).toHaveTextContent('loading');
+
+ isUserLoaded = true;
+ isSessionLoaded = false;
+ rerender();
+ expect(screen.getByTestId('status')).toHaveTextContent('loading');
+
+ isSessionLoaded = true;
+ isOrgLoaded = false;
+ rerender();
+ expect(screen.getByTestId('status')).toHaveTextContent('loading');
+ });
+
+ // Every instance-level answer the surface needs — organizations, single-session, forced
+ // selection — comes off the environment, and it hydrates on its own schedule. Reporting ready
+ // without it would mean guessing at all three and rearranging once it lands.
+ it('is loading until the environment has hydrated', () => {
+ environmentHydrated = false;
+ const { rerender } = render();
+ expect(screen.getByTestId('status')).toHaveTextContent('loading');
+
+ environmentHydrated = true;
+ rerender();
+ expect(screen.getByTestId('status')).toHaveTextContent('ready');
+ });
+
+ it('reports whether the instance has organizations at all', () => {
+ render();
+ expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('true');
+ expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true });
+
+ cleanup();
+ organizationsEnabled = false;
+ render();
+ expect(screen.getByTestId('orgs-enabled')).toHaveTextContent('false');
+ expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false });
+ });
+
+ it('does not fetch the organization lists until the environment says they are on', () => {
+ environmentHydrated = false;
+ const { rerender } = render();
+ expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: false });
+
+ environmentHydrated = true;
+ rerender();
+ expect(useOrganizationListInView).toHaveBeenCalledWith({ enabled: true });
+ });
+
+ it('does not treat reading the active organization as a request to enable them', () => {
+ render();
+ expect(useOrganization).toHaveBeenCalledWith({
+ __internal_skipAttemptToEnableOrganizations: true,
+ });
+ });
+
+ it('is hidden when loaded but there is no active user', () => {
+ user = null;
+ render();
+ expect(screen.getByTestId('status')).toHaveTextContent('hidden');
+ });
+
+ it('maps the active account and prefers first+last > username > email for the name', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('status')).toHaveTextContent('ready');
+ expect(screen.getByTestId('active-name')).toHaveTextContent('Alice Smith');
+ expect(screen.getByTestId('active-session')).toHaveTextContent('sess_1');
+
+ user = { ...(user as FakeUser), firstName: null, lastName: null };
+ rerender();
+ expect(screen.getByTestId('active-name')).toHaveTextContent('alice');
+
+ user = { ...user, username: null };
+ rerender();
+ expect(screen.getByTestId('active-name')).toHaveTextContent('alice@example.com');
+ });
+
+ it('identifies the active account by username, then email, then phone, then wallet', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice');
+
+ user = { ...(user as FakeUser), username: null };
+ rerender();
+ expect(screen.getByTestId('active-identifier')).toHaveTextContent('alice@example.com');
+
+ user = { ...user, primaryEmailAddress: null, primaryPhoneNumber: { phoneNumber: '+15550100' } };
+ rerender();
+ expect(screen.getByTestId('active-identifier')).toHaveTextContent('+15550100');
+
+ user = { ...user, primaryPhoneNumber: null, primaryWeb3Wallet: { web3Wallet: '0xabc' } };
+ rerender();
+ expect(screen.getByTestId('active-identifier')).toHaveTextContent('0xabc');
+ });
+
+ it('describes the active organization whole, and null in personal mode', () => {
+ const { rerender } = render();
+ expect(activeOrganization()).toMatchObject({
+ kind: 'membership',
+ organizationId: 'org_1',
+ name: 'Acme',
+ imageUrl: 'https://img/acme',
+ membersCount: 3,
+ });
+
+ organization = null;
+ rerender();
+ expect(activeOrganization()).toBeNull();
+ });
+
+ it('names the active organization from the organization itself, not the membership list', () => {
+ userMemberships = list([], 0, false, true);
+ render();
+
+ expect(activeOrganization()).toMatchObject({ organizationId: 'org_1', name: 'Acme' });
+ });
+
+ it('reports the organization list as loading until every one of its three parts has landed', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('orgs-loading')).toHaveTextContent('false');
+
+ userSuggestions = list([], 0, false, true);
+ rerender();
+ expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true');
+ });
+
+ it('derives hasOrganizations from the membership count, not the array length', () => {
+ userMemberships = list([membership('org_1', 'Acme', 3)], 0);
+ const { rerender } = render();
+ expect(screen.getByTestId('has-orgs')).toHaveTextContent('false');
+
+ userMemberships = list([], 5);
+ rerender();
+ expect(screen.getByTestId('has-orgs')).toHaveTextContent('true');
+ });
+
+ // Waiting on the list would open a workspace section under every personal-only account, then
+ // take it away again.
+ it('answers hasOrganizations from the user resource before any list has loaded', () => {
+ userMemberships = list([], 0, false, true);
+ user = { ...(user as FakeUser), organizationMemberships: [{ id: 'orgmem_1' }] };
+ render();
+
+ expect(screen.getByTestId('orgs-loading')).toHaveTextContent('true');
+ expect(screen.getByTestId('has-orgs')).toHaveTextContent('true');
+ });
+
+ it('carries only sessions in additionalSessions, excluding the active one', () => {
+ render();
+ expect(screen.getByTestId('additional')).toHaveTextContent('sess_2');
+ expect(screen.getByTestId('additional')).not.toHaveTextContent('sess_1');
+ });
+
+ it('maps membership, suggestion, and invitation rows with the correct kind discriminants', () => {
+ render();
+
+ const rows = memberships();
+ expect(rows[0]).toMatchObject({ kind: 'membership', organizationId: 'org_1', name: 'Acme', membersCount: 3 });
+
+ const suggestions = JSON.parse(screen.getByTestId('suggestions').textContent ?? '[]');
+ expect(suggestions[0]).toMatchObject({
+ kind: 'suggestion',
+ id: 'sug_1',
+ organizationId: 'org_2',
+ name: 'Beta',
+ status: 'pending',
+ });
+
+ expect(invitations()[0]).toMatchObject({
+ kind: 'invitation',
+ id: 'inv_1',
+ organizationId: 'org_3',
+ organizationName: 'Gamma',
+ status: 'pending',
+ });
+ });
+
+ it('lists invitations still open to the account, dropping the revoked and expired ones', () => {
+ userInvitations = list(
+ [
+ acceptable('inv_1', 'org_3', 'Gamma'),
+ acceptable('inv_2', 'org_4', 'Delta', 'accepted'),
+ acceptable('inv_3', 'org_5', 'Epsilon', 'revoked'),
+ acceptable('inv_4', 'org_6', 'Zeta', 'expired'),
+ ],
+ 4,
+ );
+ render();
+
+ expect(invitations().map((i: { id: string }) => i.id)).toEqual(['inv_1', 'inv_2']);
+ });
+
+ it('reports more to page in when any of the three lists has a next page', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('has-more')).toHaveTextContent('false');
+ expect(screen.getByTestId('paging-ref')).toHaveTextContent('true');
+
+ userSuggestions = list([], 0, true);
+ rerender();
+ expect(screen.getByTestId('has-more')).toHaveTextContent('true');
+ });
+
+ it('offers inviting members only with the manage-memberships permission', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('can-invite')).toHaveTextContent('true');
+ expect(checkAuthorization).toHaveBeenCalledWith({ permission: 'org:sys_memberships:manage' });
+
+ checkAuthorization.mockReturnValue(false);
+ rerender();
+ expect(screen.getByTestId('can-invite')).toHaveTextContent('false');
+ });
+
+ it('selects an organization via setActive, with no redirect unless one is configured', () => {
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByText('select-org'));
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
+
+ rerender();
+ fireEvent.click(screen.getByText('select-org'));
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/orgs/org_9' });
+
+ rerender( `/o/${org.name}`} />);
+ fireEvent.click(screen.getByText('select-org'));
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: '/o/Other' });
+ });
+
+ // `null` is Clerk's own name for the personal workspace, and there is no organization for
+ // `afterSelectOrganizationUrl` to resolve against.
+ it('selects the personal workspace by clearing the active organization', () => {
+ render();
+
+ fireEvent.click(screen.getByText('select-personal'));
+ expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: undefined });
+ });
+
+ it('redirects the personal workspace to the configured afterSelectPersonalUrl', () => {
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByText('select-personal'));
+ expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/user_1' });
+
+ rerender( `/u/${u.username}`} />);
+ fireEvent.click(screen.getByText('select-personal'));
+ expect(setActive).toHaveBeenCalledWith({ organization: null, redirectUrl: '/u/alice' });
+ });
+
+ // The two are configured apart, so routing the personal workspace leaves the organizations alone.
+ it('keeps the personal redirect off the organizations', () => {
+ render();
+
+ fireEvent.click(screen.getByText('select-org'));
+ expect(setActive).toHaveBeenCalledWith({ organization: 'org_9', redirectUrl: undefined });
+ });
+
+ // An instance that requires an organization has no personal workspace: clerk-js refuses
+ // `setActive({ organization: null })` outright there, so offering the switch would offer nothing.
+ it('reports no personal workspace where the instance forces an organization', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('hide-personal')).toHaveTextContent('false');
+
+ forceOrganizationSelection = true;
+ rerender();
+ expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
+ });
+
+ // An app whose organizations are the whole product withholds it itself. The instance setting is
+ // the other way in, and neither one can be talked out of it by the other.
+ it('lets the app withhold the personal workspace on an instance that allows one', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
+
+ forceOrganizationSelection = true;
+ rerender();
+ expect(screen.getByTestId('hide-personal')).toHaveTextContent('true');
+ });
+
+ it('switches sessions and routes each sign out to the URL that matches what is left', () => {
+ const { rerender } = render();
+
+ fireEvent.click(screen.getByText('switch'));
+ expect(setActive).toHaveBeenCalledWith(expect.objectContaining({ session: 'sess_2' }));
+
+ // Another account stays signed in, so this is a single sign out, not a full one.
+ fireEvent.click(screen.getByText('sign-out-one'));
+ expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-single-sign-out' });
+
+ fireEvent.click(screen.getByText('sign-out-all'));
+ expect(signOut).toHaveBeenCalledWith({ redirectUrl: '/after-sign-out' });
+
+ signedInSessions = signedInSessions.slice(0, 1);
+ rerender();
+ fireEvent.click(screen.getByText('sign-out-one'));
+ expect(signOut).toHaveBeenCalledWith({ sessionId: 'sess_2', redirectUrl: '/after-sign-out' });
+ });
+
+ // The session switched to can land on a task of its own. A plain `redirectUrl` routes past it and
+ // strands the account, so the switch hands `setActive` a callback that answers both cases.
+ it('routes a switched session to its pending task, and to the after-switch URL when it has none', async () => {
+ render();
+ fireEvent.click(screen.getByText('switch'));
+
+ expect(setActive).toHaveBeenCalledWith({ session: 'sess_2', navigate: expect.any(Function) });
+ const navigateOnSetActive = setActive.mock.calls[0][0].navigate;
+ const decorateUrl = vi.fn((url: string) => url);
+
+ await act(async () => {
+ await navigateOnSetActive({ session: { currentTask: { key: 'choose-organization' } }, decorateUrl });
+ });
+ expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/sign-in'));
+ expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization'));
+
+ await act(async () => {
+ await navigateOnSetActive({ session: { currentTask: null }, decorateUrl });
+ });
+ expect(navigate).toHaveBeenCalledWith('/after-switch');
+ // `redirectUrl` was decorated for us; taking the callback takes the Safari ITP refresh with it.
+ expect(decorateUrl).toHaveBeenCalledWith('/after-switch');
+ });
+
+ // An instance can restrict who may open an organization, and a user at their creation limit is
+ // restricted the same way. Offering the action anyway lands them on a page that turns them away.
+ it('drops create-organization for a user who cannot open one', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('can-create-org')).toHaveTextContent('true');
+
+ user = { ...(user as FakeUser), createOrganizationEnabled: false };
+ rerender();
+ expect(screen.getByTestId('can-create-org')).toHaveTextContent('false');
+ });
+
+ it('drops sign-out-all and add-account in single-session mode', () => {
+ singleSessionMode = true;
+ render();
+ expect(screen.getByTestId('can-sign-out-all')).toHaveTextContent('false');
+ expect(screen.getByTestId('can-add-account')).toHaveTextContent('false');
+ });
+
+ // An instance that has paid the branding off carries none of it, and the environment is the only
+ // place that answer lives.
+ it('carries the branding the instance is on, not the branding everyone gets', () => {
+ render();
+ expect(screen.getByTestId('branded')).toHaveTextContent('true');
+
+ cleanup();
+ branded = false;
+ render();
+ expect(screen.getByTestId('branded')).toHaveTextContent('false');
+ });
+
+ // Both profiles open as a modal unless a URL routes instead, which is what the pre-Mosaic
+ // UserButton and OrganizationSwitcher each do. Nothing navigates, so the page underneath stays.
+ it('opens the profile modals for manage-account and manage-org', () => {
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+ expect(openUserProfile).toHaveBeenCalled();
+
+ fireEvent.click(screen.getByText('manage-org'));
+ expect(openOrganizationProfile).toHaveBeenCalled();
+
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ // An app that mounts the button inside its own dialog or popover puts a portal root around it, and
+ // the modal has to land there too or it renders behind the surface that opened it.
+ it('opens the profile modals into the portal root the app configured', () => {
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+ expect(openUserProfile).toHaveBeenCalledWith({ getContainer });
+
+ fireEvent.click(screen.getByText('manage-org'));
+ expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer });
+ });
+
+ // Custom pages are bridged into this DOM-callback form by the container, since it is the layer
+ // that can render their portals. All the model owes them is a ride to the modal.
+ it('hands the profile modal the custom pages it was given', () => {
+ const customPages = [
+ {
+ label: 'Terms',
+ url: 'terms',
+ mount: vi.fn(),
+ unmount: vi.fn(),
+ mountIcon: vi.fn(),
+ unmountIcon: vi.fn(),
+ },
+ ];
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+
+ expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages });
+ });
+
+ // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass
+ // alongside it. The two are resolved apart, so routing one profile leaves the other a modal.
+ it('navigates to a profile URL when one is given, and only for that profile', () => {
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+ expect(navigate).toHaveBeenCalledWith('/account');
+ expect(openUserProfile).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByText('manage-org'));
+ expect(openOrganizationProfile).toHaveBeenCalled();
+ });
+
+ it('navigates to an organization profile URL when one is given', () => {
+ render();
+
+ fireEvent.click(screen.getByText('manage-org'));
+
+ expect(navigate).toHaveBeenCalledWith('/settings');
+ expect(openOrganizationProfile).not.toHaveBeenCalled();
+ });
+
+ // An explicit `navigation` is redundant next to a URL, but it is what the pre-Mosaic props accept,
+ // so passing both has to resolve the same as passing the URL alone.
+ it('accepts an explicit navigation mode alongside a URL', () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByText('manage-org'));
+
+ expect(navigate).toHaveBeenCalledWith('/settings');
+ expect(openOrganizationProfile).not.toHaveBeenCalled();
+ });
+
+ // Invite opens its own modal rather than following manage-org: there is no invite page to route
+ // to, so an app that routes organization management to its own page still gets the form here.
+ it('opens the invite-members modal into the portal root, whatever manage-org is routed to', () => {
+ render();
+
+ fireEvent.click(screen.getByText('invite-members'));
+
+ expect(openInviteMembers).toHaveBeenCalledWith({ getContainer });
+ expect(navigate).not.toHaveBeenCalled();
+ });
+
+ // Creating an organization resolves like the two profiles do: a modal unless a URL routes
+ // instead. Adding an account always leaves, since signing in cannot happen inside the popover.
+ it('opens the create-organization modal into the portal root, and navigates for add-account', () => {
+ render();
+
+ fireEvent.click(screen.getByText('create-org'));
+ expect(openCreateOrganization).toHaveBeenCalledWith({ getContainer });
+ expect(navigate).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByText('add-account'));
+ expect(navigate).toHaveBeenCalledWith('/sign-in');
+ });
+
+ it('navigates to a create-organization URL when one is given', () => {
+ render();
+
+ fireEvent.click(screen.getByText('create-org'));
+
+ expect(navigate).toHaveBeenCalledWith('/new-org');
+ expect(openCreateOrganization).not.toHaveBeenCalled();
+ });
+
+ // Without a URL there is nothing to navigate to but Clerk's own page, which is what an explicit
+ // `navigation` asks for.
+ it('falls back to the clerk create-organization URL for an explicit navigation mode', () => {
+ render();
+
+ fireEvent.click(screen.getByText('create-org'));
+
+ expect(navigate).toHaveBeenCalledWith('/create-org');
+ expect(openCreateOrganization).not.toHaveBeenCalled();
+ });
+
+ it('accepts invitations and suggestions, then revalidates whatever the accept changed', async () => {
+ render();
+
+ // Accepting an invitation joins the organization, so the membership list is stale too.
+ const invitation = userInvitations.data[0] as ReturnType;
+ await act(async () => {
+ fireEvent.click(screen.getByText('accept-invitation'));
+ });
+ expect(invitation.accept).toHaveBeenCalledTimes(1);
+ expect(userInvitations.revalidate).toHaveBeenCalledTimes(1);
+ expect(userMemberships.revalidate).toHaveBeenCalledTimes(1);
+
+ // A suggestion only files a request an admin has yet to approve, so nothing has been joined.
+ const suggestion = userSuggestions.data[0] as ReturnType;
+ await act(async () => {
+ fireEvent.click(screen.getByText('accept-suggestion'));
+ });
+ expect(suggestion.accept).toHaveBeenCalledTimes(1);
+ expect(userSuggestions.revalidate).toHaveBeenCalledTimes(1);
+ expect(userMemberships.revalidate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx
new file mode 100644
index 00000000000..6a7ae686a63
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx
@@ -0,0 +1,208 @@
+import type { CustomPage } from '@clerk/shared/types';
+import { act, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import type { CustomPagesOptions, CustomProfileItem } from '../user-button.pages';
+import { useCustomPages } from '../user-button.pages';
+
+// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to
+// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the
+// tests exercise the same handshake the real modal performs.
+function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ act(() => callback?.(el));
+ return el;
+}
+
+function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) {
+ act(() => callback?.(el));
+ el.remove();
+}
+
+let emitted: CustomPage[] | undefined;
+
+function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) {
+ const { customPages, portals } = useCustomPages({ items, order, builtInPages });
+ emitted = customPages;
+ return
,
+};
+
+const docs: CustomProfileItem = {
+ label: 'Docs',
+ path: 'docs',
+ href: 'https://clerk.com/docs',
+ icon: docs icon,
+};
+
+beforeEach(() => {
+ emitted = undefined;
+});
+
+describe('useCustomPages', () => {
+ it('sends nothing when there are no custom pages', () => {
+ render();
+
+ expect(emitted).toBeUndefined();
+ expect(screen.getByTestId('host')).toBeEmptyDOMElement();
+ });
+
+ it('sends a page as its path and a link as its href', () => {
+ render();
+
+ expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']);
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what
+ // make an item a page. A link carrying them would be routed to instead of followed.
+ it('sends content callbacks for a page and none for a link', () => {
+ render();
+
+ const [page, link] = emitted ?? [];
+ expect(page.mount).toBeTypeOf('function');
+ expect(page.unmount).toBeTypeOf('function');
+ expect(link.mount).toBeUndefined();
+ expect(link.unmount).toBeUndefined();
+ });
+
+ // Without them clerk-js drops the page as invalid, so `icon` could not be optional.
+ it('sends the icon callbacks even for an item with no icon', () => {
+ render(Terms body
}]} />);
+
+ const [page] = emitted ?? [];
+ expect(page.mountIcon).toBeTypeOf('function');
+ expect(page.unmountIcon).toBeTypeOf('function');
+
+ const el = mountInto(page.mountIcon);
+ expect(el).toBeEmptyDOMElement();
+ });
+
+ it('renders page content into the element clerk-js hands back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('renders an icon into its own element, apart from the content', () => {
+ render();
+
+ const content = mountInto(emitted?.[0].mount);
+ const icon = mountInto(emitted?.[0].mountIcon);
+
+ expect(within(icon).getByText('terms icon')).toBeInTheDocument();
+ expect(within(content).queryByText('terms icon')).toBeNull();
+ });
+
+ it('keeps each page in the element that asked for it', () => {
+ const help: CustomProfileItem = { label: 'Help', path: 'help', content:
Help body
};
+ render();
+
+ const first = mountInto(emitted?.[0].mount);
+ const second = mountInto(emitted?.[1].mount);
+
+ expect(within(first).getByText('Terms body')).toBeInTheDocument();
+ expect(within(second).getByText('Help body')).toBeInTheDocument();
+ });
+
+ it('stops rendering content once clerk-js gives the element back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+
+ unmountFrom(emitted?.[0].unmount, el);
+
+ expect(screen.queryByText('Terms body')).toBeNull();
+ });
+
+ // The profile is opened once with the callbacks from that render, and never handed a later set.
+ // They have to keep working against the current content, or a page re-rendered while the profile
+ // is open goes stale.
+ it('renders updated content through the callbacks the profile was opened with', () => {
+ const { rerender } = render();
+ const el = mountInto(emitted?.[0].mount);
+
+ rerender(Revised terms }]} />);
+
+ expect(within(el).getByText('Revised terms')).toBeInTheDocument();
+ });
+
+ describe('order', () => {
+ it('leaves the built-in pages alone when no order is given', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ it('sends the pages in the order it was given', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']);
+ });
+
+ // Anything more than the id and clerk-js reads it as a custom page.
+ it('sends a built-in page as its id alone', () => {
+ render();
+
+ expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]);
+ });
+
+ // Unsent built-ins jump to the front, so one left out of the order would not stay put.
+ it('sends the pages left out of the order after the ones in it', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']);
+ });
+
+ it('drops an id that belongs to no page', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']);
+ });
+
+ it('sends a page once even when the order names it twice', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'account']);
+ });
+
+ it('renders a reordered page into the element clerk-js hands back', () => {
+ render(
+ ,
+ );
+
+ const el = mountInto(emitted?.[1].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx
index 2583acccf32..58af0503188 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx
@@ -6,28 +6,37 @@ import type { UserButtonController } from '../user-button.controller';
let controller: UserButtonController;
+vi.mock('../user-button.model', () => ({
+ useUserButtonModel: () => ({ status: 'loading' }),
+}));
+
vi.mock('../user-button.controller', () => ({
useUserButtonController: () => controller,
}));
-// The container's own job is which of the three controller states renders what, so the surface is
+// The custom pages outlive the popup, so the wrapper renders their portals in every state.
+vi.mock('../user-button.pages', () => ({
+ useUserProfilePages: () => [],
+ useCustomPages: () => ({
+ customPages: undefined,
+ portals: [
+ ,
+ ],
+ }),
+}));
+
+// The wrapper's own job is which of the three controller states renders what, so the surface is
// stubbed out and the view's own tests cover it.
vi.mock('../user-button.view', () => ({
- userButtonBusyKeys: {
- selectOrganization: () => 'select-organization',
- switchSession: () => 'switch-session',
- signOutSession: () => 'sign-out-session',
- signOutAll: () => 'sign-out-all',
- acceptSuggestion: () => 'accept-suggestion',
- acceptInvitation: () => 'accept-invitation',
- },
UserButtonView: () => ,
}));
function ready(): UserButtonController {
return {
status: 'ready',
- organizationsEnabled: true,
renderBranding: true,
activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' },
activeOrganization: null,
@@ -68,8 +77,23 @@ describe('UserButton', () => {
expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
});
- it('renders nothing while loading when no fallback is given', () => {
- const { container } = render();
- expect(container).toBeEmptyDOMElement();
+ it('renders no fallback while loading when none is given', () => {
+ render();
+ expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('view')).not.toBeInTheDocument();
+ });
+
+ // The profile can be open in clerk-js's own root while the button itself has nothing to render.
+ it('keeps the custom page portals mounted in every state', () => {
+ const { rerender } = render();
+ expect(screen.getByTestId('portal')).toBeInTheDocument();
+
+ controller = { status: 'hidden' };
+ rerender();
+ expect(screen.getByTestId('portal')).toBeInTheDocument();
+
+ controller = ready();
+ rerender();
+ expect(screen.getByTestId('portal')).toBeInTheDocument();
});
});
diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
index 024c92525df..947de532aed 100644
--- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
@@ -1,288 +1,206 @@
-import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
-import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
-import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react';
-import type { OrganizationResource, UserResource } from '@clerk/shared/types';
+import { useSpinDelay } from '../hooks/useSpinDelay';
+import { setup } from '../machine/setup';
+import { useMachine } from '../machine/useMachine';
+import type { UserButtonModel } from './user-button.model';
+import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types';
+import type { UserButtonProps as UserButtonViewProps, UserButtonTriggerProps } from './user-button.view';
+import { userButtonBusyKeys } from './user-button.view';
+
+/** The model once Clerk has answered, which is the only shape an action can start from. */
+export type UserButtonReadyModel = Extract;
+
+interface UserButtonMachineContext {
+ /** Which action is currently pending. */
+ pendingKey: string | null;
+ /**
+ * The model the action started from. `setActive` swaps the active organization while its
+ * promise is still in flight, so the live model would rearrange the popup mid-action.
+ * The view renders this instead until the action settles.
+ */
+ frozen: UserButtonReadyModel | null;
+ /** Injected per-action effect — the model callback the clicked row runs. */
+ run: () => Promise;
+ /** Whether succeeding ends the interaction, and the popup with it. */
+ closeOnSuccess: boolean;
+}
-import { populateParamFromObject } from '../../contexts/utils';
-import { useOrganizationListInView } from '../../hooks/useOrganizationListInView';
-import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment';
-import { useMosaicRouter } from '../hooks/useMosaicRouter';
-import type {
- UserButtonBrandingProps,
- UserButtonCallbacks,
- UserButtonData,
- UserButtonInvitation,
- UserButtonMembership,
- UserButtonSession,
- UserButtonSuggestion,
-} from './user-button.types';
+type UserButtonMachineEvent =
+ | { type: 'OPEN' }
+ | { type: 'CLOSE' }
+ | {
+ type: 'RUN';
+ key: string;
+ frozen: UserButtonReadyModel;
+ run: () => Promise;
+ closeOnSuccess: boolean;
+ };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const settled = { pendingKey: null, frozen: null };
+
+const userButtonMachine = createMachine({
+ id: 'userButton',
+ initial: 'closed',
+ context: {
+ pendingKey: null,
+ frozen: null,
+ run: () => Promise.resolve(),
+ closeOnSuccess: false,
+ },
+ states: {
+ closed: {
+ on: { OPEN: 'open' },
+ },
+ open: {
+ on: {
+ CLOSE: 'closed',
+ RUN: {
+ target: 'busy',
+ actions: assign((_, event) => ({
+ pendingKey: event.key,
+ frozen: event.frozen,
+ run: event.run,
+ closeOnSuccess: event.closeOnSuccess,
+ })),
+ },
+ },
+ },
+ // Reached only from `open`, so a busy popup that is not open is unrepresentable, and RUN going
+ // unhandled here is what stops a second action starting while one is in flight. Dismissing the
+ // popup abandons the action: the request finishes, but nothing is left for its result to land in.
+ busy: {
+ on: { CLOSE: { target: 'closed', actions: assign(() => settled) } },
+ invoke: fromPromise(context => context.run(), {
+ onDone: [
+ { target: 'closed', guard: context => context.closeOnSuccess, actions: assign(() => settled) },
+ { target: 'open', actions: assign(() => settled) },
+ ],
+ // The popup stays up on a failure so the row can be clicked again. Nothing reports what went
+ // wrong yet; the error surface is its own change, and carrying a message before one exists
+ // would mean shipping an untranslated string nobody reads.
+ onError: { target: 'open', actions: assign(() => settled) },
+ }),
+ },
+ },
+});
-// Promise-returning so the container can drive busy state. Navigation callbacks stay fire-and-forget.
-interface UserButtonAsyncCallbacks {
- onSelectOrganization?: (organizationId: string | null) => void | Promise;
- onSwitchSession?: (sessionId: string) => void | Promise;
- onSignOutSession?: (sessionId: string) => void | Promise;
- onSignOutAll?: () => void | Promise;
- onAcceptSuggestion?: (suggestionId: string) => void | Promise;
- onAcceptInvitation?: (invitationId: string) => void | Promise;
-}
+export type UserButtonControllerOptions = Pick & UserButtonMenuProps;
export type UserButtonController =
| { status: 'loading' }
| { status: 'hidden' }
- | (UserButtonData &
- Omit &
- UserButtonAsyncCallbacks &
- UserButtonBrandingProps & {
- status: 'ready';
- /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */
- organizationsEnabled: boolean;
- });
-
-// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder.
-type AfterSelectUrl = ((entity: T) => string) | string;
-
-/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */
-type UserProfileMode =
- | { userProfileUrl: string; userProfileMode?: 'navigation' }
- | { userProfileUrl?: never; userProfileMode?: 'modal' };
-
-type OrganizationProfileMode =
- | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' }
- | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' };
-
-type CreateOrganizationMode =
- | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' }
- | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' };
-
-export type UserButtonControllerOptions = UserProfileMode &
- OrganizationProfileMode &
- CreateOrganizationMode & {
- afterSelectOrganizationUrl?: AfterSelectUrl;
- /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */
- afterSelectPersonalUrl?: AfterSelectUrl;
- /**
- * Leaves the personal workspace out. An instance that forces organization selection withholds it
- * either way, so this cannot opt back in.
- */
- hidePersonal?: boolean;
- };
-
-function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined {
- if (typeof config === 'function') {
- return config(entity);
- }
- if (config) {
- return populateParamFromObject({ urlWithParam: config, entity });
- }
- return undefined;
-}
-
-/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */
-function openOrNavigate({
- url,
- mode,
- openModal,
- buildUrl,
- navigate,
-}: {
- url: string | undefined;
- mode: 'navigation' | 'modal' | undefined;
- openModal: () => void;
- buildUrl: () => string;
- navigate: (to: string) => unknown;
-}): () => void {
- const resolved = mode ?? (url ? 'navigation' : 'modal');
- return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal();
-}
-
-const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage';
-
-function displayName(user: UserResource): string {
- return getFullName(user) || getIdentifier(user);
-}
-
-function toMembership(organization: OrganizationResource): UserButtonMembership {
- return {
- kind: 'membership',
- organizationId: organization.id,
- name: organization.name,
- imageUrl: organization.imageUrl || undefined,
- membersCount: organization.membersCount,
- };
-}
-
-function toSession(sessionId: string, user: UserResource): UserButtonSession {
- return {
- sessionId,
- name: displayName(user),
- identifier: getIdentifier(user),
- imageUrl: user.imageUrl,
- };
-}
-
-export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController {
- const { isLoaded: isUserLoaded, user } = useUser();
- const { isLoaded: isSessionLoaded, session } = useSession();
- const { isLoaded: isOrgLoaded, organization } = useOrganization();
- const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView();
-
- const clerk = useClerk();
- const router = useMosaicRouter();
- // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
- const getContainer = usePortalRoot();
- const environment = useMosaicEnvironment();
-
- const manageAccount = openOrNavigate({
- url: options?.userProfileUrl,
- mode: options?.userProfileMode,
- openModal: () => clerk.openUserProfile({ getContainer }),
- buildUrl: () => clerk.buildUserProfileUrl(),
- navigate: router.navigate,
+ | ({ status: 'ready' } & Omit);
+
+/**
+ * The controller is the layer between the component (view) and the external world (model).
+ * It represents the local state and wraps model actions in order to handle pending states,
+ * keep the UI stable while an action is ongoing, close the popup on completed actions
+ * when appropriate, etc.
+ */
+export function useUserButtonController(
+ model: UserButtonModel,
+ options: UserButtonControllerOptions = {},
+): UserButtonController {
+ const { mode: requestedMode, modePriority, customMenuItems, menuItemOrder } = options;
+ // The popover's open state and the one action in flight are the same flow: an action that ends the
+ // interaction closes the surface, so they settle together or not at all.
+ const [{ value, context }, send] = useMachine(userButtonMachine);
+
+ // Every action here is a network round trip, so we can start the
+ // pending state immediately, we use this for the minDuration
+ const displayPendingKey = useSpinDelay(context.pendingKey, {
+ delay: 0,
+ minDuration: context.closeOnSuccess ? 0 : undefined,
});
- const manageOrganization = openOrNavigate({
- url: options?.organizationProfileUrl,
- mode: options?.organizationProfileMode,
- openModal: () => clerk.openOrganizationProfile({ getContainer }),
- buildUrl: () => clerk.buildOrganizationProfileUrl(),
- navigate: router.navigate,
- });
-
- const createOrganization = openOrNavigate({
- url: options?.createOrganizationUrl,
- mode: options?.createOrganizationMode,
- openModal: () => clerk.openCreateOrganization({ getContainer }),
- buildUrl: () => clerk.buildCreateOrganizationUrl(),
- navigate: router.navigate,
- });
-
- // These all affect layout, so wait for every one and avoid a reshuffle.
- if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) {
- return { status: 'loading' };
+ if (model.status !== 'ready') {
+ return { status: model.status };
}
- if (!user || !session) {
- return { status: 'hidden' };
- }
-
- const { displayConfig, authConfig, organizationSettings } = environment;
- // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back.
- const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings;
- const { singleSessionMode } = authConfig;
-
- const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false;
- const membershipData = userMemberships.data ?? [];
- const suggestionData = userSuggestions.data ?? [];
- const invitationData = userInvitations.data ?? [];
+ const close = () => send({ type: 'CLOSE' });
- const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization));
-
- const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({
- kind: 'suggestion',
- id: s.id,
- organizationId: s.publicOrganizationData.id,
- name: s.publicOrganizationData.name,
- imageUrl: s.publicOrganizationData.imageUrl || undefined,
- status: s.status,
- }));
-
- // Accepting is all a row offers, so a revoked or expired invitation has nothing to show.
- const invitations: UserButtonInvitation[] = invitationData.flatMap(i =>
- i.status === 'pending' || i.status === 'accepted'
- ? [
- {
- kind: 'invitation',
- id: i.id,
- status: i.status,
- organizationId: i.publicOrganizationData.id,
- organizationName: i.publicOrganizationData.name,
- imageUrl: i.publicOrganizationData.imageUrl || undefined,
+ const menuItems = customMenuItems?.map(item =>
+ item.href === undefined
+ ? {
+ ...item,
+ onClick: () => {
+ // Always close the menu for custom actions
+ close();
+ item.onClick();
},
- ]
- : [],
+ }
+ : item,
);
- // Organization requests are scoped to the active session, so another account's workspaces are unknowable.
- const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => {
- const sessionUser = s.user;
- if (!sessionUser || s.id === session.id) {
- return [];
- }
- return [toSession(s.id, sessionUser)];
- });
-
- const afterSelectUrl = (organizationId: string | null): string | undefined => {
- if (!organizationId) {
- return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user);
- }
- const selected = membershipData.find(m => m.organization.id === organizationId)?.organization;
- return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined;
- };
+ // Wrapper to tie a callback into the machine
+ const runAction = (
+ keyFor: (...args: Args) => string,
+ fn: ((...args: Args) => void | Promise) | undefined,
+ closeOnSuccess = false,
+ ) =>
+ fn
+ ? (...args: Args) =>
+ send({
+ type: 'RUN',
+ key: keyFor(...args),
+ frozen: model,
+ run: async () => fn(...args),
+ closeOnSuccess,
+ })
+ : undefined;
+
+ // A callback that wraps a callback so it always closes the popup when done
+ const handOff = (fn: (() => void) | undefined) =>
+ fn
+ ? () => {
+ close();
+ fn();
+ }
+ : undefined;
+
+ // Rendering the model the action froze on holds the popup still while it runs; the result
+ // lands in one step when it settles. See `frozen` in the machine for why.
+ const {
+ status: _status,
+ organizationsEnabled,
+ onSelectOrganization,
+ onSwitchSession,
+ onSignOutSession,
+ onSignOutAll,
+ onAcceptSuggestion,
+ onAcceptInvitation,
+ onManageAccount,
+ onManageOrganization,
+ onInviteMembers,
+ onCreateOrganization,
+ onAddAccount,
+ ...data
+ } = context.frozen ?? model;
+
+ // Force user mode if organizations are disabled
+ const mode = model.organizationsEnabled ? requestedMode : 'user';
return {
status: 'ready',
- organizationsEnabled,
- renderBranding: displayConfig.branded,
- activeSession: toSession(session.id, user),
- activeOrganization: organization ? toMembership(organization) : null,
- // The user resource settles this before the paginated list answers; the count covers a stale resource.
- hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0,
- hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false),
- // Only true before the first page lands, which is the one window where empty and pending look alike.
- organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading,
- memberships,
- suggestions,
- invitations,
- additionalSessions,
- paging: {
- ref,
- hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage),
- },
- onSelectOrganization: organizationId =>
- clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }),
- // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it.
- // App-level `taskUrls` outrank this callback, so it only answers for an app that set none.
- onSwitchSession: sessionId =>
- clerk.setActive({
- session: sessionId,
- navigate: async ({ session, decorateUrl }) => {
- const task = session.currentTask;
- if (task) {
- await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() }));
- return;
- }
- // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it.
- await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl));
- },
- }),
- onSignOutSession: sessionId =>
- clerk.signOut({
- sessionId,
- // Other accounts stay signed in, so this is a single sign out rather than a full one.
- redirectUrl:
- additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(),
- }),
- // Single-session apps cannot hold a second account, so both actions are meaningless there.
- onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }),
- onManageAccount: manageAccount,
- onManageOrganization: manageOrganization,
- // Invite has no page of its own to route to, so it opens its modal even when management is routed.
- onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined,
- // Covers both restricted instances and users at their creation limit.
- onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined,
- onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()),
- onAcceptSuggestion: suggestionId => {
- const suggestion = suggestionData.find(s => s.id === suggestionId);
- return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.());
- },
- // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing.
- onAcceptInvitation: invitationId => {
- const invitation = invitationData.find(i => i.id === invitationId);
- return Promise.resolve(invitation?.accept()).finally(() => {
- void userInvitations.revalidate?.();
- void userMemberships.revalidate?.();
- });
- },
+ ...data,
+ mode,
+ modePriority,
+ customMenuItems: menuItems,
+ menuItemOrder,
+ open: value !== 'closed',
+ onOpenChange: next => send(next ? { type: 'OPEN' } : { type: 'CLOSE' }),
+ pendingKey: displayPendingKey,
+ onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true),
+ onSwitchSession: runAction(userButtonBusyKeys.switchSession, onSwitchSession),
+ onSignOutSession: runAction(userButtonBusyKeys.signOutSession, onSignOutSession),
+ onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll),
+ onAcceptSuggestion: runAction(userButtonBusyKeys.acceptSuggestion, onAcceptSuggestion),
+ onAcceptInvitation: runAction(userButtonBusyKeys.acceptInvitation, onAcceptInvitation),
+ onManageAccount: handOff(onManageAccount),
+ onManageOrganization: handOff(onManageOrganization),
+ onInviteMembers: handOff(onInviteMembers),
+ onCreateOrganization: handOff(onCreateOrganization),
+ onAddAccount: handOff(onAddAccount),
};
}
diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx
new file mode 100644
index 00000000000..4539add1422
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx
@@ -0,0 +1,303 @@
+import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
+import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
+import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react';
+import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types';
+
+import { populateParamFromObject } from '../../contexts/utils';
+import { useOrganizationListInView } from '../../hooks/useOrganizationListInView';
+import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment';
+import { useMosaicRouter } from '../hooks/useMosaicRouter';
+import type {
+ UserButtonBrandingProps,
+ UserButtonCallbacks,
+ UserButtonData,
+ UserButtonInvitation,
+ UserButtonMembership,
+ UserButtonSession,
+ UserButtonSuggestion,
+} from './user-button.types';
+
+// Promise-returning so the controller can drive busy state. Navigation callbacks stay fire-and-forget.
+interface UserButtonAsyncCallbacks {
+ onSelectOrganization?: (organizationId: string | null) => void | Promise;
+ onSwitchSession?: (sessionId: string) => void | Promise;
+ onSignOutSession?: (sessionId: string) => void | Promise;
+ onSignOutAll?: () => void | Promise;
+ onAcceptSuggestion?: (suggestionId: string) => void | Promise;
+ onAcceptInvitation?: (invitationId: string) => void | Promise;
+}
+
+export type UserButtonModel =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | (UserButtonData &
+ Omit &
+ UserButtonAsyncCallbacks &
+ UserButtonBrandingProps & {
+ status: 'ready';
+ /** Whether the instance has organizations turned on at all. False forces the button to `user` mode. */
+ organizationsEnabled: boolean;
+ });
+
+// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder.
+type AfterSelectUrl = ((entity: T) => string) | string;
+
+/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */
+type UserProfileMode =
+ | { userProfileUrl: string; userProfileMode?: 'navigation' }
+ | { userProfileUrl?: never; userProfileMode?: 'modal' };
+
+type OrganizationProfileMode =
+ | { organizationProfileUrl: string; organizationProfileMode?: 'navigation' }
+ | { organizationProfileUrl?: never; organizationProfileMode?: 'modal' };
+
+type CreateOrganizationMode =
+ | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' }
+ | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' };
+
+export type UserButtonModelOptions = UserProfileMode &
+ OrganizationProfileMode &
+ CreateOrganizationMode & {
+ afterSelectOrganizationUrl?: AfterSelectUrl;
+ /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */
+ afterSelectPersonalUrl?: AfterSelectUrl;
+ /**
+ * Leaves the personal workspace out. An instance that forces organization selection withholds it
+ * either way, so this cannot opt back in.
+ */
+ hidePersonal?: boolean;
+ };
+
+function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined {
+ if (typeof config === 'function') {
+ return config(entity);
+ }
+ if (config) {
+ return populateParamFromObject({ urlWithParam: config, entity });
+ }
+ return undefined;
+}
+
+/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */
+function openOrNavigate({
+ url,
+ mode,
+ openModal,
+ buildUrl,
+ navigate,
+}: {
+ url: string | undefined;
+ mode: 'navigation' | 'modal' | undefined;
+ openModal: () => void;
+ buildUrl: () => string;
+ navigate: (to: string) => unknown;
+}): () => void {
+ const resolved = mode ?? (url ? 'navigation' : 'modal');
+ return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal();
+}
+
+const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage';
+
+function displayName(user: UserResource): string {
+ return getFullName(user) || getIdentifier(user);
+}
+
+function toMembership(organization: OrganizationResource): UserButtonMembership {
+ return {
+ kind: 'membership',
+ organizationId: organization.id,
+ name: organization.name,
+ imageUrl: organization.imageUrl || undefined,
+ membersCount: organization.membersCount,
+ };
+}
+
+function toSession(sessionId: string, user: UserResource): UserButtonSession {
+ return {
+ sessionId,
+ name: displayName(user),
+ identifier: getIdentifier(user),
+ imageUrl: user.imageUrl,
+ };
+}
+
+/**
+ * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's
+ * DOM-callback form. The wrapper owns that conversion because it is the layer that can render
+ * the portals behind it, so they arrive here ready to forward and stay out of the public options.
+ */
+export function useUserButtonModel(
+ options?: UserButtonModelOptions,
+ userProfileCustomPages?: CustomPage[],
+): UserButtonModel {
+ const { isLoaded: isUserLoaded, user } = useUser();
+ const { isLoaded: isSessionLoaded, session } = useSession();
+ // The active org names the trigger. That is not a request to turn Organizations on.
+ const { isLoaded: isOrgLoaded, organization } = useOrganization({
+ __internal_skipAttemptToEnableOrganizations: true,
+ });
+ const clerk = useClerk();
+ const router = useMosaicRouter();
+ // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
+ const getContainer = usePortalRoot();
+ const environment = useMosaicEnvironment();
+ // Don't fetch orgsLists until we know orgs are enabled.
+ // This wont delay rendering of the trigger, or even the popup shell, since the "ready" status
+ // does not depend on this.
+ const { userMemberships, userInvitations, userSuggestions, ref } = useOrganizationListInView({
+ enabled: Boolean(environment?.organizationSettings.enabled),
+ });
+
+ const manageAccount = openOrNavigate({
+ url: options?.userProfileUrl,
+ mode: options?.userProfileMode,
+ openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }),
+ buildUrl: () => clerk.buildUserProfileUrl(),
+ navigate: router.navigate,
+ });
+
+ const manageOrganization = openOrNavigate({
+ url: options?.organizationProfileUrl,
+ mode: options?.organizationProfileMode,
+ openModal: () => clerk.openOrganizationProfile({ getContainer }),
+ buildUrl: () => clerk.buildOrganizationProfileUrl(),
+ navigate: router.navigate,
+ });
+
+ const createOrganization = openOrNavigate({
+ url: options?.createOrganizationUrl,
+ mode: options?.createOrganizationMode,
+ openModal: () => clerk.openCreateOrganization({ getContainer }),
+ buildUrl: () => clerk.buildCreateOrganizationUrl(),
+ navigate: router.navigate,
+ });
+
+ // These all affect layout, so wait for every one and avoid a reshuffle.
+ if (!isUserLoaded || !isSessionLoaded || !isOrgLoaded || !environment) {
+ return { status: 'loading' };
+ }
+
+ if (!user || !session) {
+ return { status: 'hidden' };
+ }
+
+ const { displayConfig, authConfig, organizationSettings } = environment;
+ // clerk-js refuses `setActive({ organization: null })` when selection is forced, so there is no way back.
+ const { enabled: organizationsEnabled, forceOrganizationSelection } = organizationSettings;
+ const { singleSessionMode } = authConfig;
+
+ const canInviteMembers = session.checkAuthorization({ permission: INVITE_MEMBERS_PERMISSION }) ?? false;
+ const membershipData = userMemberships.data ?? [];
+ const suggestionData = userSuggestions.data ?? [];
+ const invitationData = userInvitations.data ?? [];
+
+ const memberships: UserButtonMembership[] = membershipData.map(m => toMembership(m.organization));
+
+ const suggestions: UserButtonSuggestion[] = suggestionData.map(s => ({
+ kind: 'suggestion',
+ id: s.id,
+ organizationId: s.publicOrganizationData.id,
+ name: s.publicOrganizationData.name,
+ imageUrl: s.publicOrganizationData.imageUrl || undefined,
+ status: s.status,
+ }));
+
+ // Accepting is all a row offers, so a revoked or expired invitation has nothing to show.
+ const invitations: UserButtonInvitation[] = invitationData.flatMap(i =>
+ i.status === 'pending' || i.status === 'accepted'
+ ? [
+ {
+ kind: 'invitation',
+ id: i.id,
+ status: i.status,
+ organizationId: i.publicOrganizationData.id,
+ organizationName: i.publicOrganizationData.name,
+ imageUrl: i.publicOrganizationData.imageUrl || undefined,
+ },
+ ]
+ : [],
+ );
+
+ // Organization requests are scoped to the active session, so another account's workspaces are unknowable.
+ const additionalSessions: UserButtonSession[] = (clerk.client?.signedInSessions ?? []).flatMap(s => {
+ const sessionUser = s.user;
+ if (!sessionUser || s.id === session.id) {
+ return [];
+ }
+ return [toSession(s.id, sessionUser)];
+ });
+
+ const afterSelectUrl = (organizationId: string | null): string | undefined => {
+ if (!organizationId) {
+ return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user);
+ }
+ const selected = membershipData.find(m => m.organization.id === organizationId)?.organization;
+ return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined;
+ };
+
+ return {
+ status: 'ready',
+ organizationsEnabled,
+ renderBranding: displayConfig.branded,
+ activeSession: toSession(session.id, user),
+ activeOrganization: organization ? toMembership(organization) : null,
+ // The user resource settles this before the paginated list answers; the count covers a stale resource.
+ hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0,
+ hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false),
+ // Only true before the first page lands, which is the one window where empty and pending look alike.
+ organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading,
+ memberships,
+ suggestions,
+ invitations,
+ additionalSessions,
+ paging: {
+ ref,
+ hasMore: Boolean(userMemberships.hasNextPage || userInvitations.hasNextPage || userSuggestions.hasNextPage),
+ },
+ onSelectOrganization: organizationId =>
+ clerk.setActive({ organization: organizationId, redirectUrl: afterSelectUrl(organizationId) }),
+ // The session switched to can carry a task of its own, and a plain `redirectUrl` routes past it.
+ // App-level `taskUrls` outrank this callback, so it only answers for an app that set none.
+ onSwitchSession: sessionId =>
+ clerk.setActive({
+ session: sessionId,
+ navigate: async ({ session, decorateUrl }) => {
+ const task = session.currentTask;
+ if (task) {
+ await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() }));
+ return;
+ }
+ // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it.
+ await router.navigate(decorateUrl(displayConfig.afterSwitchSessionUrl));
+ },
+ }),
+ onSignOutSession: sessionId =>
+ clerk.signOut({
+ sessionId,
+ // Other accounts stay signed in, so this is a single sign out rather than a full one.
+ redirectUrl:
+ additionalSessions.length > 0 ? clerk.buildAfterMultiSessionSingleSignOutUrl() : clerk.buildAfterSignOutUrl(),
+ }),
+ // Single-session apps cannot hold a second account, so both actions are meaningless there.
+ onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }),
+ onManageAccount: manageAccount,
+ onManageOrganization: manageOrganization,
+ // Invite has no page of its own to route to, so it opens its modal even when management is routed.
+ onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined,
+ // Covers both restricted instances and users at their creation limit.
+ onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined,
+ onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()),
+ onAcceptSuggestion: suggestionId => {
+ const suggestion = suggestionData.find(s => s.id === suggestionId);
+ return Promise.resolve(suggestion?.accept()).finally(() => void userSuggestions.revalidate?.());
+ },
+ // Accepting joins the organization, so memberships are stale too. A suggestion joins nothing.
+ onAcceptInvitation: invitationId => {
+ const invitation = invitationData.find(i => i.id === invitationId);
+ return Promise.resolve(invitation?.accept()).finally(() => {
+ void userInvitations.revalidate?.();
+ void userMemberships.revalidate?.();
+ });
+ },
+ };
+}
diff --git a/packages/ui/src/mosaic/user-button/user-button.pages.tsx b/packages/ui/src/mosaic/user-button/user-button.pages.tsx
new file mode 100644
index 00000000000..070444d9d74
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.pages.tsx
@@ -0,0 +1,160 @@
+import {
+ disabledUserAPIKeysFeature,
+ disabledUserBillingFeature,
+} from '@clerk/shared/internal/clerk-js/componentGuards';
+import { useClerk } from '@clerk/shared/react';
+import type { CustomPage } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
+import { useCallback, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment';
+import { applyOrder } from './user-button.utils';
+
+/** A page the UserProfile brings itself, named by the id its navigation knows it as. */
+export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys';
+
+/**
+ * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned
+ * off.
+ *
+ * Ordering a custom page after a built-in one means naming every built-in that follows it, so the
+ * list has to match what the profile will actually show. It mirrors clerk-js rather than being read
+ * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages
+ * from the same environment behind the same guards.
+ */
+export function useUserProfilePages(): UserProfilePageId[] {
+ const clerk = useClerk();
+ const environment = useMosaicEnvironment();
+
+ const pages: UserProfilePageId[] = ['account', 'security'];
+ if (!disabledUserBillingFeature(clerk, environment)) {
+ pages.push('billing');
+ }
+ if (!disabledUserAPIKeysFeature(clerk, environment)) {
+ pages.push('apiKeys');
+ }
+ return pages;
+}
+
+/** A page of your own inside the profile, reached from its navigation. */
+export interface CustomProfilePage {
+ /** Names the page in the profile's navigation. */
+ label: string;
+ /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */
+ path: string;
+ href?: never;
+ icon?: ReactNode;
+ /** Rendered as the page itself. */
+ content: ReactNode;
+}
+
+/** A row in the profile's navigation that leaves for somewhere else. */
+export interface CustomProfileLink {
+ /** Names the row in the profile's navigation. */
+ label: string;
+ /** Identifies the row, for ordering. */
+ path: string;
+ /** Where the row goes. */
+ href: string;
+ icon?: ReactNode;
+ content?: never;
+}
+
+export type CustomProfileItem = CustomProfilePage | CustomProfileLink;
+
+export interface CustomPagesOptions {
+ /** Pages and links of the consumer's own. */
+ items: CustomProfileItem[] | undefined;
+ /** The order the profile's navigation should run in, by id. */
+ order: readonly string[] | undefined;
+ /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */
+ builtInPages: readonly string[];
+}
+
+export interface CustomPagesBridge {
+ /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */
+ customPages: CustomPage[] | undefined;
+ /** Render these for as long as the profile can be open, or its pages come up blank. */
+ portals: ReactNode[];
+}
+
+const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined;
+
+function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode {
+ const container = containers.get(id);
+ return container ? createPortal(node, container, id) : null;
+}
+
+/**
+ * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes.
+ *
+ * The profile opens in clerk-js's own React root, which cannot render a node from the host app's
+ * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the
+ * page belongs and hands it over, and the host tree portals the content into it from here. The
+ * portals therefore have to stay mounted in the host tree the whole time the profile is open, which
+ * is why they come back out rather than being rendered here.
+ *
+ * This is the shape of the bridge only for as long as the profile renders outside the host tree. A
+ * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the
+ * props a consumer writes.
+ */
+export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge {
+ const [containers, setContainers] = useState>(new Map());
+
+ // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep
+ // working: the portal re-reads its container from state on every render of the host tree.
+ const bind = useCallback(
+ (id: string) => ({
+ mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)),
+ unmount: () =>
+ setContainers(prev => {
+ const next = new Map(prev);
+ next.delete(id);
+ return next;
+ }),
+ }),
+ [],
+ );
+
+ const byId = new Map((items ?? []).map(item => [item.path, item]));
+ // clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a
+ // built-in left out of the order has to be sent anyway to keep it behind the pages that were named.
+ // Without an order there is nothing to hold in place, so only the custom pages go out.
+ const ids = order?.length ? applyOrder(order, [...builtInPages, ...byId.keys()], id => id) : [...byId.keys()];
+
+ if (!ids.length) {
+ return { customPages: undefined, portals: [] };
+ }
+
+ const customPages = ids.map(id => {
+ const item = byId.get(id);
+ // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and
+ // it reads as a custom page instead.
+ if (!item) {
+ return { label: id };
+ }
+
+ // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an
+ // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put
+ // through them; without them, leaving `icon` off would silently cost you the page.
+ const icon = bind(`icon:${id}`);
+ const content = isLink(item) ? undefined : bind(`content:${id}`);
+
+ return {
+ label: item.label,
+ // A page is routed to by its path; a link is followed to wherever it points.
+ url: isLink(item) ? item.href : item.path,
+ mountIcon: icon.mount,
+ unmountIcon: icon.unmount,
+ ...(content && { mount: content.mount, unmount: content.unmount }),
+ };
+ });
+
+ const portals = (items ?? []).flatMap(item => [
+ portalInto(containers, `icon:${item.path}`, item.icon),
+ ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]),
+ ]);
+
+ return { customPages, portals };
+}
diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx
index 554779a3187..60f2094ceb3 100644
--- a/packages/ui/src/mosaic/user-button/user-button.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -1,19 +1,34 @@
'use client';
import type { ReactElement, ReactNode } from 'react';
-import { useState } from 'react';
-import { useSpinDelay } from '../hooks/useSpinDelay';
-import { type UserButtonControllerOptions, useUserButtonController } from './user-button.controller';
+import { useUserButtonController } from './user-button.controller';
+import type { UserButtonModelOptions } from './user-button.model';
+import { useUserButtonModel } from './user-button.model';
+import type { CustomProfileItem, UserProfilePageId } from './user-button.pages';
+import { useCustomPages, useUserProfilePages } from './user-button.pages';
import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types';
import type { UserButtonTriggerProps } from './user-button.view';
-import { userButtonBusyKeys, UserButtonView } from './user-button.view';
+import { UserButtonView } from './user-button.view';
-/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */
-export type UserButtonProps = UserButtonControllerOptions &
+/** Configures the UserProfile this button opens. */
+export interface UserButtonUserProfileProps {
+ /** Pages and links of your own, added to the profile's navigation. */
+ customPages?: CustomProfileItem[];
+ /**
+ * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's
+ * `path`. Anything left out follows the pages named here. The first page is the one the profile
+ * opens on, so it cannot be a link.
+ */
+ pageOrder?: (UserProfilePageId | (string & {}))[];
+}
+
+/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */
+export type UserButtonProps = UserButtonModelOptions &
UserButtonTriggerProps &
UserButtonMenuProps &
- Pick & {
+ UserButtonModeProps & {
+ userProfileProps?: UserButtonUserProfileProps;
/**
* Stands in while Clerk is still answering, so the space the button will take is held rather
* than appearing under whatever is beside it. Dropped once nobody is signed in, since that is
@@ -38,9 +53,11 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*
* @example
- * `modePriority` picks which switcher the menu leads with — in its header, and in the trigger beside
- * the avatar. The other one is still listed.
+ * `mode` narrows the menu to one switcher, and `modePriority` picks which one a combined menu leads
+ * with — in its header, and in the trigger beside the avatar. The other one is still listed.
* ```tsx
+ *
+ *
*
* ```
*
@@ -64,10 +81,14 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*
* @example
- * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action
- * or an `href` link, and `menuItemOrder` names the order the foot's rows run in.
+ * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your
+ * own rows to the foot of the menu, each one either an `onClick` action or an `href` link.
* ```tsx
* , content: }],
+ * pageOrder: ['account', 'usage', 'security'],
+ * }}
* customMenuItems={[
* { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' },
* { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() },
@@ -77,85 +98,53 @@ export type UserButtonProps = UserButtonControllerOptions &
* ```
*/
export function UserButton(props: UserButtonProps = {}): ReactElement | null {
- const { renderTriggerLabel, renderTriggerBadge, modePriority, customMenuItems, menuItemOrder, fallback, ...options } =
- props;
- const controller = useUserButtonController(options);
- const [open, setOpen] = useState(false);
- const [pendingKey, setPendingKey] = useState(null);
-
- // Re-entry is guarded on the immediate `pendingKey`; only the view's feedback is delayed.
- const displayPendingKey = useSpinDelay(pendingKey);
+ const {
+ renderTriggerLabel,
+ renderTriggerBadge,
+ mode,
+ modePriority,
+ userProfileProps,
+ customMenuItems,
+ menuItemOrder,
+ fallback,
+ ...options
+ } = props;
+ // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered
+ // from here. They have to outlive the popover that opened it, and the button's own data with it,
+ // which is why they hang off the wrapper rather than anything the popover renders.
+ const builtInPages = useUserProfilePages();
+ const { customPages, portals } = useCustomPages({
+ items: userProfileProps?.customPages,
+ order: userProfileProps?.pageOrder,
+ builtInPages,
+ });
+ const model = useUserButtonModel(options, customPages);
+ const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder });
if (controller.status === 'loading') {
- return <>{fallback}>;
+ return (
+ <>
+ {fallback}
+ {portals}
+ >
+ );
}
// Signed out is an answer, so the placeholder goes too rather than promising a button.
if (controller.status === 'hidden') {
- return null;
+ return <>{portals}>;
}
- const close = () => setOpen(false);
-
- // Whatever the app's action opens takes over from here, so the popover goes with it. Links navigate away.
- const menuItems = customMenuItems?.map(item =>
- item.href === undefined
- ? {
- ...item,
- onClick: () => {
- close();
- item.onClick();
- },
- }
- : item,
- );
-
- // Only an action that ends the interaction closes the popover; the rest resolve into it.
- const runAction = (
- keyFor: (...args: Args) => string,
- fn: ((...args: Args) => void | Promise) | undefined,
- closeOnSuccess = false,
- ) =>
- fn
- ? (...args: Args) => {
- if (pendingKey) {
- return;
- }
- setPendingKey(keyFor(...args));
- void Promise.resolve(fn(...args))
- .then(closeOnSuccess ? close : () => {}, () => {})
- .finally(() => setPendingKey(null));
- }
- : undefined;
-
- const {
- status: _status,
- onSelectOrganization,
- onSwitchSession,
- onSignOutSession,
- onSignOutAll,
- onAcceptSuggestion,
- onAcceptInvitation,
- ...data
- } = controller;
+ const { status: _status, ...viewController } = controller;
return (
-
+ <>
+
+ {portals}
+ >
);
}
diff --git a/packages/ui/src/mosaic/user-button/user-button.types.ts b/packages/ui/src/mosaic/user-button/user-button.types.ts
index 26f9e5f9d1f..e2ff5df3728 100644
--- a/packages/ui/src/mosaic/user-button/user-button.types.ts
+++ b/packages/ui/src/mosaic/user-button/user-button.types.ts
@@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
// ─── Data contract ──────────────────────────────────────────────────────────
-// Session-backed, discriminated resource rows. 1:1 with `useUserButtonController()`'s output, so the
-// controller and the view agree on a shape neither one owns.
+// Session-backed, discriminated resource rows. 1:1 with `useUserButtonModel()`'s output, so the
+// model and the view agree on a shape neither one owns.
export interface UserButtonSession {
sessionId: string;
diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx
index edfef936a06..323c937d2fd 100644
--- a/packages/ui/src/mosaic/user-button/user-button.view.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx
@@ -38,7 +38,7 @@ import type {
import { applyOrder } from './user-button.utils';
// The data contract, the mode flags, and the menu item shapes live in `user-button.types`; they are
-// what the controller and the view agree on, so neither file owns them.
+// what the model and the view agree on, so neither file owns them.
export type * from './user-button.types';
/**