From 309612e574cc9c1fef1b8f62dee61fa1b31ce841 Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Fri, 30 Oct 2026 11:15:12 -0400
Subject: [PATCH 1/9] chore(swingset): Add profile permutations exploration
page
---
.changeset/profile-permutations-swingset.md | 2 +
.../swingset/src/components/DocsViewer.tsx | 1 +
packages/swingset/src/lib/registry.ts | 2 +
.../src/stories/profile-permutations.mdx | 149 ++++++++++++
.../stories/profile-permutations.stories.tsx | 221 ++++++++++++++++++
5 files changed, 375 insertions(+)
create mode 100644 .changeset/profile-permutations-swingset.md
create mode 100644 packages/swingset/src/stories/profile-permutations.mdx
create mode 100644 packages/swingset/src/stories/profile-permutations.stories.tsx
diff --git a/.changeset/profile-permutations-swingset.md b/.changeset/profile-permutations-swingset.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/profile-permutations-swingset.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 70a536937e0..9094b2133de 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -11,6 +11,7 @@ import { PlaygroundProvider } from './PlaygroundContext';
const docModules: Record> = {
'user-button': {
'user-button': dynamic(() => import('../stories/user-button.mdx')),
+ 'profile-permutations': dynamic(() => import('../stories/profile-permutations.mdx')),
},
'user-profile': {
'user-profile': dynamic(() => import('../stories/user-profile.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 460afde6778..0a7b79fa569 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -246,6 +246,7 @@ import {
meta as profileComponentMeta,
Transitions as ProfileTransitions,
} from '../stories/profile.component.stories';
+import { meta as profilePermutationsMeta } from '../stories/profile-permutations.stories';
import {
Default as ProviderButtonDefault,
Many as ProviderButtonMany,
@@ -1112,6 +1113,7 @@ const organizationProfileMembersPanelModule: StoryModule = {
export const registry: StoryModule[] = [
// User Button
userButtonModule,
+ { meta: profilePermutationsMeta },
// User Profile
userProfileModule,
// User Profile · Panels
diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx
new file mode 100644
index 00000000000..d75687b7002
--- /dev/null
+++ b/packages/swingset/src/stories/profile-permutations.mdx
@@ -0,0 +1,149 @@
+import * as Stories from './profile-permutations.stories';
+
+# Profile permutations
+
+Exploration page: every pairing of switchers and profile shape, running on fixtures. Each example shares
+one account and organization across its triggers, so switching in one updates the other and the profile.
+
+Today `mode` on `UserButton` sets both the menu and the profile. `OrganizationSwitcher` always opens the
+standalone organization profile.
+
+The snippets show the proposed API, which is not implemented. `combined` defaults to `true` in two places:
+on `UserButton` it decides what the menu lists, and on `MosaicProvider`'s `profile` it decides what the
+profile shows.
+
+| # | Switchers | Profile | With the modal today | Proposed config |
+| --- | ---------------------------------------------- | -------- | --------------------------------------------- | ---------------------- |
+| 1 | `UserButton` combined | Combined | Yes (the default) | None |
+| 2 | `UserButton` combined | Separate | No | Profile opt-out |
+| 3 | `UserButton` user | Account | Yes | Menu + profile opt-out |
+| 4 | `UserButton` user | Combined | No | Menu opt-out |
+| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Yes | Menu + profile opt-out |
+| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | No | Menu opt-out |
+| 7 | `OrganizationSwitcher` | Org | Yes | Profile opt-out |
+| 8 | `OrganizationSwitcher` | Combined | No | None |
+| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | No: the switcher opens the standalone profile | None |
+
+The `No` rows can be approximated by routing with `userProfileUrl` and `organizationProfileUrl` to pages
+that mount ``.
+
+## 1. Combined button, combined profile
+
+Settings opens one profile.
+
+```tsx
+
+```
+
+
+
+## 2. Combined button, separate profiles
+
+Settings offers the account and the organization as two targets.
+
+```tsx
+
+
+
+```
+
+
+
+## 3. User button, account profile
+
+```tsx
+
+
+
+```
+
+
+
+## 4. User button, combined profile
+
+No organization rows in the menu, but the profile carries the active organization's pages.
+
+```tsx
+
+```
+
+
+
+## 5. User button and switcher, separate profiles
+
+```tsx
+
+
+
+
+```
+
+
+
+## 6. User button and switcher, combined profile
+
+Both triggers open the same profile. The button lands on the account's pages and the switcher on the
+organization's. Switch to the personal account and the organization group leaves the profile.
+
+```tsx
+
+
+```
+
+
+
+## 7. Switcher, organization profile
+
+```tsx
+
+
+
+```
+
+
+
+## 8. Switcher, combined profile
+
+```tsx
+
+```
+
+
+
+## 9. Combined button and switcher
+
+Both menus list the organizations, and both open the combined profile.
+
+```tsx
+
+
+```
+
+
diff --git a/packages/swingset/src/stories/profile-permutations.stories.tsx b/packages/swingset/src/stories/profile-permutations.stories.tsx
new file mode 100644
index 00000000000..2a09f1ce129
--- /dev/null
+++ b/packages/swingset/src/stories/profile-permutations.stories.tsx
@@ -0,0 +1,221 @@
+import type {
+ OrganizationProfileModalContentProps,
+ OrganizationProfileModalOptions,
+} from '@clerk/mosaic/features/organization-profile/organization-profile.modal.types';
+import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view';
+import type { SwitcherViewProps } from '@clerk/mosaic/features/switcher/switcher.view';
+import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
+import type { UserButtonMode } from '@clerk/mosaic/features/user-button/user-button';
+import type {
+ UserProfileModalContentProps,
+ UserProfileModalOptions,
+} from '@clerk/mosaic/features/user-profile/user-profile.modal.types';
+import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view';
+import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface';
+import type { ModalSurfaces } from '@clerk/mosaic/modals/modal.types';
+import { ModalHost } from '@clerk/mosaic/modals/modal-host';
+import { useModal } from '@clerk/mosaic/modals/use-modal';
+import { useState } from 'react';
+
+import type { StoryMeta } from '@/lib/types';
+
+import { useOrganizationProfileFixture } from './fixtures/organization-profile';
+import { usePrototype } from './fixtures/switcher';
+import { useUserProfileFixture } from './fixtures/user-profile';
+
+export const meta: StoryMeta = {
+ group: 'User Button',
+ title: 'ProfilePermutations',
+ label: 'Profile permutations',
+ status: 'wip',
+ source: 'packages/swingset/src/stories/profile-permutations.stories.tsx',
+};
+
+export function CombinedButtonCombinedProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function CombinedButtonSeparateProfiles(_args: Record) {
+ return (
+
+ );
+}
+
+export function UserButtonAccountProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function UserButtonCombinedProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function UserButtonAndSwitcherSeparateProfiles(_args: Record) {
+ return (
+
+ );
+}
+
+export function UserButtonAndSwitcherCombinedProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function SwitcherOrganizationProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function SwitcherCombinedProfile(_args: Record) {
+ return (
+
+ );
+}
+
+export function CombinedButtonAndSwitcher(_args: Record) {
+ return (
+
+ );
+}
+
+interface PermutationProps {
+ userButton?: UserButtonMode;
+ organizationSwitcher?: boolean;
+ profile: 'combined' | 'separate';
+}
+
+function Permutation(props: PermutationProps) {
+ return (
+
+
+
+ );
+}
+
+function Triggers({ userButton, organizationSwitcher = false, profile }: PermutationProps) {
+ const prototype = usePrototype();
+ const [openSurface, setOpenSurface] = useState<'userButton' | 'organizationSwitcher'>('userButton');
+ const userProfile = useModal(fixtureUserProfileModal);
+ const organizationProfile = useModal(fixtureOrganizationProfileModal);
+ const combined = profile === 'combined' && prototype.activeOrganization ? 'combined' : 'user';
+
+ const manageAccount = () => {
+ prototype.onManageAccount?.();
+ userProfile.open({ mode: combined });
+ };
+ const manageOrganization = () => {
+ prototype.onManageOrganization?.();
+ if (profile === 'combined') {
+ userProfile.open({ mode: combined, page: 'organization/general' });
+ } else {
+ organizationProfile.open();
+ }
+ };
+ const surfaceProps = (surface: typeof openSurface): Omit => ({
+ ...prototype,
+ open: prototype.open && openSurface === surface,
+ onOpenChange: open => {
+ setOpenSurface(surface);
+ prototype.onOpenChange?.(open);
+ },
+ onManageAccount: manageAccount,
+ onManageOrganization: manageOrganization,
+ });
+
+ return (
+
+ {organizationSwitcher ? (
+
+ ) : null}
+ {userButton ? (
+
+ ) : null}
+
+ );
+}
+
+const fixtureUserProfileModal = defineModalSurface({
+ id: 'permutationUserProfile',
+ variant: 'profile',
+ load: () => Promise.resolve({ default: FixtureUserProfile }),
+});
+
+const fixtureOrganizationProfileModal = defineModalSurface({
+ id: 'permutationOrganizationProfile',
+ variant: 'profile',
+ load: () => Promise.resolve({ default: FixtureOrganizationProfile }),
+});
+
+const surfaces: ModalSurfaces = [fixtureUserProfileModal, fixtureOrganizationProfileModal];
+
+function FixtureUserProfile({ options }: UserProfileModalContentProps) {
+ const { pages } = useUserProfileFixture();
+ const organization = useOrganizationProfileFixture();
+ const [activePage, setActivePage] = useState(options?.page ?? 'account');
+
+ return (
+
+ );
+}
+
+function FixtureOrganizationProfile(_props: OrganizationProfileModalContentProps) {
+ const { activePage, setActivePage, pages } = useOrganizationProfileFixture();
+
+ return (
+
+ );
+}
From f33ffb30f83ffa53337451a9e6f9f74aa33622eb Mon Sep 17 00:00:00 2001
From: Alex Carpenter
Date: Fri, 30 Oct 2026 13:47:18 -0400
Subject: [PATCH 2/9] feat(mosaic): Configure the profile on MosaicProvider
---
.changeset/mosaic-profile-provider.md | 20 ++
.changeset/profile-permutations-swingset.md | 2 -
.../src/app/custom/page.tsx | 14 +-
.../src/app/organization-switcher/page.tsx | 12 +-
.../mosaic/src/__tests__/feature/render.tsx | 3 +
.../organization-switcher.feature.test.tsx | 2 +-
.../organization-switcher.tsx | 12 +-
.../profile-pages/use-mosaic-profile.ts | 58 +++++
.../switcher.profile.feature.test.tsx | 236 ++++++++++++++++++
.../src/features/switcher/switcher.model.tsx | 12 +-
.../src/features/switcher/switcher.pages.tsx | 12 +-
.../mosaic/src/features/switcher/switcher.tsx | 52 +---
.../__tests__/user-button.feature.test.tsx | 58 +++--
.../user-button.new-profiles.feature.test.tsx | 19 +-
.../src/features/user-button/user-button.tsx | 47 ++--
.../__tests__/user-profile.feature.test.tsx | 4 +-
.../user-profile/user-profile.modal.types.ts | 2 +-
.../features/user-profile/user-profile.tsx | 54 ++--
packages/mosaic/src/index.ts | 3 +-
packages/mosaic/src/mosaic-provider.tsx | 20 +-
.../live/user-button/user-button/page.tsx | 2 +-
.../src/stories/profile-permutations.mdx | 55 ++--
.../stories/profile-permutations.stories.tsx | 11 +-
.../src/stories/user-button.stories.tsx | 4 +-
24 files changed, 514 insertions(+), 200 deletions(-)
create mode 100644 .changeset/mosaic-profile-provider.md
delete mode 100644 .changeset/profile-permutations-swingset.md
create mode 100644 packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
create mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx
diff --git a/.changeset/mosaic-profile-provider.md b/.changeset/mosaic-profile-provider.md
new file mode 100644
index 00000000000..3dc61a6068f
--- /dev/null
+++ b/.changeset/mosaic-profile-provider.md
@@ -0,0 +1,20 @@
+---
+'@clerk/mosaic': minor
+---
+
+Configure the profile on `MosaicProvider` with the new `profile` prop, so `UserButton`, `OrganizationSwitcher` and `UserProfile` share one setup. This makes pairings like a user-only `UserButton` beside an `OrganizationSwitcher` with one combined profile possible.
+
+```tsx
+
+
+
+
+```
+
+Breaking changes:
+
+- `UserButton`: `mode` is replaced by `combined`, which only decides what the menu lists. `mode='user'` becomes `combined={false}`, and to keep the account-only profile too, also set `profile={{ combined: false }}` on `MosaicProvider`.
+- `UserButton` and `OrganizationSwitcher`: `userProfileProps` and `organizationProfileProps` move to `MosaicProvider`'s `profile`.
+- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning.
+- With `experimental.newProfiles`, `OrganizationSwitcher` now opens the combined profile by default. Set `profile={{ combined: false }}` for the standalone organization profile.
+- The `UserProfileMode` type is removed.
diff --git a/.changeset/profile-permutations-swingset.md b/.changeset/profile-permutations-swingset.md
deleted file mode 100644
index a845151cc84..00000000000
--- a/.changeset/profile-permutations-swingset.md
+++ /dev/null
@@ -1,2 +0,0 @@
----
----
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 8fc90b59a4a..766d3f60a90 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,13 +4,15 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';
export default function Page() {
return (
-
- custom-page-content
}],
- }}
- />
+ },
+ }}
+ >
+
);
}
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 876c036cf59..67dab4c1f5c 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,12 +4,16 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic';
export default function Page() {
return (
-
+ custom-page-content }],
+ },
+ }}
+ >
custom-page-content }],
- }}
/>
);
diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx
index cbcf75842b2..b0aed1469b3 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 { MosaicProfile } from '../../features/profile-pages/use-mosaic-profile';
import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental';
import type { MosaicLocalization } from '../../localization';
import { MosaicProvider } from '../../mosaic-provider';
@@ -14,6 +15,7 @@ export async function renderWithClerk(
options?: Parameters[0],
localization?: MosaicLocalization,
experimental?: MosaicExperimental,
+ profile?: MosaicProfile,
) {
const clerk = new Clerk(publishableKey());
const navigate = vi.fn((_to: string) => Promise.resolve());
@@ -26,6 +28,7 @@ export async function renderWithClerk(
{element}
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 4c617211702..2e358bb8146 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
@@ -382,7 +382,7 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => {
await page.viewport(1280, 800);
onTestFinished(() => page.viewport(414, 896));
serveFapi(noOffers());
- return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true });
+ return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }, { combined: false });
}
it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
index ace9a4a3f94..2596a7f56a8 100644
--- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
+++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
@@ -8,11 +8,11 @@ import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.mode
import type { SwitcherMenuProps } from '../switcher/switcher.types';
import type { SwitcherTriggerProps } from '../switcher/switcher.view';
-/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */
+/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */
export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
SwitcherTriggerProps &
SwitcherMenuProps &
- Pick;
+ Pick;
/**
* The active organization's avatar, and the menu behind it: switch organization or back to the
@@ -47,14 +47,10 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
* ```
*
* @example
- * `customPages` adds your own pages to the organization profile; `customMenuItems` adds your own rows
- * beside Settings, each one either an `onClick` action or an `href` link.
+ * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an
+ * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`.
* ```tsx
* , content: }],
- * pageOrder: ['general', 'members', 'audit'],
- * }}
* customMenuItems={[{ id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }]}
* />
* ```
diff --git a/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
new file mode 100644
index 00000000000..dac561e6e14
--- /dev/null
+++ b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
@@ -0,0 +1,58 @@
+import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types';
+import { createContext, useContext } from 'react';
+
+import type { OrganizationProfilePageId } from '../switcher/switcher.pages';
+import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types';
+
+export interface MosaicProfile {
+ /**
+ * Whether one profile carries the account's pages and the active organization's. `false` keeps them
+ * in separate profiles. It decides the profile's shape, not what `UserButton`'s menu lists.
+ *
+ * @default true
+ */
+ combined?: boolean;
+ /**
+ * Options for the user profile, whichever component opens or mounts it.
+ */
+ userProfileProps?: ProfileUserProps;
+ /**
+ * Options for the organization profile, or the organization's pages in a combined profile,
+ * whichever component opens or mounts it.
+ */
+ organizationProfileProps?: ProfileOrganizationProps;
+}
+
+/** What a profile takes beyond the profile component's own props. */
+export interface ProfilePages {
+ /**
+ * Provide custom pages and links to be rendered inside the profile.
+ */
+ 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, so it cannot be a link.
+ *
+ * @default undefined
+ */
+ pageOrder?: (PageId | (string & {}))[];
+}
+
+/** Options for the underlying `` component. */
+export interface ProfileUserProps
+ extends
+ ProfilePages,
+ Pick {}
+
+/** Options for the underlying `` component. */
+export interface ProfileOrganizationProps
+ extends ProfilePages, Pick {}
+
+const MosaicProfileContext = createContext({});
+
+export const MosaicProfileProvider = MosaicProfileContext.Provider;
+
+export function useMosaicProfile(): MosaicProfile {
+ return useContext(MosaicProfileContext);
+}
diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx
new file mode 100644
index 00000000000..a3cf78ec7a9
--- /dev/null
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx
@@ -0,0 +1,236 @@
+import { act, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ReactElement } from 'react';
+import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
+import { page } from 'vitest/browser';
+
+import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEmailAddress,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { openMenu } from '../../../__tests__/feature/menu';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { OrganizationSwitcher } from '../../organization-switcher/organization-switcher';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
+import { UserButton } from '../../user-button/user-button';
+import { UserProfile } from '../../user-profile/user-profile';
+
+const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 });
+const memberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] })];
+const alice = fapiUser({
+ id: 'user_1',
+ first_name: 'Alice',
+ last_name: 'Smith',
+ email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })],
+ organization_memberships: memberships,
+});
+
+const inOrganization: FakeFapiSeed = {
+ client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' })]),
+ memberships,
+};
+const inPersonalAccount: FakeFapiSeed = {
+ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]),
+ memberships,
+};
+
+const terms = { label: 'Terms', path: 'terms', content: Terms body
};
+const accountTabs = ['Account', 'Security'];
+const organizationTabs = ['General', 'Members'];
+const allTabs = [...accountTabs, ...organizationTabs];
+const separate: MosaicProfile = { combined: false };
+
+async function renderWithProfile(
+ ui: ReactElement,
+ profile?: MosaicProfile,
+ seed: FakeFapiSeed = inOrganization,
+ experimental: { newProfiles?: boolean } = { newProfiles: true },
+) {
+ serveFapi(seed);
+ return renderWithClerk(ui, undefined, undefined, experimental, profile);
+}
+
+const buttonTrigger = () => screen.getByRole('button', { name: /Open account menu/ });
+const switcherTrigger = () => screen.getByRole('button', { name: /Open organization menu/ });
+const settings = () => screen.getByRole('button', { name: 'Settings' });
+const tabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
+const selectedTab = (name: string) => screen.findByRole('tab', { name, selected: true });
+
+async function clickSettings(trigger: () => HTMLElement) {
+ const user = userEvent.setup();
+ await user.click(trigger());
+ await user.click(settings());
+ return user;
+}
+
+async function chooseSetting(trigger: () => HTMLElement, label: string) {
+ const user = userEvent.setup();
+ await user.click(trigger());
+ await openMenu(user, settings());
+ await user.click(screen.getByRole('menuitem', { name: label }));
+}
+
+beforeEach(async () => {
+ await page.viewport(1280, 800);
+ onTestFinished(() => page.viewport(414, 896));
+});
+
+describe('the profile the switchers open', () => {
+ it.each([
+ { name: 'combined by default', profile: undefined, expected: allTabs },
+ { name: 'combined when asked', profile: { combined: true }, expected: allTabs },
+ { name: 'separate when opted out', profile: separate, expected: accountTabs },
+ ])('a user-only button opens the account pages first: $name', async ({ profile, expected }) => {
+ await renderWithProfile(, profile);
+
+ await clickSettings(buttonTrigger);
+
+ expect(await selectedTab('Account')).toBeInTheDocument();
+ expect(tabs()).toEqual(expected);
+ });
+
+ it('a combined button opens one profile straight from Settings', async () => {
+ await renderWithProfile();
+
+ await clickSettings(buttonTrigger);
+
+ expect(await selectedTab('Account')).toBeInTheDocument();
+ expect(tabs()).toEqual(allTabs);
+ });
+
+ it('a combined button offers two targets over separate profiles', async () => {
+ await renderWithProfile(, separate);
+
+ await chooseSetting(buttonTrigger, 'Manage organization');
+
+ expect(await selectedTab('General')).toBeInTheDocument();
+ expect(tabs()).toEqual(organizationTabs);
+ });
+
+ it('a lone switcher lands on the organization pages of the combined profile', async () => {
+ await renderWithProfile();
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('General')).toBeInTheDocument();
+ expect(tabs()).toEqual(allTabs);
+ });
+
+ it('a lone switcher opens the standalone organization profile when opted out', async () => {
+ await renderWithProfile(, separate);
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('General')).toBeInTheDocument();
+ expect(tabs()).toEqual(organizationTabs);
+ });
+
+ it.each([
+ { name: 'a user-only button', button: },
+ { name: 'a combined button', button: },
+ ])('a switcher beside $name keeps its Settings row', async ({ button }) => {
+ await renderWithProfile(
+ <>
+
+ {button}
+ >,
+ );
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('General')).toBeInTheDocument();
+ expect(tabs()).toEqual(allTabs);
+ });
+
+ it('leaves the organization pages out of the combined profile on a personal account', async () => {
+ await renderWithProfile(, undefined, inPersonalAccount);
+
+ await clickSettings(buttonTrigger);
+
+ expect(await selectedTab('Account')).toBeInTheDocument();
+ expect(tabs()).toEqual(accountTabs);
+ });
+
+ it('routes the one profile with a URL, and opens the other as a modal', async () => {
+ const { navigate } = await renderWithProfile();
+
+ await chooseSetting(buttonTrigger, 'Manage profile');
+ expect(navigate).toHaveBeenCalledWith('/account');
+
+ await chooseSetting(buttonTrigger, 'Manage organization');
+ expect(await selectedTab('General')).toBeInTheDocument();
+ });
+
+ it.each([
+ { name: 'the button', ui: , trigger: buttonTrigger },
+ { name: 'the switcher', ui: , trigger: switcherTrigger },
+ ])('applies the custom pages and order set on the provider when $name opens it', async ({ ui, trigger }) => {
+ await renderWithProfile(ui, {
+ userProfileProps: { customPages: [terms], pageOrder: ['account', 'terms'] },
+ organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }], pageOrder: ['audit'] },
+ });
+
+ await clickSettings(trigger);
+
+ await waitFor(() => expect(tabs()).toEqual(['Account', 'Terms', 'Security', 'Audit', 'General', 'Members']));
+ });
+
+ it("hands the provider's custom pages to Clerk's modals without newProfiles", async () => {
+ const { clerk } = await renderWithProfile(
+ ,
+ { userProfileProps: { customPages: [terms] } },
+ inOrganization,
+ {},
+ );
+ const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
+
+ await clickSettings(buttonTrigger);
+
+ const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? [];
+ expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+
+ const el = document.body.appendChild(document.createElement('div'));
+ onTestFinished(() => el.remove());
+ act(() => {
+ customPages[0]?.mount?.(el);
+ });
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+});
+
+describe('a mounted UserProfile under a provider profile', () => {
+ it.each([
+ { name: 'follows the provider', profile: separate, combined: undefined, expected: accountTabs },
+ { name: 'defaults to combined', profile: undefined, combined: undefined, expected: allTabs },
+ { name: 'wins over the provider when combined', profile: separate, combined: true, expected: allTabs },
+ { name: 'wins over the provider when not', profile: { combined: true }, combined: false, expected: accountTabs },
+ ])('$name', async ({ profile, combined, expected }) => {
+ await renderWithProfile(, profile);
+
+ await waitFor(() => expect(tabs()).toEqual(expected));
+ });
+
+ it("uses the provider's profile props, leaving links out", async () => {
+ await renderWithProfile(, {
+ userProfileProps: {
+ customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }],
+ },
+ organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }] },
+ });
+
+ await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members', 'Audit']));
+ });
+
+ it('lets its own props win over the provider', async () => {
+ await renderWithProfile(, {
+ userProfileProps: { customPages: [terms] },
+ });
+
+ await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Own', 'General', 'Members']));
+ });
+});
diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx
index b12cbd6fb3d..f5b788e39ba 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -28,6 +28,7 @@ import type {
SwitcherData,
SwitcherInvitation,
SwitcherMembership,
+ SwitcherMode,
SwitcherSession,
SwitcherSuggestion,
} from './switcher.types';
@@ -122,9 +123,11 @@ export interface SwitcherModalProps {
newProfiles?: {
userProfile?: Pick<
UserProfileModalOptions,
- 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
+ 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined'
>;
organizationProfile?: Pick;
+ /** Which switchers the opening menu carries. Only a menu with both has one Settings row for two targets. */
+ menuMode?: SwitcherMode;
};
}
@@ -191,7 +194,7 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession {
/**
* @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
+ * 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 {
@@ -241,7 +244,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
openModal: () => {
if (!newProfiles) {
clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl });
- } else if (mosaicUserProfile?.mode === 'combined') {
+ } else if (mosaicUserProfile?.combined) {
userProfile.open(
{ ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' },
getContainer?.(),
@@ -256,7 +259,8 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const managesBothInOneProfile =
newProfiles &&
- mosaicUserProfile?.mode === 'combined' &&
+ modals?.newProfiles?.menuMode === 'combined' &&
+ mosaicUserProfile?.combined &&
opensModal(options?.userProfileUrl, options?.userProfileMode) &&
opensModal(options?.organizationProfileUrl, options?.organizationProfileMode);
diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx
index fe0598ab6f9..996a6314266 100644
--- a/packages/mosaic/src/features/switcher/switcher.pages.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx
@@ -16,7 +16,12 @@ import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
import { applyOrder } from '../../utils/apply-order';
import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout';
-import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types';
+import type {
+ CustomProfileItem,
+ CustomProfileLink,
+ CustomProfilePage,
+ 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
@@ -151,3 +156,8 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption
return { customPages, portals };
}
+
+/** The pages among a profile's custom items. Mosaic's own profiles skip the link entries. */
+export function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined {
+ return items?.filter((item): item is CustomProfilePage => item.href === undefined);
+}
diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx
index e5ea93af61b..9cf9bae065f 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -1,57 +1,19 @@
'use client';
-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 { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types';
+import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
import { useSwitcherController } from './switcher.controller';
import type { SwitcherModelOptions } from './switcher.model';
import { useSwitcherModel } from './switcher.model';
-import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages';
+import { pagesOnly, 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';
-/** What a profile opened from the switcher takes beyond the profile component's own props. */
-export interface SwitcherProfilePages {
- /**
- * Provide custom pages and links to be rendered inside the profile.
- */
- 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, so it cannot be a link.
- *
- * @default undefined
- */
- pageOrder?: (PageId | (string & {}))[];
-}
-
-/** Options for the underlying `` component. */
-export interface SwitcherUserProfileProps
- extends
- SwitcherProfilePages,
- Pick {}
-
-/** Options for the underlying `` component. */
-export interface SwitcherOrganizationProfileProps
- extends SwitcherProfilePages, Pick {}
-
export type SwitcherProps = SwitcherModelOptions &
SwitcherTriggerProps &
SwitcherMenuProps & {
- /**
- * Specify options for the underlying component.
- * e.g.,
- */
- userProfileProps?: SwitcherUserProfileProps;
- /**
- * Specify options for the underlying component.
- * e.g., organizationProfileProps={{appearance: {...}}}
- */
- organizationProfileProps?: SwitcherOrganizationProfileProps;
/**
* Fallback while loading.
*
@@ -69,13 +31,12 @@ export function Switcher(
triggerBadge,
triggerVariant,
mode,
- userProfileProps,
- organizationProfileProps,
customMenuItems,
menuItemOrder,
fallback,
...options
} = props;
+ const { combined = true, userProfileProps, organizationProfileProps } = useMosaicProfile();
// The portals must outlive the popover, so custom pages are bridged here rather than inside it.
const userProfile = useCustomPages({
items: userProfileProps?.customPages,
@@ -112,12 +73,13 @@ export function Switcher(
pageOrder: userProfileProps?.pageOrder,
additionalOAuthScopes: userProfileProps?.additionalOAuthScopes,
apiKeysProps: userProfileProps?.apiKeysProps,
- mode: mode === 'combined' ? 'combined' : 'user',
+ combined,
},
organizationProfile: {
customPages: pagesOnly(organizationProfileProps?.customPages),
pageOrder: organizationProfileProps?.pageOrder,
},
+ menuMode: mode,
},
});
const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder });
@@ -150,7 +112,3 @@ export function Switcher(
>
);
}
-
-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 757efb0102f..ba02dbbc97f 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
@@ -20,6 +20,7 @@ import {
import { openMenu } from '../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { PortalProvider } from '../../../primitives/portal';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
import type { UserButtonProps } from '../user-button';
import { UserButton } from '../user-button';
@@ -67,9 +68,9 @@ function tree(props: UserButtonProps = {}) {
);
}
-function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) {
+function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn(), profile?: MosaicProfile) {
const fapi = serveFapi(seed);
- return renderWithClerk(tree(props)).then(view => ({ ...view, fapi }));
+ return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi }));
}
type User = ReturnType;
@@ -268,7 +269,7 @@ describe('UserButton', () => {
});
it('lists no organizations on an account-only surface', async () => {
- await renderUserButton({ mode: 'user' });
+ await renderUserButton({ combined: false });
const user = await open();
const menu = await openAccounts(user);
@@ -501,7 +502,7 @@ describe('UserButton', () => {
});
it('renders a custom page into the element the opened profile hands back', async () => {
- const { clerk } = await renderUserButton({
+ const { clerk } = await renderUserButton({}, undefined, {
userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
});
const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
@@ -524,7 +525,7 @@ describe('UserButton', () => {
});
it('opens the profile with its pages in the order it was given', async () => {
- const { clerk } = await renderUserButton({
+ const { clerk } = await renderUserButton({}, undefined, {
userProfileProps: {
customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}],
pageOrder: ['account', 'terms'],
@@ -544,10 +545,9 @@ describe('UserButton', () => {
{ 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 { clerk } = await renderUserButton({}, signedIn({ environment: instanceWith(features) }), {
+ userProfileProps: { customPages: [terms], pageOrder: ['terms'] },
+ });
const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
@@ -587,7 +587,7 @@ describe('UserButton', () => {
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([
@@ -599,6 +599,7 @@ describe('UserButton', () => {
]),
memberships,
}),
+ { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
);
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await open();
@@ -904,7 +905,7 @@ describe('UserButton', () => {
describe('in user mode', () => {
it('heads the surface with the account, never the active organization', async () => {
- await renderUserButton({ mode: 'user' });
+ await renderUserButton({ combined: false });
await open();
expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument();
@@ -915,7 +916,7 @@ describe('UserButton', () => {
it('drops the identifier line when it would only repeat the name', async () => {
const nameless = fapiUser({ ...alice, first_name: null, last_name: null });
await renderUserButton(
- { mode: 'user' },
+ { combined: false },
signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless }), bobSession]) }),
);
await open();
@@ -924,7 +925,7 @@ describe('UserButton', () => {
});
it('lists no organizations and offers no way to make one', async () => {
- await renderUserButton({ mode: 'user' });
+ await renderUserButton({ combined: false });
await open();
expect(screen.queryByText('Personal account')).toBeNull();
@@ -933,7 +934,7 @@ describe('UserButton', () => {
});
it('manages the account from its settings row, and signs out at the foot', async () => {
- const { clerk } = await renderUserButton({ mode: 'user' });
+ const { clerk } = await renderUserButton({ combined: false });
const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
@@ -949,7 +950,7 @@ describe('UserButton', () => {
});
it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => {
- await renderUserButton({ mode: 'user' });
+ await renderUserButton({ combined: false });
const user = await open();
const items = within(await openAccounts(user)).getAllByRole('menuitem');
@@ -1081,7 +1082,7 @@ describe('UserButton', () => {
['username', { first_name: null, last_name: null }, 'alice'],
['email address', { first_name: null, last_name: null, username: null }, 'alice@example.com'],
])('names it by its %s', async (_source, overrides, name) => {
- await renderUserButton({ mode: 'user' }, signedInAs(overrides));
+ await renderUserButton({ combined: false }, signedInAs(overrides));
expect(trigger()).toHaveAccessibleName(`Open account menu for ${name}`);
});
@@ -1342,8 +1343,8 @@ describe('UserButton', () => {
describe('the trigger', () => {
it.each([
- [{ mode: 'combined' as const }, 'Acme'],
- [{ mode: 'user' as const }, 'Alice Smith'],
+ [{ combined: true }, 'Acme'],
+ [{ combined: false }, 'Alice Smith'],
])('with %o names %s beside the avatar', async (props, name) => {
await renderUserButton(props);
@@ -1384,7 +1385,7 @@ describe('UserButton', () => {
});
it('keeps the condensed trigger in user mode', async () => {
- await renderUserButton({ mode: 'user', triggerVariant: 'full' });
+ await renderUserButton({ combined: false, triggerVariant: 'full' });
expect(trigger()).toHaveAttribute('data-variant', 'condensed');
});
@@ -1398,19 +1399,22 @@ describe('UserButton', () => {
});
it('names no organization beneath the account in user mode', async () => {
- await renderUserButton({ mode: 'user' });
+ await renderUserButton({ combined: false });
expect(within(trigger()).queryByText('Acme')).toBeNull();
});
it('still names the account in user mode where personal is hidden and none is active', async () => {
- await renderUserButton({ mode: 'user', hidePersonal: true }, signedIn({ client: fapiClient([personalSession]) }));
+ await renderUserButton(
+ { combined: false, hidePersonal: true },
+ signedIn({ client: fapiClient([personalSession]) }),
+ );
expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument();
});
it('renders the avatar alone when the label is off', async () => {
- await renderUserButton({ mode: 'user', showTriggerLabel: false });
+ await renderUserButton({ combined: false, showTriggerLabel: false });
expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith');
expect(within(trigger()).queryByText('Alice Smith')).toBeNull();
@@ -1433,17 +1437,17 @@ describe('UserButton', () => {
describe('UserButton custom trigger badges', () => {
it.each([
- { mode: 'combined' as const, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' },
- { mode: 'user' as const, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' },
+ { combined: true, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' },
+ { combined: false, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' },
{
- mode: 'combined' as const,
+ combined: true,
seed: signedIn({ client: fapiClient([personalSession]) }),
badge: 'user:user_1',
name: 'Alice Smith',
},
- ])('shows a badge for the displayed identity as $badge', async ({ mode, seed, badge, name }) => {
+ ])('shows a badge for the displayed identity as $badge', async ({ combined, seed, badge, name }) => {
await renderUserButton(
- { mode, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) },
+ { combined, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) },
seed,
);
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 16f93c880c7..f386e817faa 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
@@ -17,6 +17,7 @@ import {
import { openMenu } from '../../../__tests__/feature/menu';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { PortalProvider } from '../../../primitives/portal';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
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';
@@ -98,11 +99,15 @@ 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()) {
+ async function renderWithNewProfiles(
+ props: UserButtonProps = {},
+ seed: FakeFapiSeed = signedIn(),
+ profile?: MosaicProfile,
+ ) {
await page.viewport(1280, 800);
onTestFinished(() => page.viewport(414, 896));
serveFapi(seed);
- return renderWithClerk(tree(props), undefined, undefined, newProfiles);
+ return renderWithClerk(tree(props), undefined, undefined, newProfiles, profile);
}
const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent);
@@ -158,7 +163,7 @@ describe('UserButton with experimental.newProfiles', () => {
});
it('renders custom pages in their order, and leaves out links', async () => {
- await renderWithNewProfiles({
+ await renderWithNewProfiles({}, undefined, {
userProfileProps: {
customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }],
pageOrder: ['terms', 'account'],
@@ -174,7 +179,9 @@ describe('UserButton with experimental.newProfiles', () => {
});
it("lists the organization's own pages in order in the combined profile", async () => {
- await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } });
+ await renderWithNewProfiles({}, undefined, {
+ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] },
+ });
const user = await open();
await user.click(screen.getByRole('button', { name: 'Settings' }));
@@ -192,8 +199,8 @@ describe('UserButton with experimental.newProfiles', () => {
expect(navigate).toHaveBeenCalledWith('/org');
});
- it("leaves the organization's pages out in user mode", async () => {
- await renderWithNewProfiles({ mode: 'user' });
+ it("leaves the organization's pages out of a separate profile", async () => {
+ await renderWithNewProfiles({ combined: false }, undefined, { combined: false });
const user = await open();
await user.click(screen.getByRole('button', { name: 'Settings' }));
diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx
index 5f6ab970961..5cf288dca80 100644
--- a/packages/mosaic/src/features/user-button/user-button.tsx
+++ b/packages/mosaic/src/features/user-button/user-button.tsx
@@ -4,35 +4,29 @@ import type { ReactElement } from 'react';
import type { SwitcherProps } from '../switcher/switcher';
import { Switcher } from '../switcher/switcher';
-import type { SwitcherMode } from '../switcher/switcher.types';
import type { SwitcherTriggerVariant } from '../switcher/switcher.view';
-/**
- * Which switchers the surface carries. `combined` is both; `user` is an account switcher that never
- * shows an organization, even when one is active.
- */
-export type UserButtonMode = Exclude;
-
-/** Which switchers the surface carries. */
-export interface UserButtonModeProps {
+/** What the menu lists, and how much its trigger carries. */
+export interface UserButtonMenuProps {
/**
- * Which switchers the popup carries: both, or accounts alone.
+ * Whether the menu lists organizations beside accounts. `false` lists accounts alone, and never shows
+ * an organization, even when one is active. The profile's shape is set by `MosaicProvider`'s `profile`.
*
- * @default 'combined'
+ * @default true
*/
- mode?: UserButtonMode;
+ combined?: boolean;
/**
- * How much the labelled trigger carries in `combined` mode: `full` badges the active organization
- * with the account at a larger size, `condensed` shows the lead's avatar alone. `user` mode is
- * always `condensed`.
+ * How much the labelled trigger carries when `combined`: `full` badges the active organization with
+ * the account at a larger size, `condensed` shows the lead's avatar alone. Otherwise it is always
+ * `condensed`.
*
* @default 'full'
*/
triggerVariant?: SwitcherTriggerVariant;
}
-/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profiles it opens. */
-export type UserButtonProps = SwitcherProps & UserButtonModeProps;
+/** Everything `` takes: profile routing, trigger content, and the app's own menu rows. */
+export type UserButtonProps = SwitcherProps & UserButtonMenuProps;
/**
* The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account,
@@ -50,9 +44,9 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps;
* ```
*
* @example
- * `mode` narrows the menu to the account switcher.
+ * `combined={false}` narrows the menu to the account switcher.
* ```tsx
- *
+ *
* ```
*
* @example
@@ -82,18 +76,10 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps;
* ```
*
* @example
- * `customPages` adds your own pages to either profile this button opens; `customMenuItems` adds your
- * own rows beside Settings, each one either an `onClick` action or an `href` link.
+ * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an
+ * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`.
* ```tsx
* , content: }],
- * pageOrder: ['account', 'usage', 'security'],
- * }}
- * organizationProfileProps={{
- * customPages: [{ path: 'audit', label: 'Audit log', icon: , content: }],
- * pageOrder: ['general', 'members', 'audit'],
- * }}
* customMenuItems={[
* { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' },
* { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() },
@@ -102,8 +88,7 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps;
* />
* ```
*/
-export function UserButton({ mode, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement {
- const combined = mode !== 'user';
+export function UserButton({ combined = true, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement {
return (
{
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
});
- it('lists only the account pages in user mode', async () => {
+ it('lists only the account pages when not combined', async () => {
serveWithOrganization();
- await renderWithClerk();
+ await renderWithClerk();
expect(tabNames()).toEqual(['Account', 'Security']);
});
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
index 73ea32c6e4e..c1c8d1e51b0 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts
@@ -3,7 +3,7 @@ import type { UserProfileProps } from './user-profile';
export interface UserProfileModalOptions extends Pick<
UserProfileProps,
- 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' | 'organizationProfileProps'
+ 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined' | 'organizationProfileProps'
> {
page?: UserProfileProps['defaultPage'];
}
diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx
index d352d11380c..6e138e9b768 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -8,8 +8,8 @@ import { organizationProfilePanels } from '../organization-profile/organization-
import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model';
import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
-import { useUserProfilePages } from '../switcher/switcher.pages';
-import type { SwitcherMode } from '../switcher/switcher.types';
+import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
+import { pagesOnly, useUserProfilePages } from '../switcher/switcher.pages';
import { userProfilePageIds } from './user-profile.layout';
import type { UserProfileOptions } from './user-profile.provider';
import { UserProfileProvider } from './user-profile.provider';
@@ -20,12 +20,6 @@ import { UserProfileProfilePanel } from './user-profile-profile-panel';
import { UserProfileSecurityPanel } from './user-profile-security-panel';
import { useUserProfileUserModel } from './user-profile-user.model';
-/**
- * Which profiles the surface carries. `combined` adds the active organization's pages after the
- * account's; `user` shows the account's alone, even when an organization is active.
- */
-export type UserProfileMode = Exclude;
-
/** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */
export interface UserProfileProps
extends Pick, Pick {
@@ -47,14 +41,14 @@ export interface UserProfileProps
*/
apiKeysProps?: APIKeysPageProps;
/**
- * Which profiles the surface carries: the account's and the active organization's, or the
- * account's alone.
+ * Whether the surface adds the active organization's pages after the account's. `false` shows the
+ * account's alone, even when an organization is active.
*
- * @default 'combined'
+ * @default `MosaicProvider`'s `profile.combined`, which is `true` unless set
*/
- mode?: UserProfileMode;
+ combined?: boolean;
/**
- * Options for the organization's pages in `combined` mode: your own pages and their order, the API
+ * Options for the organization's pages when `combined`: your own pages and their order, the API
* keys page, and where leaving the organization lands. `pageOrder` names the organization's pages
* without the `organization/` prefix, as in `['members', 'general']`.
*/
@@ -89,9 +83,9 @@ export interface UserProfileProps
* ```
*
* @example
- * `mode='user'` leaves the active organization's pages out.
+ * `combined={false}` leaves the active organization's pages out.
* ```tsx
- *
+ *
* ```
*
* @example
@@ -121,9 +115,29 @@ export interface UserProfileProps
* ```
*/
export function UserProfile(props: UserProfileProps) {
+ const { combined, userProfileProps, organizationProfileProps } = useMosaicProfile();
+ const {
+ combined: ownCombined = combined,
+ customPages = pagesOnly(userProfileProps?.customPages),
+ pageOrder = userProfileProps?.pageOrder,
+ additionalOAuthScopes = userProfileProps?.additionalOAuthScopes,
+ apiKeysProps = userProfileProps?.apiKeysProps,
+ organizationProfileProps: ownOrganizationProfileProps = {
+ customPages: pagesOnly(organizationProfileProps?.customPages),
+ pageOrder: organizationProfileProps?.pageOrder,
+ },
+ ...rest
+ } = props;
+
return (
);
@@ -131,7 +145,7 @@ export function UserProfile(props: UserProfileProps) {
export function UserProfileSurface({
presentation,
- mode = 'combined',
+ combined = true,
organizationProfileProps,
customPages,
pageOrder,
@@ -168,13 +182,13 @@ export function UserProfileSurface({
additionalOAuthScopes={additionalOAuthScopes}
mode={presentation}
>
- {mode === 'user' ? (
-
- ) : (
+ {combined ? (
+ ) : (
+
)}
);
diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts
index fb3e2beacba..a64f3111ea2 100644
--- a/packages/mosaic/src/index.ts
+++ b/packages/mosaic/src/index.ts
@@ -8,8 +8,9 @@ export type { OrganizationSwitcherProps } from './features/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 type { UserProfileProps } from './features/user-profile/user-profile';
export { UserProfile } from './features/user-profile/user-profile';
+export type { MosaicProfile } from './features/profile-pages/use-mosaic-profile';
export { MosaicProvider } from './mosaic-provider';
export type { PortalContainer, PortalProviderProps } from './primitives/portal';
export { PortalProvider } from './primitives/portal';
diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx
index 7f90a73d2c0..9bc8b271cfa 100644
--- a/packages/mosaic/src/mosaic-provider.tsx
+++ b/packages/mosaic/src/mosaic-provider.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { ToastProvider } from './components/toast/toast';
import { organizationProfileModal } from './features/organization-profile/organization-profile.modal';
+import type { MosaicProfile } from './features/profile-pages/use-mosaic-profile';
+import { MosaicProfileProvider } from './features/profile-pages/use-mosaic-profile';
import { userProfileModal } from './features/user-profile/user-profile.modal';
import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return';
import type { MosaicExperimental } from './hooks/use-mosaic-experimental';
@@ -26,6 +28,8 @@ export interface MosaicProviderProps {
localization?: MosaicLocalization;
/** Features still being tried out. Their names and behavior can change in any release. */
experimental?: MosaicExperimental;
+ /** The profile `UserButton`, `OrganizationSwitcher` and `UserProfile` share: its shape, and the options it takes. */
+ profile?: MosaicProfile;
}
// Exported Mosaic components annotate a React return type on purpose. The package sets
@@ -37,6 +41,7 @@ export function MosaicProvider({
icons,
localization,
experimental,
+ profile = defaultProfile,
}: MosaicProviderProps): React.ReactElement {
const iconsValue = React.useMemo(() => icons ?? {}, [icons]);
const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]);
@@ -50,12 +55,14 @@ export function MosaicProvider({
-
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+
@@ -64,4 +71,5 @@ export function MosaicProvider({
);
}
+const defaultProfile: MosaicProfile = {};
const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal];
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 37670fef95f..b3321f42f96 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
@@ -30,7 +30,7 @@ export default function UserButtonLivePage() {
) : null}
-
+
diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx
index d75687b7002..9949de22919 100644
--- a/packages/swingset/src/stories/profile-permutations.mdx
+++ b/packages/swingset/src/stories/profile-permutations.mdx
@@ -2,30 +2,37 @@ import * as Stories from './profile-permutations.stories';
# Profile permutations
-Exploration page: every pairing of switchers and profile shape, running on fixtures. Each example shares
-one account and organization across its triggers, so switching in one updates the other and the profile.
-
-Today `mode` on `UserButton` sets both the menu and the profile. `OrganizationSwitcher` always opens the
-standalone organization profile.
-
-The snippets show the proposed API, which is not implemented. `combined` defaults to `true` in two places:
-on `UserButton` it decides what the menu lists, and on `MosaicProvider`'s `profile` it decides what the
-profile shows.
-
-| # | Switchers | Profile | With the modal today | Proposed config |
-| --- | ---------------------------------------------- | -------- | --------------------------------------------- | ---------------------- |
-| 1 | `UserButton` combined | Combined | Yes (the default) | None |
-| 2 | `UserButton` combined | Separate | No | Profile opt-out |
-| 3 | `UserButton` user | Account | Yes | Menu + profile opt-out |
-| 4 | `UserButton` user | Combined | No | Menu opt-out |
-| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Yes | Menu + profile opt-out |
-| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | No | Menu opt-out |
-| 7 | `OrganizationSwitcher` | Org | Yes | Profile opt-out |
-| 8 | `OrganizationSwitcher` | Combined | No | None |
-| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | No: the switcher opens the standalone profile | None |
-
-The `No` rows can be approximated by routing with `userProfileUrl` and `organizationProfileUrl` to pages
-that mount ``.
+Every pairing of switchers and profile shape, running on fixtures. Each example shares one account and
+organization across its triggers, so switching in one updates the other and the profile.
+
+`combined` defaults to `true` in two places: on `UserButton` it decides what the menu lists, and on
+`MosaicProvider`'s `profile` it decides what the profile shows. `UserProfile` follows the provider unless
+it sets `combined` itself. Custom pages and page order go on the provider too:
+
+```tsx
+
+
+
+
+```
+
+| # | Switchers | Profile | Config |
+| --- | ---------------------------------------------- | -------- | ---------------------- |
+| 1 | `UserButton` combined | Combined | None |
+| 2 | `UserButton` combined | Separate | Profile opt-out |
+| 3 | `UserButton` user | Account | Menu + profile opt-out |
+| 4 | `UserButton` user | Combined | Menu opt-out |
+| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Menu + profile opt-out |
+| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | Menu opt-out |
+| 7 | `OrganizationSwitcher` | Org | Profile opt-out |
+| 8 | `OrganizationSwitcher` | Combined | None |
+| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | None |
## 1. Combined button, combined profile
diff --git a/packages/swingset/src/stories/profile-permutations.stories.tsx b/packages/swingset/src/stories/profile-permutations.stories.tsx
index 2a09f1ce129..2e0075e5d31 100644
--- a/packages/swingset/src/stories/profile-permutations.stories.tsx
+++ b/packages/swingset/src/stories/profile-permutations.stories.tsx
@@ -5,7 +5,6 @@ import type {
import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view';
import type { SwitcherViewProps } from '@clerk/mosaic/features/switcher/switcher.view';
import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view';
-import type { UserButtonMode } from '@clerk/mosaic/features/user-button/user-button';
import type {
UserProfileModalContentProps,
UserProfileModalOptions,
@@ -116,7 +115,7 @@ export function CombinedButtonAndSwitcher(_args: Record) {
}
interface PermutationProps {
- userButton?: UserButtonMode;
+ userButton?: 'combined' | 'user';
organizationSwitcher?: boolean;
profile: 'combined' | 'separate';
}
@@ -134,16 +133,16 @@ function Triggers({ userButton, organizationSwitcher = false, profile }: Permuta
const [openSurface, setOpenSurface] = useState<'userButton' | 'organizationSwitcher'>('userButton');
const userProfile = useModal(fixtureUserProfileModal);
const organizationProfile = useModal(fixtureOrganizationProfileModal);
- const combined = profile === 'combined' && prototype.activeOrganization ? 'combined' : 'user';
+ const combined = profile === 'combined' && Boolean(prototype.activeOrganization);
const manageAccount = () => {
prototype.onManageAccount?.();
- userProfile.open({ mode: combined });
+ userProfile.open({ combined });
};
const manageOrganization = () => {
prototype.onManageOrganization?.();
if (profile === 'combined') {
- userProfile.open({ mode: combined, page: 'organization/general' });
+ userProfile.open({ combined, page: 'organization/general' });
} else {
organizationProfile.open();
}
@@ -202,7 +201,7 @@ function FixtureUserProfile({ options }: UserProfileModalContentProps) {
);
diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx
index b6267038785..da4761bf497 100644
--- a/packages/swingset/src/stories/user-button.stories.tsx
+++ b/packages/swingset/src/stories/user-button.stories.tsx
@@ -301,7 +301,7 @@ function FixtureUserProfile({ options }: UserProfileModalContentProps) {
pages={pages}
customPages={options?.customPages}
pageOrder={options?.pageOrder}
- organization={options?.mode === 'user' ? undefined : { pages: organization.pages }}
+ organization={options?.combined === false ? undefined : { pages: organization.pages }}
onPageChange={setActivePage}
/>
);
@@ -364,7 +364,7 @@ function ProfileModalTriggers() {