From 43573615e5146577314ed92e645b8aa9918a1921 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 28 Oct 2026 18:49:06 -0600
Subject: [PATCH 01/12] feat(mosaic): export UserProfile
---
.changeset/mosaic-user-profile-export.md | 2 ++
packages/mosaic/src/index.ts | 2 ++
2 files changed, 4 insertions(+)
create mode 100644 .changeset/mosaic-user-profile-export.md
diff --git a/.changeset/mosaic-user-profile-export.md b/.changeset/mosaic-user-profile-export.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-profile-export.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index b9690e71e22..68e3b41677b 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -4,6 +4,8 @@ export type { UserButtonProps } from './features/user-button/user-button';
export { UserButton } from './features/user-button/user-button';
export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher';
export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher';
+export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile';
+export { UserProfile } from './features/user-profile/user-profile';
export { MosaicProvider } from './mosaic-provider';
export type { PortalContainer, PortalProviderProps } from './primitives/portal';
export { PortalProvider } from './primitives/portal';
From 58bdb9772e8c70bd210c8bd958378f8c7eb71c6e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 28 Oct 2026 18:49:07 -0600
Subject: [PATCH 02/12] feat(mosaic): open the Mosaic UserProfile from
UserButton
---
.../src/features/switcher/switcher.model.tsx | 23 ++++--
.../mosaic/src/features/switcher/switcher.tsx | 32 ++------
.../__tests__/user-button.feature.test.tsx | 82 +++++++++----------
.../user-profile.modal-content.tsx | 24 ++++++
.../user-profile/user-profile.modal.ts | 8 ++
packages/mosaic/src/mosaic-provider.tsx | 3 +-
packages/swingset/src/stories/user-button.mdx | 4 +-
7 files changed, 99 insertions(+), 77 deletions(-)
create mode 100644 packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
create mode 100644 packages/mosaic/src/features/user-profile/user-profile.modal.ts
diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx
index 413cc0bf287..794e6383ab8 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -6,15 +6,17 @@ import type {
OrganizationResource,
OrganizationSwitcherProps,
UserButtonProps as ClerkUserButtonProps,
- UserProfileModalProps,
UserResource,
} from '@clerk/shared/types';
import { populateParamFromObject } from '@clerk/shared/url';
import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
import { useMosaicRouter } from '../../hooks/use-mosaic-router';
+import { useModal } from '../../modals/use-modal';
import { useGetPortalContainer } from '../../primitives/portal';
import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages';
+import { userProfileModal } from '../user-profile/user-profile.modal';
+import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types';
import type {
SwitcherBrandingProps,
SwitcherCallbacks,
@@ -109,7 +111,10 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode &
/** Props forwarded to the profile modals this button opens. */
export interface SwitcherModalProps {
- userProfile?: Pick;
+ userProfile?: Pick<
+ UserProfileModalOptions,
+ 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
+ >;
organizationProfile?: Pick;
}
@@ -173,8 +178,8 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession {
}
/**
- * @param modals - Props forwarded to the profile modals. Custom pages arrive already bridged, since only
- * the wrapper can render the portals behind them.
+ * @param modals - Props forwarded to the profile modals. The organization profile's custom pages arrive
+ * already bridged, since only the wrapper can render the portals behind them.
*/
export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel {
const { isLoaded: isUserLoaded, user } = useUser();
@@ -185,6 +190,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
});
const clerk = useClerk();
const router = useMosaicRouter();
+ const userProfile = useModal(userProfileModal);
// The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
const getContainer = useGetPortalContainer();
const environment = useMosaicEnvironment();
@@ -199,7 +205,14 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const manageAccount = openOrNavigate({
url: options?.userProfileUrl,
mode: options?.userProfileMode,
- openModal: () => clerk.openUserProfile({ getContainer, ...modals?.userProfile }),
+ openModal: () =>
+ userProfile.open(
+ {
+ ...modals?.userProfile,
+ organizationProfileProps: { afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl },
+ },
+ getContainer?.(),
+ ),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx
index aac782c9ad1..1c736311605 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -3,22 +3,22 @@
import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types';
import type { ReactElement, ReactNode } from 'react';
-import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types';
+import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
import { useSwitcherController } from './switcher.controller';
import type { SwitcherModelOptions } from './switcher.model';
import { useSwitcherModel } from './switcher.model';
import type { OrganizationProfilePageId } from './switcher.pages';
-import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages';
+import { useCustomPages, useOrganizationProfilePages } from './switcher.pages';
import type { SwitcherMenuProps, SwitcherMode } from './switcher.types';
import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view';
import { SwitcherView } from './switcher.view';
/** What a profile opened from the switcher takes beyond the profile component's own props. */
-export interface SwitcherProfilePages {
+export interface SwitcherProfilePages {
/**
* Provide custom pages and links to be rendered inside the profile.
*/
- customPages?: CustomProfileItem[];
+ customPages?: Item[];
/**
* Controls the order of the profile's navigation. Accepts the ids of built-in pages and the
* `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is
@@ -32,8 +32,8 @@ export interface SwitcherProfilePages {
/** Options for the underlying `` component. */
export interface SwitcherUserProfileProps
extends
- SwitcherProfilePages,
- Pick {}
+ SwitcherProfilePages,
+ Pick {}
/** Options for the underlying `` component. */
export interface SwitcherOrganizationProfileProps
@@ -77,30 +77,14 @@ export function Switcher(
...options
} = props;
// The portals must outlive the popover, so custom pages are bridged here rather than inside it.
- const userProfile = useCustomPages({
- items: userProfileProps?.customPages,
- order: userProfileProps?.pageOrder,
- builtInPages: useUserProfilePages(),
- });
const organizationProfile = useCustomPages({
items: organizationProfileProps?.customPages,
order: organizationProfileProps?.pageOrder,
builtInPages: useOrganizationProfilePages(),
});
- const portals = (
- <>
- {userProfile.portals}
- {organizationProfile.portals}
- >
- );
+ const portals = <>{organizationProfile.portals}>;
const model = useSwitcherModel(options, {
- userProfile: {
- customPages: userProfile.customPages,
- additionalOAuthScopes: userProfileProps?.additionalOAuthScopes,
- apiKeysProps: userProfileProps?.apiKeysProps,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched.
- appearance: userProfileProps?.appearance,
- },
+ userProfile: { ...userProfileProps, mode: mode === 'user' ? 'user' : 'combined' },
organizationProfile: {
customPages: organizationProfile.customPages,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched.
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
index 4ebb5a240dd..51eab70def5 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
@@ -2,6 +2,7 @@ import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { page } from 'vitest/browser';
import { deferred } from '../../../__tests__/async';
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
@@ -111,6 +112,13 @@ const reading = (...names: string[]) =>
.filter(node => !node.closest('.cl-user-button-header'))
.map(node => node.textContent);
+async function wideViewport() {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+}
+
+const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
+
const current = () =>
Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent);
@@ -465,28 +473,30 @@ describe('UserButton', () => {
describe('handing off', () => {
it('opens the UserProfile modal and closes when managing the account', async () => {
+ await wideViewport();
const { clerk, navigate } = await renderUserButton();
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile');
const user = await open();
await openSettings(user, 'Manage profile');
- expect(openUserProfile).toHaveBeenCalled();
+ expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
+ expect(openUserProfile).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
await waitFor(() => expect(popup()).toBeNull());
});
- it('hands the opened profile the portal root the app provides', async () => {
+ it('opens the profile into the portal root the app provides', async () => {
+ await wideViewport();
const container = document.body.appendChild(document.createElement('div'));
onTestFinished(() => container.remove());
serveFapi(signedIn());
- const { clerk } = await renderWithClerk({tree()});
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ await renderWithClerk({tree()});
const user = await open();
await openSettings(user, 'Manage profile');
- expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container);
+ expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument();
});
it('runs a custom menu item and closes', async () => {
@@ -500,62 +510,43 @@ describe('UserButton', () => {
await waitFor(() => expect(popup()).toBeNull());
});
- it('renders a custom page into the element the opened profile hands back', async () => {
- const { clerk } = await renderUserButton({
- userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
- });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ it('renders a custom page inside the opened profile', async () => {
+ await wideViewport();
+ await renderUserButton({ userProfileProps: { customPages: [terms] } });
const user = await open();
await openSettings(user, 'Manage profile');
- await waitFor(() => expect(popup()).toBeNull());
-
- const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages).toHaveLength(1);
- expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+ await user.click(await screen.findByRole('tab', { name: 'Terms' }));
- const el = document.createElement('div');
- document.body.appendChild(el);
- act(() => {
- customPages[0]?.mount?.(el);
- });
-
- expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ expect(screen.getByText('Terms body')).toBeVisible();
});
- it('opens the profile with its pages in the order it was given', async () => {
- const { clerk } = await renderUserButton({
- userProfileProps: {
- customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}],
- pageOrder: ['account', 'terms'],
- },
- });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ it('opens the profile on the first page of the order it was given', async () => {
+ await wideViewport();
+ await renderUserButton({ userProfileProps: { customPages: [terms], pageOrder: ['terms', 'account'] } });
const user = await open();
await openSettings(user, 'Manage profile');
- await waitFor(() => expect(popup()).toBeNull());
- const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
+ expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']);
});
it.each([
- { features: {}, pages: ['Terms', 'account', 'security'] },
- { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] },
- ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => {
- const { clerk } = await renderUserButton(
+ { features: {}, tabs: ['Terms', 'Account', 'Security', 'General', 'Members'] },
+ { features: { apiKeys: true }, tabs: ['Terms', 'Account', 'Security', 'API Keys', 'General', 'Members'] },
+ ])('orders the UserProfile pages the instance turns on: $features', async ({ features, tabs }) => {
+ await wideViewport();
+ await renderUserButton(
{ userProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
signedIn({ environment: instanceWith(features) }),
);
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
- await waitFor(() => expect(popup()).toBeNull());
+ await screen.findByRole('tab', { name: 'Terms' });
- const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
+ expect(profileTabs()).toEqual(tabs);
});
it.each([
@@ -932,8 +923,8 @@ describe('UserButton', () => {
});
it('manages the account from its settings row, and signs out at the foot', async () => {
- const { clerk } = await renderUserButton({ mode: 'user' });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ await wideViewport();
+ await renderUserButton({ mode: 'user' });
const user = await open();
expect(
@@ -944,7 +935,8 @@ describe('UserButton', () => {
await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.queryByRole('menu')).toBeNull();
- expect(openUserProfile).toHaveBeenCalled();
+ expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Account', 'Security']);
});
it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
new file mode 100644
index 00000000000..f0830a0b37e
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
@@ -0,0 +1,24 @@
+import { useUser } from '@clerk/shared/react';
+import React from 'react';
+
+import { UserProfile } from './user-profile';
+import type { UserProfileModalContentProps } from './user-profile.modal.types';
+
+export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement {
+ const { isLoaded, user } = useUser();
+ const signedOut = isLoaded && !user;
+
+ React.useEffect(() => {
+ if (signedOut) {
+ close();
+ }
+ }, [signedOut, close]);
+
+ const { page, ...props } = options ?? {};
+ return (
+
+ );
+}
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.ts
new file mode 100644
index 00000000000..636d8aa504a
--- /dev/null
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal.ts
@@ -0,0 +1,8 @@
+import { defineModalSurface } from '../../modals/define-modal-surface';
+import type { UserProfileModalOptions } from './user-profile.modal.types';
+
+export const userProfileModal = defineModalSurface({
+ id: 'userProfile',
+ variant: 'profile',
+ load: () => import('./user-profile.modal-content'),
+});
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index 7eb3f61e87f..686bb3c6f09 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { ToastProvider } from './components/toast/toast';
+import { userProfileModal } from './features/user-profile/user-profile.modal';
import { MosaicNowProvider } from './hooks/use-now';
import type { MosaicIconOverrides } from './icons/overrides';
import { MosaicIconsProvider } from './icons/overrides';
@@ -44,4 +45,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos
);
}
-const modalSurfaces: ModalSurfaces = [];
+const modalSurfaces: ModalSurfaces = [userProfileModal];
diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx
index 1e91a2c0bae..fba984c2386 100644
--- a/packages/swingset/src/stories/user-button.mdx
+++ b/packages/swingset/src/stories/user-button.mdx
@@ -219,8 +219,8 @@ all accounts**. The foot keeps **Sign out**.
## Profile modal
Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a
-dialog. The exported UserButton still opens the clerk-js profile. This example backs the modal with
-fixture data, so it runs without a Clerk session. The profile is combined: the active organization's
+dialog. The exported UserButton opens Mosaic's `UserProfile` the same way, against the signed-in
+user. This example backs the modal with fixture data, so it runs without a Clerk session. The profile is combined: the active organization's
pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls
`open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or
`open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a
From 30e6511d71098038a8ffb24b03e73adacc1da399 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 28 Oct 2026 18:49:08 -0600
Subject: [PATCH 03/12] test(mosaic): cover UserProfile and the UserButton
profile modal in integration tests
---
.../src/app/user-profile/page.tsx | 11 +++
integration/tests/mosaic/user-button.test.ts | 21 ++++-
integration/tests/mosaic/user-profile.test.ts | 82 +++++++++++++++++++
3 files changed, 110 insertions(+), 4 deletions(-)
create mode 100644 integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
create mode 100644 integration/tests/mosaic/user-profile.test.ts
diff --git a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
new file mode 100644
index 00000000000..a23e6aeb5d6
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx
@@ -0,0 +1,11 @@
+'use client';
+
+import { MosaicProvider, UserProfile } from '@clerk/mosaic';
+
+export default function Page() {
+ return (
+
+ custom-page-content
}]} />
+
+ );
+}
diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts
index c4d5e6ed310..2f0f51065ef 100644
--- a/integration/tests/mosaic/user-button.test.ts
+++ b/integration/tests/mosaic/user-button.test.ts
@@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
async function openProfileSettings(page: Page) {
await popup(page).getByRole('button', { name: 'Settings' }).click();
const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' });
- await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
+ const profile = page.getByRole('tab', { name: 'Account', exact: true });
+ await profileSettings.or(profile).first().waitFor();
if (await profileSettings.isVisible()) {
await profileSettings.click();
}
@@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
await expect(page.getByText('custom-link-target')).toBeVisible();
});
+ test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await signIn({ page, context });
+
+ await trigger(page).click();
+ await openProfileSettings(page);
+
+ await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true');
+ await expect(page.locator('.cl-userProfile-root')).toHaveCount(0);
+ await expect(popup(page)).toBeHidden();
+ });
+
test('renders a custom page inside the UserProfile modal', async ({ page, context }) => {
- const u = await signIn({ page, context }, '/custom');
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await signIn({ page, context }, '/custom');
await trigger(page).click();
await openProfileSettings(page);
- await u.po.userProfile.waitForUserProfileModal();
- await page.locator('.cl-userProfile-root').getByText('Custom page').click();
+ await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts
new file mode 100644
index 00000000000..3eab59203f2
--- /dev/null
+++ b/integration/tests/mosaic/user-profile.test.ts
@@ -0,0 +1,82 @@
+import type { Organization } from '@clerk/backend';
+import type { BrowserContext, Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+ let organization: Organization;
+ const cleanups: Array<() => Promise> = [];
+
+ test.beforeAll(async () => {
+ const u = await createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser(test);
+ cleanups.push(() => fakeUser.deleteIfExists());
+ const user = await u.services.users.createBapiUser(fakeUser);
+ organization = await u.services.clerk.organizations.createOrganization({
+ name: `Mosaic ${Date.now()}`,
+ createdBy: user.id,
+ });
+ cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
+ });
+
+ test.afterAll(async () => {
+ const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
+ await app.teardown();
+ const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed');
+ }
+ });
+
+ const path = '/user-profile';
+ const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });
+
+ async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
+ const u = await createTestUtils({ app, page, context });
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.page.goToRelative(path);
+ await u.po.expect.toBeSignedIn();
+ await expect(tab(page, 'Account')).toBeVisible();
+ return u;
+ }
+
+ test('renders nothing when signed out', async ({ page, context }) => {
+ const u = await createTestUtils({ app, page, context });
+ await u.page.goToRelative(path);
+ await u.page.waitForClerkJsLoaded();
+
+ await expect(tab(page, 'Account')).toHaveCount(0);
+ });
+
+ test('opens on the account page with the signed-in user', async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByText(fakeUser.email)).toBeVisible();
+ });
+
+ test("lists the active organization's pages after the account's", async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
+ await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
+
+ await tab(page, 'General').click();
+ await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByText(organization.name).first()).toBeVisible();
+ });
+
+ test('renders a custom page', async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await tab(page, 'Custom page').click();
+ await expect(page.getByText('custom-page-content')).toBeVisible();
+ });
+});
From ff244d2802f4b8dadc0bddaac95247a2e0743a3e Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 28 Oct 2026 19:29:23 -0600
Subject: [PATCH 04/12] feat(mosaic): export OrganizationProfile and open the
Mosaic profiles from the switchers
---
.../organization-profile.modal-content.tsx | 27 +++
.../organization-profile.modal.ts | 8 +
.../organization-switcher.feature.test.tsx | 31 ++-
.../__tests__/switcher.pages.test.tsx | 190 ------------------
.../src/features/switcher/switcher.model.tsx | 35 ++--
.../src/features/switcher/switcher.pages.tsx | 128 +-----------
.../mosaic/src/features/switcher/switcher.tsx | 63 ++----
.../__tests__/user-button.feature.test.tsx | 76 ++-----
.../user-profile/user-profile.types.ts | 14 --
packages/mosaic/src/index.ts | 2 +
packages/mosaic/src/mosaic-provider.tsx | 3 +-
11 files changed, 115 insertions(+), 462 deletions(-)
create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.modal.ts
delete mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
new file mode 100644
index 00000000000..b7ff553c1d7
--- /dev/null
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx
@@ -0,0 +1,27 @@
+import { useOrganization } from '@clerk/shared/react';
+import React from 'react';
+
+import { OrganizationProfile } from './organization-profile';
+import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types';
+
+export default function OrganizationProfileModalContent({
+ options,
+ close,
+}: OrganizationProfileModalContentProps): React.ReactElement {
+ const { isLoaded, organization } = useOrganization();
+ const inactive = isLoaded && !organization;
+
+ React.useEffect(() => {
+ if (inactive) {
+ close();
+ }
+ }, [inactive, close]);
+
+ const { page, ...props } = options ?? {};
+ return (
+
+ );
+}
diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts
new file mode 100644
index 00000000000..c9b211967fc
--- /dev/null
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts
@@ -0,0 +1,8 @@
+import { defineModalSurface } from '../../modals/define-modal-surface';
+import type { OrganizationProfileModalOptions } from './organization-profile.modal.types';
+
+export const organizationProfileModal = defineModalSurface({
+ id: 'organizationProfile',
+ variant: 'profile',
+ load: () => import('./organization-profile.modal-content'),
+});
diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
index 278fd3d049e..aea996da81e 100644
--- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
@@ -1,7 +1,9 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { describe, expect, it, vi } from 'vitest';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { page } from 'vitest/browser';
+import { openDialog } from '../../../__tests__/feature/dialog';
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
import {
fapiClient,
@@ -45,6 +47,11 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi
const bobSession = fapiSession({ id: 'sess_2', user: bob });
const personalSession = fapiSession({ id: 'sess_1', user: alice });
+async function wideViewport() {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+}
+
function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed {
return {
client: fapiClient([aliceSession, bobSession]),
@@ -128,8 +135,8 @@ describe('OrganizationSwitcher', () => {
});
it('heads the surface with the active organization, and manages it from the rows beneath', async () => {
- const { clerk } = await renderSwitcher({}, noOffers());
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ await wideViewport();
+ await renderSwitcher({}, noOffers());
const user = await openWithList();
const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup();
@@ -144,7 +151,8 @@ describe('OrganizationSwitcher', () => {
await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.queryByRole('menu')).toBeNull();
- expect(openOrganizationProfile).toHaveBeenCalled();
+ expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull();
});
it('falls back to the account, with nothing to manage, where no organization is active', async () => {
@@ -215,16 +223,19 @@ describe('OrganizationSwitcher', () => {
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other'));
});
- it('tells the organization profile where to land after leaving', async () => {
- const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
+ await wideViewport();
+ const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
const user = await openWithList();
await user.click(screen.getByRole('button', { name: 'Settings' }));
+ const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
+ role: 'alertdialog',
+ });
+ await user.type(within(dialog).getByRole('textbox'), 'Acme');
+ await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
- expect(openOrganizationProfile).toHaveBeenCalledWith(
- expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }),
- );
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
});
it('tells create-organization where to land and whether to skip inviting members', async () => {
diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
deleted file mode 100644
index 8db622b685e..00000000000
--- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-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 { CustomProfileItem } from '../../user-profile/user-profile.types';
-import type { CustomPagesOptions } from '../switcher.pages';
-import { useCustomPages } from '../switcher.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 {portals}
;
-}
-
-const terms: CustomProfileItem = {
- label: 'Terms',
- path: 'terms',
- icon: terms icon,
- content: Terms body
,
-};
-
-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 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']);
- });
-
- // 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/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx
index 794e6383ab8..698b7957a57 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -2,7 +2,6 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react';
import type {
- OrganizationProfileModalProps,
OrganizationResource,
OrganizationSwitcherProps,
UserButtonProps as ClerkUserButtonProps,
@@ -15,6 +14,8 @@ import { useMosaicRouter } from '../../hooks/use-mosaic-router';
import { useModal } from '../../modals/use-modal';
import { useGetPortalContainer } from '../../primitives/portal';
import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages';
+import { organizationProfileModal } from '../organization-profile/organization-profile.modal';
+import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types';
import { userProfileModal } from '../user-profile/user-profile.modal';
import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types';
import type {
@@ -115,7 +116,7 @@ export interface SwitcherModalProps {
UserProfileModalOptions,
'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
>;
- organizationProfile?: Pick;
+ organizationProfile?: Pick;
}
function resolveAfterUrl(
@@ -178,8 +179,8 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession {
}
/**
- * @param modals - Props forwarded to the profile modals. The organization profile's custom pages arrive
- * already bridged, since only the wrapper can render the portals behind them.
+ * @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the
+ * organization's pages, so managing the organization opens it on them.
*/
export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel {
const { isLoaded: isUserLoaded, user } = useUser();
@@ -191,6 +192,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const clerk = useClerk();
const router = useMosaicRouter();
const userProfile = useModal(userProfileModal);
+ const organizationProfile = useModal(organizationProfileModal);
// The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
const getContainer = useGetPortalContainer();
const environment = useMosaicEnvironment();
@@ -202,17 +204,15 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
enabled: Boolean(environment?.organizationSettings.enabled),
});
+ const organizationProfileProps = {
+ ...modals?.organizationProfile,
+ afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl,
+ };
+
const manageAccount = openOrNavigate({
url: options?.userProfileUrl,
mode: options?.userProfileMode,
- openModal: () =>
- userProfile.open(
- {
- ...modals?.userProfile,
- organizationProfileProps: { afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl },
- },
- getContainer?.(),
- ),
+ openModal: () => userProfile.open({ ...modals?.userProfile, organizationProfileProps }, getContainer?.()),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
@@ -221,11 +221,12 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
url: options?.organizationProfileUrl,
mode: options?.organizationProfileMode,
openModal: () =>
- clerk.openOrganizationProfile({
- getContainer,
- ...modals?.organizationProfile,
- afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl,
- }),
+ modals?.userProfile?.mode === 'combined'
+ ? userProfile.open(
+ { ...modals.userProfile, organizationProfileProps, page: 'organization/general' },
+ getContainer?.(),
+ )
+ : organizationProfile.open(organizationProfileProps, getContainer?.()),
buildUrl: () => clerk.buildOrganizationProfileUrl(),
navigate: router.navigate,
});
diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx
index 301e64b0d0c..4a7b6cc63e8 100644
--- a/packages/mosaic/src/features/switcher/switcher.pages.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx
@@ -1,24 +1,12 @@
import {
- disabledOrganizationAPIKeysFeature,
- disabledOrganizationBillingFeature,
- disabledSelfServeDirectorySyncFeature,
- disabledSelfServeSSOFeature,
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/use-mosaic-environment';
-import { applyOrder } from '../../utils/apply-order';
import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout';
-import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types';
-
-/** A built-in OrganizationProfile page, by the id its navigation uses. */
-export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security';
+import type { UserProfilePageId } from '../user-profile/user-profile.types';
/**
* The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned
@@ -39,117 +27,3 @@ export function useUserProfilePages(): UserProfilePageId[] {
};
return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]);
}
-
-/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */
-export function useOrganizationProfilePages(): OrganizationProfilePageId[] {
- const clerk = useClerk();
- const environment = useMosaicEnvironment();
-
- const pages: OrganizationProfilePageId[] = ['general', 'members'];
- if (!disabledOrganizationBillingFeature(clerk, environment)) {
- pages.push('billing');
- }
- if (!disabledOrganizationAPIKeysFeature(clerk, environment)) {
- pages.push('apiKeys');
- }
- if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) {
- pages.push('security');
- }
- return pages;
-}
-
-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/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx
index 1c736311605..c1e7c9ec0b2 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -1,28 +1,27 @@
'use client';
-import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types';
+import type { UserProfileProps } from '@clerk/shared/types';
import type { ReactElement, ReactNode } from 'react';
-import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
+import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
+import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
import { useSwitcherController } from './switcher.controller';
import type { SwitcherModelOptions } from './switcher.model';
import { useSwitcherModel } from './switcher.model';
-import type { OrganizationProfilePageId } from './switcher.pages';
-import { useCustomPages, useOrganizationProfilePages } from './switcher.pages';
import type { SwitcherMenuProps, SwitcherMode } from './switcher.types';
import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view';
import { SwitcherView } from './switcher.view';
/** What a profile opened from the switcher takes beyond the profile component's own props. */
-export interface SwitcherProfilePages {
+export interface SwitcherProfilePages {
/**
- * Provide custom pages and links to be rendered inside the profile.
+ * Provide custom pages to be rendered inside the profile.
*/
- customPages?: Item[];
+ customPages?: CustomProfilePage[];
/**
* Controls the order of the profile's navigation. Accepts the ids of built-in pages and the
* `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is
- * the page the profile opens on, so it cannot be a link.
+ * the page the profile opens on.
*
* @default undefined
*/
@@ -31,13 +30,10 @@ export interface SwitcherProfilePages` component. */
export interface SwitcherUserProfileProps
- extends
- SwitcherProfilePages,
- Pick {}
+ extends SwitcherProfilePages, Pick {}
/** Options for the underlying `` component. */
-export interface SwitcherOrganizationProfileProps
- extends SwitcherProfilePages, Pick {}
+export type SwitcherOrganizationProfileProps = SwitcherProfilePages;
export type SwitcherProps = SwitcherModelOptions &
SwitcherTriggerProps &
@@ -49,7 +45,7 @@ export type SwitcherProps = SwitcherModelOptions &
userProfileProps?: SwitcherUserProfileProps;
/**
* Specify options for the underlying component.
- * e.g., organizationProfileProps={{appearance: {...}}}
+ * e.g., organizationProfileProps={{pageOrder: ['members', 'general']}}
*/
organizationProfileProps?: SwitcherOrganizationProfileProps;
/**
@@ -76,48 +72,29 @@ export function Switcher(
fallback,
...options
} = props;
- // The portals must outlive the popover, so custom pages are bridged here rather than inside it.
- const organizationProfile = useCustomPages({
- items: organizationProfileProps?.customPages,
- order: organizationProfileProps?.pageOrder,
- builtInPages: useOrganizationProfilePages(),
- });
- const portals = <>{organizationProfile.portals}>;
const model = useSwitcherModel(options, {
- userProfile: { ...userProfileProps, mode: mode === 'user' ? 'user' : 'combined' },
- organizationProfile: {
- customPages: organizationProfile.customPages,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched.
- appearance: organizationProfileProps?.appearance,
- },
+ userProfile: { ...userProfileProps, mode: mode === 'combined' ? 'combined' : 'user' },
+ organizationProfile: organizationProfileProps,
});
const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder });
if (controller.status === 'loading') {
- return (
- <>
- {fallback}
- {portals}
- >
- );
+ return <>{fallback}>;
}
// Signed out is an answer, so the placeholder goes too rather than promising a button.
if (controller.status === 'hidden') {
- return <>{portals}>;
+ return null;
}
const { status: _status, ...viewController } = controller;
return (
- <>
-
- {portals}
- >
+
);
}
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
index 51eab70def5..2259d4dbba7 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
@@ -1,4 +1,4 @@
-import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types';
+import type { UserJSON, Web3WalletJSON } from '@clerk/shared/types';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
@@ -549,58 +549,16 @@ describe('UserButton', () => {
expect(profileTabs()).toEqual(tabs);
});
- it.each([
- { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] },
- {
- features: { billing: true, apiKeys: true },
- organization: {},
- pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'],
- },
- { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] },
- {
- features: { sso: true },
- organization: { self_serve_sso_enabled: true },
- pages: ['Terms', 'general', 'members', 'security'],
- },
- {
- features: { sso: true, directorySync: true },
- organization: {},
- pages: ['Terms', 'general', 'members'],
- },
- {
- features: { sso: true, directorySync: true },
- organization: { self_serve_directory_sync_enabled: true },
- pages: ['Terms', 'general', 'members', 'security'],
- },
- ])(
- 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization',
- async ({ features, organization, pages }) => {
- const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization });
- const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })];
- const { clerk } = await renderUserButton(
- { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
- signedIn({
- environment: instanceWith(features),
- client: fapiClient([
- fapiSession({
- id: 'sess_1',
- user: { ...alice, organization_memberships: memberships },
- last_active_organization_id: 'org_1',
- }),
- ]),
- memberships,
- }),
- );
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
- const user = await open();
+ it("opens the combined profile on the organization's General page, with its own pages in order", async () => {
+ await wideViewport();
+ await renderUserButton({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } });
+ const user = await open();
- await openSettings(user, 'Manage organization');
- await waitFor(() => expect(popup()).toBeNull());
+ await openSettings(user, 'Manage organization');
- const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? [];
- expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
- },
- );
+ expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']);
+ });
it('opens the InviteMembers modal and closes', async () => {
const { clerk, navigate } = await renderUserButton();
@@ -667,25 +625,23 @@ describe('UserButton', () => {
});
it('navigates to manage the account when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' });
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+ const { navigate } = await renderUserButton({ userProfileUrl: '/account' });
const user = await open();
await openSettings(user, 'Manage profile');
expect(navigate).toHaveBeenCalledWith('/account');
- expect(openUserProfile).not.toHaveBeenCalled();
+ expect(screen.queryByRole('tab')).toBeNull();
});
it('navigates to manage the organization when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' });
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ const { navigate } = await renderUserButton({ organizationProfileUrl: '/org' });
const user = await open();
await openSettings(user, 'Manage organization');
expect(navigate).toHaveBeenCalledWith('/org');
- expect(openOrganizationProfile).not.toHaveBeenCalled();
+ expect(screen.queryByRole('tab')).toBeNull();
});
it('sends add-account to the sign-in page', async () => {
@@ -964,8 +920,8 @@ describe('UserButton', () => {
});
it('heads the surface with the active organization, badged with the account, managing both', async () => {
- const { clerk } = await renderUserButton();
- const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ await wideViewport();
+ await renderUserButton();
expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
const user = await open();
@@ -987,7 +943,7 @@ describe('UserButton', () => {
'Sign out',
]);
await openSettings(user, 'Manage organization');
- expect(openOrganizationProfile).toHaveBeenCalled();
+ expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
});
it('keeps the identifier beneath the organization even when it is all that names the account', async () => {
diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts
index dcd57d0fa44..77ff769e925 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts
@@ -40,17 +40,3 @@ export interface CustomProfilePage {
/** 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;
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index 68e3b41677b..ad03bffa48f 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -4,6 +4,8 @@ export type { UserButtonProps } from './features/user-button/user-button';
export { UserButton } from './features/user-button/user-button';
export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher';
export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher';
+export type { OrganizationProfileProps } from './features/organization-profile/organization-profile';
+export { OrganizationProfile } from './features/organization-profile/organization-profile';
export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile';
export { UserProfile } from './features/user-profile/user-profile';
export { MosaicProvider } from './mosaic-provider';
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index 686bb3c6f09..c3e65bde3e7 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { ToastProvider } from './components/toast/toast';
+import { organizationProfileModal } from './features/organization-profile/organization-profile.modal';
import { userProfileModal } from './features/user-profile/user-profile.modal';
import { MosaicNowProvider } from './hooks/use-now';
import type { MosaicIconOverrides } from './icons/overrides';
@@ -45,4 +46,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos
);
}
-const modalSurfaces: ModalSurfaces = [userProfileModal];
+const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal];
From 59e9b4d78a7cb622b8133e71a9538ec65fdff40a Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Wed, 28 Oct 2026 19:29:24 -0600
Subject: [PATCH 05/12] test(mosaic): cover OrganizationProfile and the
switcher profile modals in integration tests
---
.../src/app/organization-profile/page.tsx | 13 +++
.../tests/mosaic/organization-profile.test.ts | 82 +++++++++++++++++++
.../mosaic/organization-switcher.test.ts | 6 +-
3 files changed, 99 insertions(+), 2 deletions(-)
create mode 100644 integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
create mode 100644 integration/tests/mosaic/organization-profile.test.ts
diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
new file mode 100644
index 00000000000..3efd034696b
--- /dev/null
+++ b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic';
+
+export default function Page() {
+ return (
+
+ custom-page-content }]}
+ />
+
+ );
+}
diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts
new file mode 100644
index 00000000000..a7d0cda9388
--- /dev/null
+++ b/integration/tests/mosaic/organization-profile.test.ts
@@ -0,0 +1,82 @@
+import type { Organization } from '@clerk/backend';
+import type { BrowserContext, Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+
+import type { FakeUser } from '../../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
+
+testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+ let organization: Organization;
+ const cleanups: Array<() => Promise> = [];
+
+ test.beforeAll(async () => {
+ const u = await createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser(test);
+ cleanups.push(() => fakeUser.deleteIfExists());
+ const user = await u.services.users.createBapiUser(fakeUser);
+ organization = await u.services.clerk.organizations.createOrganization({
+ name: `Mosaic ${Date.now()}`,
+ createdBy: user.id,
+ });
+ cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
+ });
+
+ test.afterAll(async () => {
+ const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
+ await app.teardown();
+ const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
+ if (failures.length > 0) {
+ throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed');
+ }
+ });
+
+ const path = '/organization-profile';
+ const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });
+
+ async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
+ const u = await createTestUtils({ app, page, context });
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.page.goToRelative(path);
+ await u.po.expect.toBeSignedIn();
+ await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
+ await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
+ await expect(tab(page, 'General')).toBeVisible();
+ return u;
+ }
+
+ test('renders nothing when signed out', async ({ page, context }) => {
+ const u = await createTestUtils({ app, page, context });
+ await u.page.goToRelative(path);
+ await u.page.waitForClerkJsLoaded();
+
+ await expect(tab(page, 'General')).toHaveCount(0);
+ });
+
+ test('opens on the general page with the active organization', async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByText(organization.name).first()).toBeVisible();
+ });
+
+ test('switches to the members page', async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await tab(page, 'Members').click();
+
+ await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true');
+ await expect(page.getByText(fakeUser.email)).toBeVisible();
+ });
+
+ test('renders a custom page', async ({ page, context }) => {
+ await signIn({ page, context });
+
+ await tab(page, 'Custom page').click();
+ await expect(page.getByText('custom-page-content')).toBeVisible();
+ });
+});
diff --git a/integration/tests/mosaic/organization-switcher.test.ts b/integration/tests/mosaic/organization-switcher.test.ts
index 0b091106da6..c19fa9f8e9c 100644
--- a/integration/tests/mosaic/organization-switcher.test.ts
+++ b/integration/tests/mosaic/organization-switcher.test.ts
@@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })(
});
test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context });
await trigger(page).click();
await popup(page).getByRole('button', { name: 'Settings' }).click();
- const profile = page.locator('.cl-organizationProfile-root');
- await profile.getByText('Custom page').click();
+ await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true');
+ await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0);
+ await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
},
From fff800948a546b6da9d19bd6aa61ceb1ac7ff2d7 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 12:38:00 -0600
Subject: [PATCH 06/12] fix(mosaic): render the profile opened from UserButton
in modal mode
---
.../__tests__/user-button.feature.test.tsx | 20 +++++++++++++++++++
.../user-profile.modal-content.tsx | 5 +++--
.../features/user-profile/user-profile.tsx | 16 ++++++++++++---
3 files changed, 36 insertions(+), 5 deletions(-)
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
index 2259d4dbba7..4b394a78907 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
@@ -21,6 +21,7 @@ import {
import { openMenu } from '../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { PortalProvider } from '../../../primitives/portal';
+import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures';
import type { UserButtonProps } from '../user-button';
import { UserButton } from '../user-button';
@@ -486,6 +487,25 @@ describe('UserButton', () => {
await waitFor(() => expect(popup()).toBeNull());
});
+ it('opens the profile as a modal, so an OAuth connection returns to it', async () => {
+ await wideViewport();
+ await renderUserButton({}, withConnectedAccounts([]));
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = await open();
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+ await user.click(await screen.findByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
+ expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
+ componentName: 'UserProfile',
+ socialProvider: 'github',
+ });
+ request.fail();
+ });
+
it('opens the profile into the portal root the app provides', async () => {
await wideViewport();
const container = document.body.appendChild(document.createElement('div'));
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
index f0830a0b37e..c9d35d442de 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx
@@ -1,7 +1,7 @@
import { useUser } from '@clerk/shared/react';
import React from 'react';
-import { UserProfile } from './user-profile';
+import { UserProfileSurface } from './user-profile';
import type { UserProfileModalContentProps } from './user-profile.modal.types';
export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement {
@@ -16,8 +16,9 @@ export default function UserProfileModalContent({ options, close }: UserProfileM
const { page, ...props } = options ?? {};
return (
-
);
diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx
index 99e07acb914..d352d11380c 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -120,7 +120,17 @@ export interface UserProfileProps
*
* ```
*/
-export function UserProfile({
+export function UserProfile(props: UserProfileProps) {
+ return (
+
+ );
+}
+
+export function UserProfileSurface({
+ presentation,
mode = 'combined',
organizationProfileProps,
customPages,
@@ -129,7 +139,7 @@ export function UserProfile({
additionalOAuthScopes,
fallback,
apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {},
-}: UserProfileProps) {
+}: UserProfileProps & { presentation: NonNullable }) {
const model = useUserProfileUserModel();
const builtInPages = useUserProfilePages();
@@ -156,7 +166,7 @@ export function UserProfile({
return (
{mode === 'user' ? (
From 4d8889123e7b3e653db04737fc3712ff71c26b96 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 13:11:20 -0600
Subject: [PATCH 07/12] feat(mosaic): open the Mosaic profiles from the
switchers only behind experimental.newProfiles
---
.../mosaic/src/__tests__/feature/render.tsx | 9 +-
.../organization-switcher.feature.test.tsx | 62 ++++--
.../__tests__/switcher.pages.test.tsx | 190 +++++++++++++++++
.../src/features/switcher/switcher.model.tsx | 59 ++++--
.../src/features/switcher/switcher.pages.tsx | 128 +++++++++++-
.../mosaic/src/features/switcher/switcher.tsx | 94 +++++++--
.../__tests__/user-button.feature.test.tsx | 178 +++++++++-------
.../user-button.new-profiles.feature.test.tsx | 192 ++++++++++++++++++
.../user-profile/user-profile.types.ts | 14 ++
.../src/hooks/use-mosaic-experimental.ts | 20 ++
packages/mosaic/src/mosaic-provider.tsx | 22 +-
packages/swingset/src/stories/user-button.mdx | 7 +-
12 files changed, 837 insertions(+), 138 deletions(-)
create mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
create mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
create mode 100644 packages/mosaic/src/hooks/use-mosaic-experimental.ts
diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx
index 4ed0152569b..933e4dbffca 100644
--- a/packages/mosaic/src/__tests__/feature/render.tsx
+++ b/packages/mosaic/src/__tests__/feature/render.tsx
@@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { vi } from 'vitest';
+import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental';
import type { MosaicLocalization } from '../../localization';
import { MosaicProvider } from '../../mosaic-provider';
import { publishableKey } from './fake-fapi';
@@ -12,6 +13,7 @@ export async function renderWithClerk(
ui: ReactElement,
options?: Parameters[0],
localization?: MosaicLocalization,
+ experimental?: MosaicExperimental,
) {
const clerk = new Clerk(publishableKey());
const navigate = vi.fn((_to: string) => Promise.resolve());
@@ -21,7 +23,12 @@ export async function renderWithClerk(
clerk={clerk}
clerkStatus={clerk.status}
>
- {element}
+
+ {element}
+
);
let current = ui;
diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
index aea996da81e..36ba0bc99fa 100644
--- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
+++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx
@@ -47,11 +47,6 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi
const bobSession = fapiSession({ id: 'sess_2', user: bob });
const personalSession = fapiSession({ id: 'sess_1', user: alice });
-async function wideViewport() {
- await page.viewport(1280, 800);
- onTestFinished(() => page.viewport(414, 896));
-}
-
function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed {
return {
client: fapiClient([aliceSession, bobSession]),
@@ -135,8 +130,8 @@ describe('OrganizationSwitcher', () => {
});
it('heads the surface with the active organization, and manages it from the rows beneath', async () => {
- await wideViewport();
- await renderSwitcher({}, noOffers());
+ const { clerk } = await renderSwitcher({}, noOffers());
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await openWithList();
const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup();
@@ -151,8 +146,7 @@ describe('OrganizationSwitcher', () => {
await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.queryByRole('menu')).toBeNull();
- expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
- expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull();
+ expect(openOrganizationProfile).toHaveBeenCalled();
});
it('falls back to the account, with nothing to manage, where no organization is active', async () => {
@@ -223,19 +217,16 @@ describe('OrganizationSwitcher', () => {
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other'));
});
- it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
- await wideViewport();
- const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
+ it('tells the organization profile where to land after leaving', async () => {
+ const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await openWithList();
await user.click(screen.getByRole('button', { name: 'Settings' }));
- const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
- role: 'alertdialog',
- });
- await user.type(within(dialog).getByRole('textbox'), 'Acme');
- await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
- await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
+ expect(openOrganizationProfile).toHaveBeenCalledWith(
+ expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }),
+ );
});
it('tells create-organization where to land and whether to skip inviting members', async () => {
@@ -384,3 +375,38 @@ describe('OrganizationSwitcher custom trigger badges', () => {
expect(trigger()).toHaveAccessibleName('Open organization menu for Other');
});
});
+
+describe('OrganizationSwitcher with experimental.newProfiles', () => {
+ async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+ serveFapi(noOffers());
+ return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true });
+ }
+
+ it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
+ const { clerk } = await renderWithNewProfiles();
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile');
+ const user = await openWithList();
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+
+ expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull();
+ expect(openOrganizationProfile).not.toHaveBeenCalled();
+ });
+
+ it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
+ const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' });
+ const user = await openWithList();
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+ const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
+ role: 'alertdialog',
+ });
+ await user.type(within(dialog).getByRole('textbox'), 'Acme');
+ await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
+
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
+ });
+});
diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
new file mode 100644
index 00000000000..8db622b685e
--- /dev/null
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
@@ -0,0 +1,190 @@
+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 { CustomProfileItem } from '../../user-profile/user-profile.types';
+import type { CustomPagesOptions } from '../switcher.pages';
+import { useCustomPages } from '../switcher.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 {portals}
;
+}
+
+const terms: CustomProfileItem = {
+ label: 'Terms',
+ path: 'terms',
+ icon: terms icon,
+ content: Terms body
,
+};
+
+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 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']);
+ });
+
+ // 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/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx
index 698b7957a57..cde2769e7f9 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -2,14 +2,17 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react';
import type {
+ OrganizationProfileModalProps,
OrganizationResource,
OrganizationSwitcherProps,
UserButtonProps as ClerkUserButtonProps,
+ UserProfileModalProps,
UserResource,
} from '@clerk/shared/types';
import { populateParamFromObject } from '@clerk/shared/url';
import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
+import { useMosaicExperimental } from '../../hooks/use-mosaic-experimental';
import { useMosaicRouter } from '../../hooks/use-mosaic-router';
import { useModal } from '../../modals/use-modal';
import { useGetPortalContainer } from '../../primitives/portal';
@@ -112,11 +115,16 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode &
/** Props forwarded to the profile modals this button opens. */
export interface SwitcherModalProps {
- userProfile?: Pick<
- UserProfileModalOptions,
- 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
- >;
- organizationProfile?: Pick;
+ userProfile?: Pick;
+ organizationProfile?: Pick;
+ /** What Mosaic's own profiles take when `experimental.newProfiles` opens them instead. */
+ newProfiles?: {
+ userProfile?: Pick<
+ UserProfileModalOptions,
+ 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
+ >;
+ organizationProfile?: Pick;
+ };
}
function resolveAfterUrl(
@@ -179,8 +187,10 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession {
}
/**
- * @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the
- * organization's pages, so managing the organization opens it on them.
+ * @param modals - Props forwarded to the profile modals. Custom pages for Clerk's arrive already bridged,
+ * since only the wrapper can render the portals behind them. With `experimental.newProfiles`, a
+ * `combined` user profile also carries the organization's pages, so managing the organization opens it
+ * on them.
*/
export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel {
const { isLoaded: isUserLoaded, user } = useUser();
@@ -193,6 +203,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const router = useMosaicRouter();
const userProfile = useModal(userProfileModal);
const organizationProfile = useModal(organizationProfileModal);
+ const { newProfiles } = useMosaicExperimental();
// The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
const getContainer = useGetPortalContainer();
const environment = useMosaicEnvironment();
@@ -204,15 +215,20 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
enabled: Boolean(environment?.organizationSettings.enabled),
});
- const organizationProfileProps = {
- ...modals?.organizationProfile,
- afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl,
- };
+ const afterLeaveOrganizationUrl = options?.afterLeaveOrganizationUrl;
+ const mosaicUserProfile = modals?.newProfiles?.userProfile;
+ const mosaicOrganizationProfile = { ...modals?.newProfiles?.organizationProfile, afterLeaveOrganizationUrl };
const manageAccount = openOrNavigate({
url: options?.userProfileUrl,
mode: options?.userProfileMode,
- openModal: () => userProfile.open({ ...modals?.userProfile, organizationProfileProps }, getContainer?.()),
+ openModal: () =>
+ newProfiles
+ ? userProfile.open(
+ { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile },
+ getContainer?.(),
+ )
+ : clerk.openUserProfile({ getContainer, ...modals?.userProfile }),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
@@ -220,13 +236,18 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const manageOrganization = openOrNavigate({
url: options?.organizationProfileUrl,
mode: options?.organizationProfileMode,
- openModal: () =>
- modals?.userProfile?.mode === 'combined'
- ? userProfile.open(
- { ...modals.userProfile, organizationProfileProps, page: 'organization/general' },
- getContainer?.(),
- )
- : organizationProfile.open(organizationProfileProps, getContainer?.()),
+ openModal: () => {
+ if (!newProfiles) {
+ clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl });
+ } else if (mosaicUserProfile?.mode === 'combined') {
+ userProfile.open(
+ { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' },
+ getContainer?.(),
+ );
+ } else {
+ organizationProfile.open(mosaicOrganizationProfile, getContainer?.());
+ }
+ },
buildUrl: () => clerk.buildOrganizationProfileUrl(),
navigate: router.navigate,
});
diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx
index 4a7b6cc63e8..301e64b0d0c 100644
--- a/packages/mosaic/src/features/switcher/switcher.pages.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx
@@ -1,12 +1,24 @@
import {
+ disabledOrganizationAPIKeysFeature,
+ disabledOrganizationBillingFeature,
+ disabledSelfServeDirectorySyncFeature,
+ disabledSelfServeSSOFeature,
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/use-mosaic-environment';
+import { applyOrder } from '../../utils/apply-order';
import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout';
-import type { UserProfilePageId } from '../user-profile/user-profile.types';
+import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types';
+
+/** A built-in OrganizationProfile page, by the id its navigation uses. */
+export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security';
/**
* The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned
@@ -27,3 +39,117 @@ export function useUserProfilePages(): UserProfilePageId[] {
};
return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]);
}
+
+/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */
+export function useOrganizationProfilePages(): OrganizationProfilePageId[] {
+ const clerk = useClerk();
+ const environment = useMosaicEnvironment();
+
+ const pages: OrganizationProfilePageId[] = ['general', 'members'];
+ if (!disabledOrganizationBillingFeature(clerk, environment)) {
+ pages.push('billing');
+ }
+ if (!disabledOrganizationAPIKeysFeature(clerk, environment)) {
+ pages.push('apiKeys');
+ }
+ if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) {
+ pages.push('security');
+ }
+ return pages;
+}
+
+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/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx
index c1e7c9ec0b2..bd5694e8124 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -1,13 +1,14 @@
'use client';
-import type { UserProfileProps } from '@clerk/shared/types';
+import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types';
import type { ReactElement, ReactNode } from 'react';
-import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
-import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
+import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
import { useSwitcherController } from './switcher.controller';
import type { SwitcherModelOptions } from './switcher.model';
import { useSwitcherModel } from './switcher.model';
+import type { OrganizationProfilePageId } from './switcher.pages';
+import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages';
import type { SwitcherMenuProps, SwitcherMode } from './switcher.types';
import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view';
import { SwitcherView } from './switcher.view';
@@ -15,13 +16,13 @@ import { SwitcherView } from './switcher.view';
/** What a profile opened from the switcher takes beyond the profile component's own props. */
export interface SwitcherProfilePages {
/**
- * Provide custom pages to be rendered inside the profile.
+ * Provide custom pages and links to be rendered inside the profile.
*/
- customPages?: CustomProfilePage[];
+ customPages?: CustomProfileItem[];
/**
* Controls the order of the profile's navigation. Accepts the ids of built-in pages and the
* `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is
- * the page the profile opens on.
+ * the page the profile opens on, so it cannot be a link.
*
* @default undefined
*/
@@ -30,10 +31,13 @@ export interface SwitcherProfilePages {
/** Options for the underlying `` component. */
export interface SwitcherUserProfileProps
- extends SwitcherProfilePages, Pick {}
+ extends
+ SwitcherProfilePages,
+ Pick {}
/** Options for the underlying `` component. */
-export type SwitcherOrganizationProfileProps = SwitcherProfilePages;
+export interface SwitcherOrganizationProfileProps
+ extends SwitcherProfilePages, Pick {}
export type SwitcherProps = SwitcherModelOptions &
SwitcherTriggerProps &
@@ -45,7 +49,7 @@ export type SwitcherProps = SwitcherModelOptions &
userProfileProps?: SwitcherUserProfileProps;
/**
* Specify options for the underlying component.
- * e.g., organizationProfileProps={{pageOrder: ['members', 'general']}}
+ * e.g., organizationProfileProps={{appearance: {...}}}
*/
organizationProfileProps?: SwitcherOrganizationProfileProps;
/**
@@ -72,29 +76,81 @@ export function Switcher(
fallback,
...options
} = props;
+ // The portals must outlive the popover, so custom pages are bridged here rather than inside it.
+ const userProfile = useCustomPages({
+ items: userProfileProps?.customPages,
+ order: userProfileProps?.pageOrder,
+ builtInPages: useUserProfilePages(),
+ });
+ const organizationProfile = useCustomPages({
+ items: organizationProfileProps?.customPages,
+ order: organizationProfileProps?.pageOrder,
+ builtInPages: useOrganizationProfilePages(),
+ });
+ const portals = (
+ <>
+ {userProfile.portals}
+ {organizationProfile.portals}
+ >
+ );
const model = useSwitcherModel(options, {
- userProfile: { ...userProfileProps, mode: mode === 'combined' ? 'combined' : 'user' },
- organizationProfile: organizationProfileProps,
+ userProfile: {
+ customPages: userProfile.customPages,
+ additionalOAuthScopes: userProfileProps?.additionalOAuthScopes,
+ apiKeysProps: userProfileProps?.apiKeysProps,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched.
+ appearance: userProfileProps?.appearance,
+ },
+ organizationProfile: {
+ customPages: organizationProfile.customPages,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched.
+ appearance: organizationProfileProps?.appearance,
+ },
+ newProfiles: {
+ userProfile: {
+ customPages: pagesOnly(userProfileProps?.customPages),
+ pageOrder: userProfileProps?.pageOrder,
+ additionalOAuthScopes: userProfileProps?.additionalOAuthScopes,
+ apiKeysProps: userProfileProps?.apiKeysProps,
+ mode: mode === 'combined' ? 'combined' : 'user',
+ },
+ organizationProfile: {
+ customPages: pagesOnly(organizationProfileProps?.customPages),
+ pageOrder: organizationProfileProps?.pageOrder,
+ },
+ },
});
const controller = useSwitcherController(model, { mode, 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 { status: _status, ...viewController } = controller;
return (
-
+ <>
+
+ {portals}
+ >
);
}
+
+function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined {
+ return items?.filter((item): item is CustomProfilePage => item.href === undefined);
+}
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
index 4b394a78907..4ebb5a240dd 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx
@@ -1,8 +1,7 @@
-import type { UserJSON, Web3WalletJSON } from '@clerk/shared/types';
+import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
-import { page } from 'vitest/browser';
import { deferred } from '../../../__tests__/async';
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
@@ -21,7 +20,6 @@ import {
import { openMenu } from '../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { PortalProvider } from '../../../primitives/portal';
-import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures';
import type { UserButtonProps } from '../user-button';
import { UserButton } from '../user-button';
@@ -113,13 +111,6 @@ const reading = (...names: string[]) =>
.filter(node => !node.closest('.cl-user-button-header'))
.map(node => node.textContent);
-async function wideViewport() {
- await page.viewport(1280, 800);
- onTestFinished(() => page.viewport(414, 896));
-}
-
-const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
-
const current = () =>
Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent);
@@ -474,49 +465,28 @@ describe('UserButton', () => {
describe('handing off', () => {
it('opens the UserProfile modal and closes when managing the account', async () => {
- await wideViewport();
const { clerk, navigate } = await renderUserButton();
- const openUserProfile = vi.spyOn(clerk, 'openUserProfile');
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
- expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
- expect(openUserProfile).not.toHaveBeenCalled();
+ expect(openUserProfile).toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
await waitFor(() => expect(popup()).toBeNull());
});
- it('opens the profile as a modal, so an OAuth connection returns to it', async () => {
- await wideViewport();
- await renderUserButton({}, withConnectedAccounts([]));
- const request = holdRequests('post', '/v1/me/external_accounts');
- const user = await open();
-
- await user.click(screen.getByRole('button', { name: 'Settings' }));
- await user.click(await screen.findByRole('button', { name: 'Connect GitHub' }));
- await waitFor(() => expect(request.requests).toHaveLength(1));
-
- const body = new URLSearchParams(await request.requests[0]?.text());
- const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
- expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
- componentName: 'UserProfile',
- socialProvider: 'github',
- });
- request.fail();
- });
-
- it('opens the profile into the portal root the app provides', async () => {
- await wideViewport();
+ it('hands the opened profile the portal root the app provides', async () => {
const container = document.body.appendChild(document.createElement('div'));
onTestFinished(() => container.remove());
serveFapi(signedIn());
- await renderWithClerk({tree()});
+ const { clerk } = await renderWithClerk({tree()});
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
- expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument();
+ expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container);
});
it('runs a custom menu item and closes', async () => {
@@ -530,55 +500,116 @@ describe('UserButton', () => {
await waitFor(() => expect(popup()).toBeNull());
});
- it('renders a custom page inside the opened profile', async () => {
- await wideViewport();
- await renderUserButton({ userProfileProps: { customPages: [terms] } });
+ it('renders a custom page into the element the opened profile hands back', async () => {
+ const { clerk } = await renderUserButton({
+ userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
+ });
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
- await user.click(await screen.findByRole('tab', { name: 'Terms' }));
+ await waitFor(() => expect(popup()).toBeNull());
- expect(screen.getByText('Terms body')).toBeVisible();
+ const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
+ expect(customPages).toHaveLength(1);
+ expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ act(() => {
+ customPages[0]?.mount?.(el);
+ });
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
});
- it('opens the profile on the first page of the order it was given', async () => {
- await wideViewport();
- await renderUserButton({ userProfileProps: { customPages: [terms], pageOrder: ['terms', 'account'] } });
+ it('opens the profile with its pages in the order it was given', async () => {
+ const { clerk } = await renderUserButton({
+ userProfileProps: {
+ customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}],
+ pageOrder: ['account', 'terms'],
+ },
+ });
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
+ await waitFor(() => expect(popup()).toBeNull());
- expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true');
- expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']);
+ const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
});
it.each([
- { features: {}, tabs: ['Terms', 'Account', 'Security', 'General', 'Members'] },
- { features: { apiKeys: true }, tabs: ['Terms', 'Account', 'Security', 'API Keys', 'General', 'Members'] },
- ])('orders the UserProfile pages the instance turns on: $features', async ({ features, tabs }) => {
- await wideViewport();
- await renderUserButton(
+ { features: {}, pages: ['Terms', 'account', 'security'] },
+ { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] },
+ ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => {
+ const { clerk } = await renderUserButton(
{ userProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
signedIn({ environment: instanceWith(features) }),
);
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
- await screen.findByRole('tab', { name: 'Terms' });
+ await waitFor(() => expect(popup()).toBeNull());
- expect(profileTabs()).toEqual(tabs);
+ const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
});
- it("opens the combined profile on the organization's General page, with its own pages in order", async () => {
- await wideViewport();
- await renderUserButton({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } });
- const user = await open();
+ it.each([
+ { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] },
+ {
+ features: { billing: true, apiKeys: true },
+ organization: {},
+ pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'],
+ },
+ { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] },
+ {
+ features: { sso: true },
+ organization: { self_serve_sso_enabled: true },
+ pages: ['Terms', 'general', 'members', 'security'],
+ },
+ {
+ features: { sso: true, directorySync: true },
+ organization: {},
+ pages: ['Terms', 'general', 'members'],
+ },
+ {
+ features: { sso: true, directorySync: true },
+ organization: { self_serve_directory_sync_enabled: true },
+ pages: ['Terms', 'general', 'members', 'security'],
+ },
+ ])(
+ 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization',
+ async ({ features, organization, pages }) => {
+ const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization });
+ const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })];
+ const { clerk } = await renderUserButton(
+ { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
+ signedIn({
+ environment: instanceWith(features),
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_1',
+ user: { ...alice, organization_memberships: memberships },
+ last_active_organization_id: 'org_1',
+ }),
+ ]),
+ memberships,
+ }),
+ );
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ const user = await open();
- await openSettings(user, 'Manage organization');
+ await openSettings(user, 'Manage organization');
+ await waitFor(() => expect(popup()).toBeNull());
- expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
- expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']);
- });
+ const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? [];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages);
+ },
+ );
it('opens the InviteMembers modal and closes', async () => {
const { clerk, navigate } = await renderUserButton();
@@ -645,23 +676,25 @@ describe('UserButton', () => {
});
it('navigates to manage the account when a URL routes it', async () => {
- const { navigate } = await renderUserButton({ userProfileUrl: '/account' });
+ const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' });
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage profile');
expect(navigate).toHaveBeenCalledWith('/account');
- expect(screen.queryByRole('tab')).toBeNull();
+ expect(openUserProfile).not.toHaveBeenCalled();
});
it('navigates to manage the organization when a URL routes it', async () => {
- const { navigate } = await renderUserButton({ organizationProfileUrl: '/org' });
+ const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' });
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await open();
await openSettings(user, 'Manage organization');
expect(navigate).toHaveBeenCalledWith('/org');
- expect(screen.queryByRole('tab')).toBeNull();
+ expect(openOrganizationProfile).not.toHaveBeenCalled();
});
it('sends add-account to the sign-in page', async () => {
@@ -899,8 +932,8 @@ describe('UserButton', () => {
});
it('manages the account from its settings row, and signs out at the foot', async () => {
- await wideViewport();
- await renderUserButton({ mode: 'user' });
+ const { clerk } = await renderUserButton({ mode: 'user' });
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
expect(
@@ -911,8 +944,7 @@ describe('UserButton', () => {
await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(screen.queryByRole('menu')).toBeNull();
- expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
- expect(profileTabs()).toEqual(['Account', 'Security']);
+ expect(openUserProfile).toHaveBeenCalled();
});
it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => {
@@ -940,8 +972,8 @@ describe('UserButton', () => {
});
it('heads the surface with the active organization, badged with the account, managing both', async () => {
- await wideViewport();
- await renderUserButton();
+ const { clerk } = await renderUserButton();
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
const user = await open();
@@ -963,7 +995,7 @@ describe('UserButton', () => {
'Sign out',
]);
await openSettings(user, 'Manage organization');
- expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(openOrganizationProfile).toHaveBeenCalled();
});
it('keeps the identifier beneath the organization even when it is all that names the account', async () => {
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
new file mode 100644
index 00000000000..0bc73ec3945
--- /dev/null
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
@@ -0,0 +1,192 @@
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, onTestFinished, vi } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEmailAddress,
+ fapiInvitation,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiSuggestion,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { openMenu } from '../../../__tests__/feature/menu';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { PortalProvider } from '../../../primitives/portal';
+import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures';
+import type { UserButtonProps } from '../user-button';
+import { UserButton } from '../user-button';
+
+const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
+const other = fapiOrganization({ id: 'org_9', name: 'Other' });
+const beta = fapiOrganization({ id: 'org_2', name: 'Beta' });
+const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma' });
+
+const aliceMemberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(other)];
+
+const alice = fapiUser({
+ id: 'user_1',
+ first_name: 'Alice',
+ last_name: 'Smith',
+ username: 'alice',
+ email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })],
+ organization_memberships: aliceMemberships,
+});
+
+const bob = fapiUser({
+ id: 'user_2',
+ first_name: 'Bob',
+ last_name: 'Jones',
+ email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })],
+});
+
+const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' });
+const bobSession = fapiSession({ id: 'sess_2', user: bob });
+
+function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed {
+ return {
+ client: fapiClient([aliceSession, bobSession]),
+ memberships: aliceMemberships,
+ invitations: [fapiInvitation('inv_1', gamma)],
+ suggestions: [fapiSuggestion('sug_1', beta)],
+ ...overrides,
+ };
+}
+
+function tree(props: UserButtonProps = {}) {
+ return (
+
+
+
+ );
+}
+
+type User = ReturnType;
+
+const trigger = () => screen.getByRole('button', { name: /Open account menu/ });
+const popup = () => screen.queryByRole('dialog', { name: 'Account' });
+
+async function open() {
+ const user = userEvent.setup();
+ await user.click(trigger());
+ expect(popup()).toBeInTheDocument();
+ return user;
+}
+
+async function openSettings(user: User, label: string) {
+ await openMenu(user, within(requiredPopup()).getByRole('button', { name: 'Settings' }));
+ await user.click(screen.getByRole('menuitem', { name: label }));
+}
+
+function requiredPopup() {
+ const surface = popup();
+ if (!surface) {
+ throw new Error('expected the popover to be open');
+ }
+ return surface;
+}
+
+const terms = { label: 'Terms', path: 'terms', content: Terms body
};
+describe('UserButton with experimental.newProfiles', () => {
+ const newProfiles = { newProfiles: true };
+
+ async function renderWithNewProfiles(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+ serveFapi(seed);
+ return renderWithClerk(tree(props), undefined, undefined, newProfiles);
+ }
+
+ const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
+
+ it('opens the combined UserProfile when managing the account, and closes', async () => {
+ const { clerk, navigate } = await renderWithNewProfiles();
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile');
+ const user = await open();
+
+ await openSettings(user, 'Manage profile');
+
+ expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Account', 'Security', 'General', 'Members']);
+ expect(openUserProfile).not.toHaveBeenCalled();
+ expect(navigate).not.toHaveBeenCalled();
+ await waitFor(() => expect(popup()).toBeNull());
+ });
+
+ it('opens the profile as a modal, so an OAuth connection returns to it', async () => {
+ await renderWithNewProfiles({}, withConnectedAccounts([]));
+ const request = holdRequests('post', '/v1/me/external_accounts');
+ const user = await open();
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+ await user.click(await screen.findByRole('button', { name: 'Connect GitHub' }));
+ await waitFor(() => expect(request.requests).toHaveLength(1));
+
+ const body = new URLSearchParams(await request.requests[0]?.text());
+ const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state');
+ expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({
+ componentName: 'UserProfile',
+ socialProvider: 'github',
+ });
+ request.fail();
+ });
+
+ it('opens the profile into the portal root the app provides', async () => {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+ const container = document.body.appendChild(document.createElement('div'));
+ onTestFinished(() => container.remove());
+ serveFapi(signedIn());
+ await renderWithClerk(
+ {tree()},
+ undefined,
+ undefined,
+ newProfiles,
+ );
+ const user = await open();
+
+ await openSettings(user, 'Manage profile');
+
+ expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument();
+ });
+
+ it('renders custom pages in their order, and leaves out links', async () => {
+ await renderWithNewProfiles({
+ userProfileProps: {
+ customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }],
+ pageOrder: ['terms', 'account'],
+ },
+ });
+ const user = await open();
+
+ await openSettings(user, 'Manage profile');
+
+ expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Terms body')).toBeVisible();
+ expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']);
+ });
+
+ it("opens the combined profile on the organization's General page, with its own pages in order", async () => {
+ await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } });
+ const user = await open();
+
+ await openSettings(user, 'Manage organization');
+
+ expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']);
+ });
+
+ it("leaves the organization's pages out in user mode", async () => {
+ await renderWithNewProfiles({ mode: 'user' });
+ const user = await open();
+
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+
+ expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
+ expect(profileTabs()).toEqual(['Account', 'Security']);
+ });
+});
diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts
index 77ff769e925..dcd57d0fa44 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts
@@ -40,3 +40,17 @@ export interface CustomProfilePage {
/** 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;
diff --git a/packages/mosaic/src/hooks/use-mosaic-experimental.ts b/packages/mosaic/src/hooks/use-mosaic-experimental.ts
new file mode 100644
index 00000000000..8acce7161c4
--- /dev/null
+++ b/packages/mosaic/src/hooks/use-mosaic-experimental.ts
@@ -0,0 +1,20 @@
+import { createContext, useContext } from 'react';
+
+export interface MosaicExperimental {
+ /**
+ * Opens Mosaic's own `UserProfile` and `OrganizationProfile` from `UserButton` and
+ * `OrganizationSwitcher`, instead of Clerk's profile modals. They skip `appearance` and the link
+ * entries of `customPages`.
+ *
+ * @default false
+ */
+ newProfiles?: boolean;
+}
+
+const MosaicExperimentalContext = createContext({});
+
+export const MosaicExperimentalProvider = MosaicExperimentalContext.Provider;
+
+export function useMosaicExperimental(): MosaicExperimental {
+ return useContext(MosaicExperimentalContext);
+}
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index c3e65bde3e7..3d7355893ba 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -3,6 +3,8 @@ import React from 'react';
import { ToastProvider } from './components/toast/toast';
import { organizationProfileModal } from './features/organization-profile/organization-profile.modal';
import { userProfileModal } from './features/user-profile/user-profile.modal';
+import type { MosaicExperimental } from './hooks/use-mosaic-experimental';
+import { MosaicExperimentalProvider } from './hooks/use-mosaic-experimental';
import { MosaicNowProvider } from './hooks/use-now';
import type { MosaicIconOverrides } from './icons/overrides';
import { MosaicIconsProvider } from './icons/overrides';
@@ -21,24 +23,36 @@ export interface MosaicProviderProps {
icons?: MosaicIconOverrides;
/** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */
localization?: MosaicLocalization;
+ /** Features still being tried out. Their names and behavior can change in any release. */
+ experimental?: MosaicExperimental;
}
// Exported Mosaic components annotate a React return type on purpose. The package sets
// `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the
// published `.d.ts`, which React 19 consumers reject as a JSX element type.
-export function MosaicProvider({ children, direction, icons, localization }: MosaicProviderProps): React.ReactElement {
+export function MosaicProvider({
+ children,
+ direction,
+ icons,
+ localization,
+ experimental,
+}: MosaicProviderProps): React.ReactElement {
const iconsValue = React.useMemo(() => icons ?? {}, [icons]);
const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]);
const [now] = React.useState(() => new Date());
+ const newProfiles = experimental?.newProfiles;
+ const experimentalValue = React.useMemo(() => ({ newProfiles }), [newProfiles]);
return (
-
- {children}
-
+
+
+ {children}
+
+
diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx
index fba984c2386..4c4eb01c34e 100644
--- a/packages/swingset/src/stories/user-button.mdx
+++ b/packages/swingset/src/stories/user-button.mdx
@@ -219,9 +219,10 @@ all accounts**. The foot keeps **Sign out**.
## Profile modal
Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a
-dialog. The exported UserButton opens Mosaic's `UserProfile` the same way, against the signed-in
-user. This example backs the modal with fixture data, so it runs without a Clerk session. The profile is combined: the active organization's
-pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls
+dialog. With `experimental={{ newProfiles: true }}` on `MosaicProvider`, the exported UserButton opens
+Mosaic's `UserProfile` the same way, against the signed-in user; without it, it opens Clerk's profile.
+This example backs the modal with fixture data, so it runs without a Clerk session. The profile is
+combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls
`open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or
`open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a
wait.
From d344f06f77ff1f345aa4b2862a24641d2c639c93 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 13:11:22 -0600
Subject: [PATCH 08/12] test(mosaic): turn on experimental.newProfiles in the
Mosaic integration app
---
.../templates/next-app-router-mosaic/src/app/custom/page.tsx | 2 +-
.../next-app-router-mosaic/src/app/mosaic-user-button.tsx | 2 +-
.../src/app/organization-switcher/page.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
index 3656841dc26..8fc90b59a4a 100644
--- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
+++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx
@@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';
export default function Page() {
return (
-
+
+
);
diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx
index 401ff9bcefc..876c036cf59 100644
--- a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx
+++ b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx
@@ -4,7 +4,7 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic';
export default function Page() {
return (
-
+
Date: Thu, 29 Oct 2026 13:35:28 -0600
Subject: [PATCH 09/12] feat(mosaic): open the combined profile straight from
Settings behind experimental.newProfiles
---
.../src/features/switcher/switcher.model.tsx | 15 +++++++++---
.../user-button.new-profiles.feature.test.tsx | 24 +++++++++++++------
2 files changed, 29 insertions(+), 10 deletions(-)
diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx
index cde2769e7f9..26b51fd4ffd 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -140,6 +140,10 @@ function resolveAfterUrl(
return undefined;
}
+function opensModal(url: string | undefined, mode: 'navigation' | 'modal' | undefined): boolean {
+ return (mode ?? (url ? 'navigation' : 'modal')) === 'modal';
+}
+
/** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */
function openOrNavigate({
url,
@@ -154,8 +158,7 @@ function openOrNavigate({
buildUrl: () => string;
navigate: (to: string) => unknown;
}): () => void {
- const resolved = mode ?? (url ? 'navigation' : 'modal');
- return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal();
+ return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl());
}
const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage';
@@ -252,6 +255,12 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
navigate: router.navigate,
});
+ const managesBothInOneProfile =
+ newProfiles &&
+ mosaicUserProfile?.mode === 'combined' &&
+ opensModal(options?.userProfileUrl, options?.userProfileMode) &&
+ opensModal(options?.organizationProfileUrl, options?.organizationProfileMode);
+
const createOrganization = openOrNavigate({
url: options?.createOrganizationUrl,
mode: options?.createOrganizationMode,
@@ -383,7 +392,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
// 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,
+ onManageOrganization: managesBothInOneProfile ? undefined : 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.
diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
index 0bc73ec3945..b41f67b573a 100644
--- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
+++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx
@@ -103,13 +103,14 @@ describe('UserButton with experimental.newProfiles', () => {
const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
- it('opens the combined UserProfile when managing the account, and closes', async () => {
+ it('opens the combined UserProfile straight from Settings, without a menu, and closes', async () => {
const { clerk, navigate } = await renderWithNewProfiles();
const openUserProfile = vi.spyOn(clerk, 'openUserProfile');
const user = await open();
- await openSettings(user, 'Manage profile');
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
+ expect(screen.queryByRole('menu')).toBeNull();
expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
expect(profileTabs()).toEqual(['Account', 'Security', 'General', 'Members']);
expect(openUserProfile).not.toHaveBeenCalled();
@@ -149,7 +150,7 @@ describe('UserButton with experimental.newProfiles', () => {
);
const user = await open();
- await openSettings(user, 'Manage profile');
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument();
});
@@ -163,23 +164,32 @@ describe('UserButton with experimental.newProfiles', () => {
});
const user = await open();
- await openSettings(user, 'Manage profile');
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('Terms body')).toBeVisible();
expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']);
});
- it("opens the combined profile on the organization's General page, with its own pages in order", async () => {
+ it("lists the organization's own pages in order in the combined profile", async () => {
await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } });
const user = await open();
- await openSettings(user, 'Manage organization');
+ await user.click(screen.getByRole('button', { name: 'Settings' }));
- expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
+ expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true');
expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']);
});
+ it('still asks which to manage when the organization profile routes to a page', async () => {
+ const { navigate } = await renderWithNewProfiles({ organizationProfileUrl: '/org' });
+ const user = await open();
+
+ await openSettings(user, 'Manage organization');
+
+ expect(navigate).toHaveBeenCalledWith('/org');
+ });
+
it("leaves the organization's pages out in user mode", async () => {
await renderWithNewProfiles({ mode: 'user' });
const user = await open();
From a59f604b3f2d8f96394ba36f7df9edcf463fafb1 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 13:35:29 -0600
Subject: [PATCH 10/12] chore(swingset): turn on experimental.newProfiles in
the live UserButton
---
.../src/app/(clerk)/live/user-button/user-button/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx
index dcd7d510253..37670fef95f 100644
--- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx
+++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx
@@ -9,7 +9,7 @@ export default function UserButtonLivePage() {
const { isLoaded, isSignedIn } = useUser();
return (
-
+
User button
From b9cc624f3d57a69812358ef1be43e794cbe3ef42 Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 14:40:36 -0600
Subject: [PATCH 11/12] test(mosaic): warm up the lazily loaded profile modals
in feature tests
---
packages/mosaic/vitest.config.mts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts
index 4d9b79a4415..8bf6f87ada0 100644
--- a/packages/mosaic/vitest.config.mts
+++ b/packages/mosaic/vitest.config.mts
@@ -60,6 +60,11 @@ export default defineConfig({
{
extends: true,
publicDir: 'test/public',
+ server: {
+ warmup: {
+ clientFiles: ['./src/**/*.modal-content.tsx'],
+ },
+ },
optimizeDeps: {
include: [
'@clerk/clerk-js',
From 41bcfb6adaa3c720c2b642d20b7e1b1ac566308b Mon Sep 17 00:00:00 2001
From: austincalvelage
Date: Thu, 29 Oct 2026 14:46:20 -0600
Subject: [PATCH 12/12] test(mosaic): find profile tabs in the profile
navigation in integration tests
---
integration/tests/mosaic/organization-profile.test.ts | 2 +-
integration/tests/mosaic/user-profile.test.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts
index a7d0cda9388..b4d0fecb7a3 100644
--- a/integration/tests/mosaic/organization-profile.test.ts
+++ b/integration/tests/mosaic/organization-profile.test.ts
@@ -34,7 +34,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic Orga
});
const path = '/organization-profile';
- const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });
+ const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });
async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });
diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts
index 3eab59203f2..6d89555bf08 100644
--- a/integration/tests/mosaic/user-profile.test.ts
+++ b/integration/tests/mosaic/user-profile.test.ts
@@ -34,7 +34,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
});
const path = '/user-profile';
- const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true });
+ const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });
async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });