diff --git a/.changeset/mosaic-user-button-controller.md b/.changeset/mosaic-user-button-controller.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-button-controller.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/mosaic-user-button-integration.md b/.changeset/mosaic-user-button-integration.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-button-integration.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts
index 68a178b90dd..f4452d21b4d 100644
--- a/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts
+++ b/packages/shared/src/react/hooks/useAttemptToEnableOrganizations.ts
@@ -7,13 +7,16 @@ import { useClerk } from './useClerk';
*
* @internal
*/
-export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'useOrganizationList') {
+export function useAttemptToEnableOrganizations(
+ caller: 'useOrganization' | 'useOrganizationList',
+ { enabled = true }: { enabled?: boolean } = {},
+) {
const clerk = useClerk();
const hasAttempted = useRef(false);
useEffect(() => {
// Guard to not run this effect twice on Clerk resource update
- if (hasAttempted.current) {
+ if (!enabled || hasAttempted.current) {
return;
}
@@ -23,5 +26,5 @@ export function useAttemptToEnableOrganizations(caller: 'useOrganization' | 'use
for: 'organizations',
caller,
});
- }, [clerk, caller]);
+ }, [clerk, caller, enabled]);
}
diff --git a/packages/shared/src/react/hooks/useOrganization.tsx b/packages/shared/src/react/hooks/useOrganization.tsx
index 619230df9b2..cec686047d0 100644
--- a/packages/shared/src/react/hooks/useOrganization.tsx
+++ b/packages/shared/src/react/hooks/useOrganization.tsx
@@ -61,6 +61,12 @@ export type UseOrganizationParams = {
*
*/
invitations?: true | PaginatedHookConfig;
+ /**
+ * Skip the development prompt that offers to enable Organizations.
+ *
+ * @internal
+ */
+ __internal_skipAttemptToEnableOrganizations?: boolean;
};
/**
@@ -275,10 +281,13 @@ export function useOrganization(params?: T): Us
membershipRequests: membershipRequestsListParams,
memberships: membersListParams,
invitations: invitationsListParams,
+ __internal_skipAttemptToEnableOrganizations,
} = params || {};
useAssertWrappedByClerkProvider('useOrganization');
- useAttemptToEnableOrganizations('useOrganization');
+ useAttemptToEnableOrganizations('useOrganization', {
+ enabled: !__internal_skipAttemptToEnableOrganizations,
+ });
const organization = useOrganizationBase();
const session = useSessionBase();
diff --git a/packages/shared/src/react/hooks/useOrganizationList.tsx b/packages/shared/src/react/hooks/useOrganizationList.tsx
index e50fab1e88e..0872adff464 100644
--- a/packages/shared/src/react/hooks/useOrganizationList.tsx
+++ b/packages/shared/src/react/hooks/useOrganizationList.tsx
@@ -253,7 +253,10 @@ export function useOrganizationList(params?
const { userMemberships, userInvitations, userSuggestions } = params || {};
useAssertWrappedByClerkProvider('useOrganizationList');
- useAttemptToEnableOrganizations('useOrganizationList');
+ // No list keys means this call is not using Organizations; the prompt is for the lists.
+ useAttemptToEnableOrganizations('useOrganizationList', {
+ enabled: userMemberships !== undefined || userInvitations !== undefined || userSuggestions !== undefined,
+ });
const userMembershipsSafeValues = useWithSafeValues(userMemberships, {
initialPage: 1,
diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx
index a4068ee6b14..f32bbdc1671 100644
--- a/packages/swingset/src/stories/user-button.mdx
+++ b/packages/swingset/src/stories/user-button.mdx
@@ -74,7 +74,7 @@ Exports are flat (not `UserButton.Trigger`) so each part declares its own `'use
## Trigger
The active workspace's avatar and what it is called: the org and its plan wherever one heads the
-trigger, the account otherwise.
+trigger, no selection when personal is hidden and none is active, the account otherwise.
`renderTriggerLabel={false}` leaves the avatar alone.
@@ -164,6 +164,18 @@ accounts" ever meant here.
storyModule={UserButtonStories}
/>
+## No organization selected
+
+`hidePersonal` withholds the personal workspace — the instance does this when it forces an
+organization, or the app does it itself. With no org active either, the lead is not the account:
+trigger and header say **No organization selected**, the mark is square, and the gear is **Manage
+account**.
+
+
+
## Menu items
`customMenuItems` adds the app's own rows to the foot of the popup, ahead of Clerk's own. A row with
diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx
index 7997b90e03e..3bb74cb952e 100644
--- a/packages/swingset/src/stories/user-button.stories.tsx
+++ b/packages/swingset/src/stories/user-button.stories.tsx
@@ -143,9 +143,19 @@ const LATENCY_MS = 800;
* The actions that would navigate somewhere in a real app (Manage, Invite, Create organization, Add
* account) have nowhere to go here, so they only close the popover.
*/
-function usePrototype(): Omit {
+function usePrototype({
+ hidePersonal = false,
+ startWithoutOrganization = false,
+}: {
+ hidePersonal?: boolean;
+ startWithoutOrganization?: boolean;
+} = {}): Omit {
const [open, setOpen] = useState(false);
- const [accounts, setAccounts] = useState(initialAccounts);
+ const [accounts, setAccounts] = useState(() =>
+ startWithoutOrganization
+ ? initialAccounts.map(a => (a.session.sessionId === colin.sessionId ? { ...a, activeOrganizationId: null } : a))
+ : initialAccounts,
+ );
const [activeSessionId, setActiveSessionId] = useState(colin.sessionId);
const [pendingKey, setPendingKey] = useState(null);
@@ -196,6 +206,7 @@ function usePrototype(): Omit {
suggestions: account.suggestions,
invitations: account.invitations,
additionalSessions: accounts.filter(a => a.session.sessionId !== activeSessionId).map(a => a.session),
+ hidePersonal,
// Selecting an organization only ever acts on the active account, and is the one action that
// closes the surface behind it.
onSelectOrganization: organizationId =>
@@ -300,6 +311,19 @@ export function User(_args: Record) {
);
}
+export function NoOrganizationSelected(_args: Record) {
+ const prototype = usePrototype({ hidePersonal: true, startWithoutOrganization: true });
+
+ // Personal is withheld and nothing is active, so the lead is no selection — not the account.
+ // Picking an organization leaves it.
+ return (
+
+ );
+}
+
export function SingleSession(_args: Record) {
const prototype = usePrototype();
diff --git a/packages/ui/src/hooks/useOrganizationListInView.ts b/packages/ui/src/hooks/useOrganizationListInView.ts
index 6f3bd7f36d9..9de803e3db8 100644
--- a/packages/ui/src/hooks/useOrganizationListInView.ts
+++ b/packages/ui/src/hooks/useOrganizationListInView.ts
@@ -5,14 +5,18 @@ import { useInView } from './useInView';
/**
* @internal
+ *
+ * `enabled` withholds the list params so the three requests do not start. Defaults on.
*/
-export const useOrganizationListInView = () => {
- const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
+export const useOrganizationListInView = ({ enabled = true }: { enabled?: boolean } = {}) => {
+ const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(
+ enabled ? organizationListParams : undefined,
+ );
const { ref } = useInView({
threshold: 0,
onChange: inView => {
- if (!inView) {
+ if (!enabled || !inView) {
return;
}
if (userMemberships.hasNextPage) {
diff --git a/packages/ui/src/mosaic/components/button/submit-button.test.tsx b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
index a7fbca36ba8..c9ce86b091f 100644
--- a/packages/ui/src/mosaic/components/button/submit-button.test.tsx
+++ b/packages/ui/src/mosaic/components/button/submit-button.test.tsx
@@ -317,19 +317,29 @@ describe('Mosaic SubmitButton spin delay', () => {
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});
- // A consumer who already knows the action is slow has nothing to gain by waiting.
+ // A consumer who already knows the action is slow has nothing to gain by waiting: there is no
+ // delay left to outlast, so the spinner shows in the render that starts the action rather than a
+ // timer's.
it('lets the consumer opt out of the delay', () => {
- render(
+ const { rerender } = render(
Save
,
);
const hidden = atoms(spinner());
- advance(0);
+ rerender(
+
+ Save
+ ,
+ );
+
expect(atoms(spinner()).length).toBeLessThan(hidden.length);
});
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
index 66052c26835..3ac3f63839e 100644
--- a/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
+++ b/packages/ui/src/mosaic/hooks/__tests__/useSpinDelay.test.ts
@@ -76,6 +76,27 @@ describe('useSpinDelay', () => {
expect(result.current).toBeNull();
});
+ // Direct feedback on a click has nothing to debounce, so a zero delay must not cost a timer's
+ // worth of render passes before the spinner appears.
+ it('surfaces the value in the same pass when there is no delay to wait out', async () => {
+ const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+
+ expect(result.current).toBe('a');
+ });
+
+ it('still holds a zero-delay value for minDuration', async () => {
+ const { result, rerender } = render(null, { delay: 0, minDuration: 200 });
+ await act(() => rerender({ value: 'a' }));
+ await act(() => rerender({ value: null }));
+
+ await advance(199);
+ expect(result.current).toBe('a');
+
+ await advance(1);
+ expect(result.current).toBeNull();
+ });
+
it('swaps to a new value immediately when one replaces another mid-show', async () => {
const { result, rerender } = render(null, { delay: 500, minDuration: 200 });
await act(() => rerender({ value: 'a' }));
diff --git a/packages/ui/src/mosaic/hooks/useSpinDelay.ts b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
index b847c0bc517..dac6bc682b7 100644
--- a/packages/ui/src/mosaic/hooks/useSpinDelay.ts
+++ b/packages/ui/src/mosaic/hooks/useSpinDelay.ts
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
export interface SpinDelayOptions {
- /** Wait this long before showing the value, so quick actions never flash a spinner. */
+ /** Wait this long before showing the value, so quick actions never flash a spinner. `0` shows it straight away. */
delay?: number;
/** Once shown, keep the value up at least this long, so the spinner never flickers off. */
minDuration?: number;
@@ -25,11 +25,17 @@ export function useSpinDelay(value: T | null, options: SpinDelayOptions = {})
const shownAt = useRef(0);
useEffect(() => {
- // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`.
+ // Nothing showing yet: arm a timer so the value only surfaces if it outlasts `delay`. With no
+ // delay there is nothing to outlast, so it surfaces in this pass rather than a timer's.
if (shown === null) {
if (value === null) {
return;
}
+ if (delay <= 0) {
+ shownAt.current = Date.now();
+ setShown(value);
+ return;
+ }
const timer = setTimeout(() => {
shownAt.current = Date.now();
setShown(value);
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
new file mode 100644
index 00000000000..726b6ccb56c
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
@@ -0,0 +1,285 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { deferred, tick } from '../../machines/__tests__/test-utils';
+import type { UserButtonControllerOptions, UserButtonReadyModel } from '../user-button.controller';
+import { useUserButtonController } from '../user-button.controller';
+import type { UserButtonModel } from '../user-button.model';
+
+function ready(overrides: Partial = {}): UserButtonReadyModel {
+ return {
+ status: 'ready',
+ organizationsEnabled: true,
+ renderBranding: true,
+ activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' },
+ activeOrganization: null,
+ hasOrganizations: false,
+ hidePersonal: false,
+ organizationsLoading: false,
+ memberships: [],
+ suggestions: [],
+ invitations: [],
+ additionalSessions: [],
+ ...overrides,
+ };
+}
+
+function Harness({ model, ...options }: { model: UserButtonModel } & UserButtonControllerOptions) {
+ const c = useUserButtonController(model, options);
+ 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');
+ });
+
+ it('does not navigate after a session switch when no after-switch URL is set', async () => {
+ afterSwitchSessionUrl = '';
+ render();
+ fireEvent.click(screen.getByText('switch'));
+
+ const navigateOnSetActive = setActive.mock.calls[0][0].navigate;
+ const decorateUrl = vi.fn((url: string) => url);
+ await act(async () => {
+ await navigateOnSetActive({ session: { currentTask: null }, decorateUrl });
+ });
+
+ expect(navigate).not.toHaveBeenCalled();
+ expect(decorateUrl).not.toHaveBeenCalled();
+ });
+
+ it('prefers the afterSwitchSessionUrl prop over the instance URL', async () => {
+ render();
+ fireEvent.click(screen.getByText('switch'));
+
+ const navigateOnSetActive = setActive.mock.calls[0][0].navigate;
+ const decorateUrl = vi.fn((url: string) => url);
+ await act(async () => {
+ await navigateOnSetActive({ session: { currentTask: null }, decorateUrl });
+ });
+
+ expect(navigate).toHaveBeenCalledWith('/app-switch');
+ expect(decorateUrl).toHaveBeenCalledWith('/app-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);
+ });
+
+ it('does not treat a failed list refresh as a failed accept', async () => {
+ userInvitations.revalidate.mockRejectedValueOnce(new Error('stale'));
+ userMemberships.revalidate.mockRejectedValueOnce(new Error('stale'));
+ userSuggestions.revalidate.mockRejectedValueOnce(new Error('stale'));
+ const { result } = renderHook(() => useUserButtonModel());
+ if (result.current.status !== 'ready') {
+ throw new Error('expected ready');
+ }
+
+ await expect(result.current.onAcceptInvitation?.('inv_1')).resolves.toBeUndefined();
+ await expect(result.current.onAcceptSuggestion?.('sug_1')).resolves.toBeUndefined();
+ });
+});
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
new file mode 100644
index 00000000000..58af0503188
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx
@@ -0,0 +1,99 @@
+import { render, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { UserButton } from '../user-button';
+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 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', () => ({
+ UserButtonView: () => ,
+}));
+
+function ready(): UserButtonController {
+ return {
+ status: 'ready',
+ renderBranding: true,
+ activeSession: { sessionId: 'sess_1', name: 'Alice Smith', identifier: 'alice@example.com' },
+ activeOrganization: null,
+ hasOrganizations: false,
+ hidePersonal: false,
+ organizationsLoading: false,
+ memberships: [],
+ suggestions: [],
+ invitations: [],
+ additionalSessions: [],
+ };
+}
+
+describe('UserButton', () => {
+ beforeEach(() => {
+ controller = { status: 'loading' };
+ });
+
+ it('stands the fallback in while Clerk is still answering', () => {
+ render(} />);
+ expect(screen.getByTestId('fallback')).toBeInTheDocument();
+ expect(screen.queryByTestId('view')).not.toBeInTheDocument();
+ });
+
+ // Signing out is an answer, not a wait. Holding the placeholder there would promise a button to
+ // someone who is never going to get one.
+ it('drops the fallback once nobody is signed in', () => {
+ controller = { status: 'hidden' };
+ render(} />);
+ expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('view')).not.toBeInTheDocument();
+ });
+
+ it('renders the surface once the session is ready', () => {
+ controller = ready();
+ render(} />);
+ expect(screen.getByTestId('view')).toBeInTheDocument();
+ expect(screen.queryByTestId('fallback')).not.toBeInTheDocument();
+ });
+
+ 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/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx
index fa480affdf0..3d6ffbb851c 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx
@@ -199,6 +199,17 @@ describe('UserButtonView, organization mode', () => {
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
});
+ // `hidePersonal` withholds the workspace, so a missing org is no selection — not the account.
+ it('names no organization selected where personal is hidden and none is active', () => {
+ renderOrganizationMode({ hidePersonal: true, activeOrganization: null });
+
+ expect(within(groups()[0]).getByText('No organization selected')).toBeInTheDocument();
+ expect(screen.queryByText('Alice Smith')).toBeNull();
+ expect(screen.getByRole('button', { name: 'Manage account' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Manage organization' })).toBeNull();
+ });
+
// The header acts on the active organization, which is known whole before the list it belongs to
// lands. Invite and the gear act on the same organization, so they answer together.
it('offers to invite while the membership list is still in flight', () => {
@@ -811,6 +822,20 @@ describe('UserButtonTrigger', () => {
expect(screen.queryByText('Pro')).toBeNull();
});
+ it('names no organization selected where personal is hidden and none is active', () => {
+ renderTrigger({ mode: 'organization', hidePersonal: true, activeOrganization: null });
+
+ expect(screen.getByText('No organization selected')).toBeInTheDocument();
+ expect(screen.queryByText('Alice Smith')).toBeNull();
+ });
+
+ it('still names the account in user mode when personal is hidden and none is active', () => {
+ renderTrigger({ mode: 'user', hidePersonal: true, activeOrganization: null });
+
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
+ expect(screen.queryByText('No organization selected')).toBeNull();
+ });
+
it('names the active organization in combined mode', () => {
renderTrigger({ mode: 'combined' });
diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
new file mode 100644
index 00000000000..e91242cc9ff
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
@@ -0,0 +1,223 @@
+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 {
+ /** Independent of the action: dismissing must not abandon an in-flight invoke. */
+ open: boolean;
+ /** 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.
+ */
+ frozenModel: 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;
+}
+
+type UserButtonMachineEvent =
+ | { type: 'OPEN' }
+ | { type: 'CLOSE' }
+ | {
+ type: 'RUN';
+ key: string;
+ frozenModel: UserButtonReadyModel;
+ run: () => Promise;
+ closeOnSuccess: boolean;
+ };
+
+const { createMachine, assign, fromPromise } = setup();
+
+const settled = { pendingKey: null, frozenModel: null };
+
+const userButtonMachine = createMachine({
+ id: 'userButton',
+ initial: 'idle',
+ context: {
+ open: false,
+ pendingKey: null,
+ frozenModel: null,
+ run: () => Promise.resolve(),
+ closeOnSuccess: false,
+ },
+ states: {
+ idle: {
+ on: {
+ OPEN: { actions: assign(() => ({ open: true })) },
+ CLOSE: { actions: assign(() => ({ open: false })) },
+ RUN: {
+ target: 'busy',
+ guard: context => context.open,
+ actions: assign((_, event) => ({
+ pendingKey: event.key,
+ frozenModel: event.frozenModel,
+ run: event.run,
+ closeOnSuccess: event.closeOnSuccess,
+ })),
+ },
+ },
+ },
+ // OPEN/CLOSE have no target so they do not leave this state and abandon the invoke.
+ busy: {
+ on: {
+ OPEN: { actions: assign(() => ({ open: true })) },
+ CLOSE: { actions: assign(() => ({ open: false })) },
+ },
+ invoke: fromPromise(context => context.run(), {
+ onDone: [
+ {
+ target: 'idle',
+ guard: context => context.closeOnSuccess,
+ actions: assign(() => ({ ...settled, open: false })),
+ },
+ { target: 'idle', actions: assign(() => settled) },
+ ],
+ // Leave `open` as the user left it. The error surface is a later change.
+ onError: { target: 'idle', actions: assign(() => settled) },
+ }),
+ },
+ },
+});
+
+export type UserButtonControllerOptions = Pick & UserButtonMenuProps;
+
+export type UserButtonController =
+ | { status: 'loading' }
+ | { status: 'hidden' }
+ | ({ 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;
+ const [{ 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,
+ });
+
+ // If an action is pending and the model is frozen, we use the status of the frozen model.
+ // This prevents the fallback from showing when we revert from ready->loading during Clerks
+ // transitive state.
+ const resolvedModel = context.frozenModel ?? model;
+ if (resolvedModel.status !== 'ready') {
+ return { status: resolvedModel.status };
+ }
+
+ const close = () => send({ type: 'CLOSE' });
+
+ const menuItems = customMenuItems?.map(item =>
+ item.href === undefined
+ ? {
+ ...item,
+ onClick: () => {
+ // Always close the menu for custom actions
+ close();
+ item.onClick();
+ },
+ }
+ : item,
+ );
+
+ // 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),
+ // RUN can only happen from a idle state, so this should always
+ // resolve the actual current model, not a previously frozen
+ // one. If the logic later changes so RUN can happen outside of
+ // idle, using the resolvedModel here means we keep using the
+ // first captured frozen model until all actions settle.
+ frozenModel: resolvedModel,
+ 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 `frozenModel` in the machine for why.
+ const {
+ status: _status,
+ organizationsEnabled,
+ onSelectOrganization,
+ onSwitchSession,
+ onSignOutSession,
+ onSignOutAll,
+ onAcceptSuggestion,
+ onAcceptInvitation,
+ onManageAccount,
+ onManageOrganization,
+ onInviteMembers,
+ onCreateOrganization,
+ onAddAccount,
+ ...data
+ } = resolvedModel;
+
+ // Force user mode if organizations are disabled
+ const mode = organizationsEnabled ? requestedMode : 'user';
+
+ return {
+ status: 'ready',
+ ...data,
+ mode,
+ modePriority,
+ customMenuItems: menuItems,
+ menuItemOrder,
+ open: context.open,
+ onOpenChange: next => send(next ? { type: 'OPEN' } : { type: 'CLOSE' }),
+ pendingKey: displayPendingKey,
+ onSelectOrganization: runAction(userButtonBusyKeys.selectOrganization, onSelectOrganization, true),
+ onSwitchSession: runAction(userButtonBusyKeys.switchSession, onSwitchSession),
+ // Last-account and all-accounts sign-out unmount the button. Staying `open` would reopen the menu on the next sign-in.
+ onSignOutSession: runAction(
+ userButtonBusyKeys.signOutSession,
+ onSignOutSession,
+ data.additionalSessions.length === 0,
+ ),
+ onSignOutAll: runAction(userButtonBusyKeys.signOutAll, onSignOutAll, true),
+ 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.messages.ts b/packages/ui/src/mosaic/user-button/user-button.messages.ts
index 43a4e25e4e5..812ddf8f961 100644
--- a/packages/ui/src/mosaic/user-button/user-button.messages.ts
+++ b/packages/ui/src/mosaic/user-button/user-button.messages.ts
@@ -15,6 +15,7 @@ export const userButtonBase = {
},
workspaces: {
personal: 'Personal account',
+ notSelected: 'No organization selected',
loading: 'Loading organizations…',
members: { one: '{count} member', other: '{count} members' },
accept: 'Accept',
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..20404039508
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx
@@ -0,0 +1,317 @@
+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;
+ /** Where switching account lands. The instance URL is used when this is omitted. */
+ afterSwitchSessionUrl?: string;
+ /**
+ * 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;
+ }
+ const afterSwitchSessionUrl = options?.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;
+ if (!afterSwitchSessionUrl) {
+ return;
+ }
+ // `redirectUrl` decorated for us; taking the callback takes the Safari ITP refresh with it.
+ await router.navigate(decorateUrl(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: async suggestionId => {
+ const suggestion = suggestionData.find(s => s.id === suggestionId);
+ try {
+ await suggestion?.accept();
+ } finally {
+ // We always revalidate, a failed accept might be because of old state
+ // Using allSettled since it never throws and we don't want failed revalidates to look like failed accepts
+ await Promise.allSettled([userSuggestions.revalidate?.()]);
+ }
+ },
+ onAcceptInvitation: async invitationId => {
+ const invitation = invitationData.find(i => i.id === invitationId);
+ try {
+ await invitation?.accept();
+ } finally {
+ // We always revalidate, a failed accept might be because of old state
+ // Using allSettled since it never throws and we don't want failed revalidates to look like failed accepts
+ await Promise.allSettled([userInvitations.revalidate?.(), 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
new file mode 100644
index 00000000000..4abdcfc67cc
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -0,0 +1,154 @@
+'use client';
+
+import type { ReactElement, ReactNode } from 'react';
+
+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 { UserButtonView } from './user-button.view';
+
+/** 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. */
+// TODO: Possibly missing, verify these before GA:
+// defaultOpen, signInUrl, userProfileProps.additionalOAuthScopes, userProfileProps.apiKeysProps, userProfileProps.appearance, customMenuItems open/startPath, afterCreateOrganizationUrl, skipInvitationScreen, afterLeaveOrganizationUrl, organizationProfileProps
+export type UserButtonProps = UserButtonModelOptions &
+ UserButtonTriggerProps &
+ UserButtonMenuProps &
+ UserButtonModeProps & {
+ userProfileProps?: UserButtonUserProfileProps;
+ /**
+ * Fallback while loading.
+ *
+ * Note that the UserButton renders nothing when the user is signed out, so using this on
+ * pages that are reachable while both signed-out and signed-in can result in Fallback->Nothing.
+ */
+ fallback?: ReactNode;
+ };
+
+/**
+ * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account,
+ * open the profile, and sign out. It reads the active session and organization from Clerk, so it takes
+ * no data. It renders `fallback` until Clerk answers, and nothing at all when nobody is signed in.
+ *
+ * Each action is a request: the row you click spins, the others stand down, and the menu stays open on
+ * the result. Only an action that navigates closes it.
+ *
+ * @example
+ * ```tsx
+ * import { UserButton } from '@clerk/ui/mosaic';
+ *
+ *
+ * ```
+ *
+ * @example
+ * `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
+ *
+ *
+ *
+ * ```
+ *
+ * @example
+ * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole
+ * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param`
+ * template, a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands.
+ * ```tsx
+ *
+ * ```
+ *
+ * @example
+ * `fallback` holds the space while Clerk is still answering. Size it to the trigger to keep the row
+ * it sits in from moving. Nothing stands in once the answer is that nobody is signed in.
+ * ```tsx
+ * } />
+ * ```
+ *
+ * @example
+ * `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() },
+ * ]}
+ * menuItemOrder={['docs', 'support', 'addAccount', 'signOutAll']}
+ * />
+ * ```
+ */
+export function UserButton(props: UserButtonProps = {}): ReactElement | null {
+ 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}
+ {portals}
+ >
+ );
+ }
+
+ // Signed out is an answer, so the placeholder goes too rather than promising a button.
+ if (controller.status === 'hidden') {
+ return <>{portals}>;
+ }
+
+ 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 b07594c8b9f..e7a69fbf3b6 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;
@@ -51,7 +51,8 @@ export interface UserButtonData {
activeSession: UserButtonSession;
/**
* The active organization, described whole rather than found in `memberships`, so the surface
- * names it while the list it belongs to is still loading. `null` => the personal workspace.
+ * names it while the list it belongs to is still loading. `null` means none is active: the
+ * personal workspace when one exists, and no selection otherwise.
*/
activeOrganization: UserButtonMembership | null;
/**
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 c7906c94206..ada9c3a745f 100644
--- a/packages/ui/src/mosaic/user-button/user-button.view.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx
@@ -39,7 +39,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';
/**
@@ -86,23 +86,42 @@ function useBusy(key?: string): { busy: boolean; disabled: boolean } {
return { busy: pendingKey === key, disabled: pendingKey !== key };
}
-interface ActiveWorkspace {
- name: string;
- imageUrl?: string;
- shape: 'circle' | 'square';
- /** Absent when the personal account is what's active. */
- organization?: UserButtonMembership;
-}
+type ActiveWorkspace =
+ | {
+ kind: 'organization';
+ name: string;
+ imageUrl?: string;
+ shape: 'square';
+ organization: UserButtonMembership;
+ }
+ | { kind: 'user'; name: string; imageUrl?: string; shape: 'circle' }
+ | { kind: 'none'; name: string; imageUrl?: string; shape: 'square' };
/**
* What the surface leads with: named in the trigger and headed in the popup, so the two always
- * agree. Only an organization-led surface with an organization actually active resolves to one.
+ * agree. An organization-led surface with no org and no personal workspace is no selection.
*/
-function leadWorkspace({ layout, activeOrganization, activeSession }: UserButtonContextValue): ActiveWorkspace {
- const organization = layout.leadWith === 'organization' ? activeOrganization : null;
- return organization
- ? { name: organization.name, imageUrl: organization.imageUrl, shape: 'square', organization }
- : { name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' };
+function leadWorkspace({
+ layout,
+ activeOrganization,
+ activeSession,
+ hidePersonal,
+}: UserButtonContextValue): ActiveWorkspace {
+ if (layout.leadWith === 'organization') {
+ if (activeOrganization) {
+ return {
+ kind: 'organization',
+ name: activeOrganization.name,
+ imageUrl: activeOrganization.imageUrl,
+ shape: 'square',
+ organization: activeOrganization,
+ };
+ }
+ if (hidePersonal) {
+ return { kind: 'none', name: m.workspaces.notSelected, shape: 'square' };
+ }
+ }
+ return { kind: 'user', name: activeSession.name, imageUrl: activeSession.imageUrl, shape: 'circle' };
}
function membershipSubtitle(membership: UserButtonMembership): string {
@@ -351,10 +370,18 @@ function Header() {
const data = useUserButtonContext();
const signOutSession = data.onSignOutSession;
const { sessionId, identifier } = data.activeSession;
- const { name, imageUrl, shape, organization } = leadWorkspace(data);
+ const workspace = leadWorkspace(data);
+ const { name, imageUrl, shape } = workspace;
+ const organization = workspace.kind === 'organization' ? workspace.organization : undefined;
// An account with no name is titled by its identifier, and repeating it underneath says nothing.
+ // No selection is not the account, so it carries no identifier line either.
const accountSubtitle = identifier === name ? '' : identifier;
- const subtitle = organization ? membershipSubtitle(organization) : accountSubtitle;
+ const subtitle =
+ workspace.kind === 'organization'
+ ? membershipSubtitle(workspace.organization)
+ : workspace.kind === 'user'
+ ? accountSubtitle
+ : '';
const actions: HeaderAction[] = [];
for (const action of data.layout.actions.header) {
@@ -1026,7 +1053,8 @@ export function UserButtonRoot(props: UserButtonRootProps): ReactElement {
export interface UserButtonTriggerProps {
/**
* Names the active workspace beside its avatar — the organization wherever one heads the
- * trigger, the account otherwise. Turn it off for the avatar alone.
+ * trigger, no selection when personal is hidden and none is active, the account otherwise.
+ * Turn it off for the avatar alone.
*
* @default true
*/
@@ -1046,8 +1074,10 @@ export function UserButtonTrigger({
renderTriggerBadge = true,
}: UserButtonTriggerProps = {}): ReactElement {
const data = useUserButtonContext();
- const { name, imageUrl, shape, organization } = leadWorkspace(data);
- const planLabel = renderTriggerBadge ? organization?.planLabel : undefined;
+ const workspace = leadWorkspace(data);
+ const { name, imageUrl, shape } = workspace;
+ const planLabel =
+ renderTriggerBadge && workspace.kind === 'organization' ? workspace.organization.planLabel : undefined;
return (