diff --git a/.changeset/mosaic-profile-provider.md b/.changeset/mosaic-profile-provider.md
new file mode 100644
index 00000000000..2cef3b13977
--- /dev/null
+++ b/.changeset/mosaic-profile-provider.md
@@ -0,0 +1,25 @@
+---
+'@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`.
+- `UserButton` and `OrganizationSwitcher`: `userProfileUrl` and `organizationProfileUrl` move to `MosaicProvider`'s `profile`.
+- `UserButton` and `OrganizationSwitcher`: `userProfileMode` and `organizationProfileMode` are removed. Setting a URL routes there, and leaving it out opens the modal.
+- `UserButton` and `OrganizationSwitcher`: `appearance` is no longer accepted in `userProfileProps` or `organizationProfileProps`.
+- `UserButton` and `OrganizationSwitcher`: link items (`href`) are no longer accepted in `customPages`.
+- `UserButton` and `OrganizationSwitcher`: `afterLeaveOrganizationUrl` moves to `profile.organizationProfileProps`, which also takes `apiKeysProps`.
+- `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. `OrganizationProfile` picks them up the same way.
+- 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/mosaic-switcher-sso-bypass-page-order.md b/.changeset/mosaic-switcher-sso-bypass-page-order.md
new file mode 100644
index 00000000000..a115dfa8f25
--- /dev/null
+++ b/.changeset/mosaic-switcher-sso-bypass-page-order.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Fix the organization profile's page order when `OrganizationSwitcher` or `UserButton` opens the legacy profile for a member who can manage the SSO bypass allowlist. The Security page now stays where `pageOrder` puts it instead of moving to the top.
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/docs/models.md b/packages/mosaic/docs/models.md
index 545dfb49553..2bb142bb915 100644
--- a/packages/mosaic/docs/models.md
+++ b/packages/mosaic/docs/models.md
@@ -72,9 +72,9 @@ drops the fallback instead of holding the space open. Keep the two apart.
pair so it cannot contradict itself:
```ts
- type UserProfileMode =
- | { userProfileUrl: string; userProfileMode?: 'navigation' }
- | { userProfileUrl?: never; userProfileMode?: 'modal' };
+ type CreateOrganizationMode =
+ | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' }
+ | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' };
```
## Rules
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-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
index 9d736de54df..c4d69f58f5f 100644
--- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx
+++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
+import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
import { organizationProfilePageIds } from './organization-profile.layout';
import { useOrganizationProfileModel } from './organization-profile.model';
import type { OrganizationProfileOptions } from './organization-profile.provider';
@@ -79,14 +80,16 @@ export interface OrganizationProfileProps
*
* ```
*/
-export function OrganizationProfile({
- customPages,
- pageOrder,
- defaultPage,
- afterLeaveOrganizationUrl,
- fallback,
- apiKeysProps,
-}: OrganizationProfileProps) {
+export function OrganizationProfile(props: OrganizationProfileProps) {
+ const { organizationProfileProps } = useMosaicProfile();
+ const {
+ customPages = organizationProfileProps?.customPages,
+ pageOrder = organizationProfileProps?.pageOrder,
+ defaultPage,
+ afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl,
+ fallback,
+ apiKeysProps = organizationProfileProps?.apiKeysProps,
+ } = props;
const model = useOrganizationProfileModel();
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
const navigation = useProfilePagesController(
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..f8272ca6270 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
@@ -17,6 +17,7 @@ import {
fapiUser,
} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
import type { OrganizationSwitcherProps } from '../organization-switcher';
import { OrganizationSwitcher } from '../organization-switcher';
@@ -71,9 +72,13 @@ function tree(props: OrganizationSwitcherProps = {}) {
);
}
-function renderSwitcher(props: OrganizationSwitcherProps = {}, seed: FakeFapiSeed = signedIn()) {
+function renderSwitcher(
+ props: OrganizationSwitcherProps = {},
+ 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 }));
}
const host = () => screen.getByTestId('host');
@@ -222,7 +227,9 @@ describe('OrganizationSwitcher', () => {
});
it('tells the organization profile where to land after leaving', async () => {
- const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
+ const { clerk } = await renderSwitcher({}, noOffers(), {
+ organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
+ });
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await openWithList();
@@ -298,6 +305,38 @@ describe('OrganizationSwitcher', () => {
expect(reading('Settings', 'Terms of service', 'Support')).toEqual(['Support', 'Terms of service', 'Settings']);
});
+ describe('the organization profile page order', () => {
+ const termsPage = { label: 'Terms', path: 'terms', icon: , content: Terms body
};
+
+ async function openedPageLabels(permissions: string[]) {
+ const memberships = [fapiMembership(acme, { permissions }), fapiMembership(other)];
+ const user = fapiUser({ ...alice, organization_memberships: memberships });
+ const session = fapiSession({ id: 'sess_1', user, last_active_organization_id: 'org_1' });
+ const { clerk } = await renderSwitcher({}, noOffers({ client: fapiClient([session]), memberships }), {
+ organizationProfileProps: { customPages: [termsPage], pageOrder: ['terms', 'security'] },
+ });
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+ const clicker = await openWithList();
+
+ await clicker.click(screen.getByRole('button', { name: 'Settings' }));
+
+ return openOrganizationProfile.mock.calls[0]?.[0]?.customPages?.map(page => page.label);
+ }
+
+ it('keeps Security where it was asked for when the member can manage the SSO bypass allowlist', async () => {
+ expect(await openedPageLabels(['org:sys_entconns_sso_bypass:manage'])).toEqual([
+ 'Terms',
+ 'security',
+ 'general',
+ 'members',
+ ]);
+ });
+
+ it('leaves Security out when the organization profile will not show it', async () => {
+ expect(await openedPageLabels([])).toEqual(['Terms', 'general', 'members']);
+ });
+ });
+
it('themes its parts through organization-switcher slots', async () => {
await renderSwitcher({}, noOffers());
await openWithList();
@@ -378,11 +417,11 @@ describe('OrganizationSwitcher custom trigger badges', () => {
});
describe('OrganizationSwitcher with experimental.newProfiles', () => {
- async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) {
+ async function renderWithNewProfiles(profile: MosaicProfile = { combined: false }) {
await page.viewport(1280, 800);
onTestFinished(() => page.viewport(414, 896));
serveFapi(noOffers());
- return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true });
+ return renderWithClerk(tree(), undefined, undefined, { newProfiles: true }, profile);
}
it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
@@ -397,8 +436,14 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => {
expect(openOrganizationProfile).not.toHaveBeenCalled();
});
- it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
- const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' });
+ it.each([
+ { name: 'separate', combined: false },
+ { name: 'combined', combined: true },
+ ])('lands on afterLeaveOrganizationUrl after leaving from the opened profile: $name', async ({ combined }) => {
+ const { navigate } = await renderWithNewProfiles({
+ combined,
+ organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
+ });
const user = await openWithList();
await user.click(screen.getByRole('button', { name: 'Settings' }));
diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx
index ace9a4a3f94..0e4ff5630b4 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: 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
@@ -35,11 +35,11 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
* ```
*
* @example
- * Passing a URL routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
- * is where switching organization lands, and takes a `:param` template, a plain path, or a function.
+ * `createOrganizationUrl` routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
+ * is where switching organization lands, and takes a `:param` template, a plain path, or a function. Set
+ * `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to a page of your own.
* ```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/profile-pages.controller.ts b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts
index bfd0d37f898..de00da1ecf5 100644
--- a/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts
+++ b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts
@@ -1,12 +1,20 @@
import { useState } from 'react';
-export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) {
+export function useProfilePagesController(
+ initialPage: string,
+ pageIds: readonly string[] | undefined,
+ openingFallbackPage?: string,
+) {
const [activePage, setActivePage] = useState(initialPage);
+ const [isOpening, setIsOpening] = useState(true);
if (pageIds) {
- const [firstPage] = pageIds;
- if (firstPage !== undefined && !pageIds.includes(activePage)) {
- setActivePage(firstPage);
+ if (isOpening) {
+ setIsOpening(false);
+ }
+ const fallbackPage = (isOpening ? openingFallbackPage : undefined) ?? pageIds[0];
+ if (fallbackPage !== undefined && !pageIds.includes(activePage)) {
+ setActivePage(fallbackPage);
}
}
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..820d90ff8ce
--- /dev/null
+++ b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts
@@ -0,0 +1,76 @@
+import { createContext, useContext } from 'react';
+
+import type { OrganizationProfileProps } from '../organization-profile/organization-profile';
+import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types';
+import type { UserProfileProps } from '../user-profile/user-profile';
+import type { CustomProfilePage, 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;
+ /**
+ * Full URL or path of your own user profile page. Setting it routes there instead of opening the modal.
+ */
+ userProfileUrl?: string;
+ /**
+ * Full URL or path of your own organization profile page. Setting it routes there instead of opening the modal.
+ */
+ organizationProfileUrl?: string;
+}
+
+/** What a profile takes beyond the profile component's own props. */
+export interface ProfilePages {
+ /**
+ * Provide custom pages to be rendered inside the profile.
+ */
+ 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.
+ *
+ * @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);
+}
+
+/** What the user profile modal opens with, whichever path opens it. */
+export function userProfileModalOptions({
+ combined = true,
+ userProfileProps,
+ organizationProfileProps,
+}: MosaicProfile) {
+ return { ...userProfileProps, combined, organizationProfileProps };
+}
diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
index 8db622b685e..4a4f7fb5626 100644
--- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx
@@ -2,7 +2,7 @@ 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 { CustomProfilePage } from '../../user-profile/user-profile.types';
import type { CustomPagesOptions } from '../switcher.pages';
import { useCustomPages } from '../switcher.pages';
@@ -29,18 +29,18 @@ function Harness({ items, order, builtInPages = ['account', 'security'] }: Parti
return {portals}
;
}
-const terms: CustomProfileItem = {
+const terms: CustomProfilePage = {
label: 'Terms',
path: 'terms',
icon: terms icon ,
content: Terms body
,
};
-const docs: CustomProfileItem = {
+const docs: CustomProfilePage = {
label: 'Docs',
path: 'docs',
- href: 'https://clerk.com/docs',
icon: docs icon ,
+ content: Docs body
,
};
beforeEach(() => {
@@ -55,23 +55,21 @@ describe('useCustomPages', () => {
expect(screen.getByTestId('host')).toBeEmptyDOMElement();
});
- it('sends a page as its path and a link as its href', () => {
+ it('sends a page as its path', () => {
render( );
- expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']);
+ expect(emitted?.map(page => page.url)).toEqual(['terms', '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( );
+ // make an item a page.
+ it('sends content callbacks for a page', () => {
+ render( );
- const [page, link] = emitted ?? [];
+ const [page] = 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.
@@ -97,7 +95,7 @@ describe('useCustomPages', () => {
});
it('keeps each page in the element that asked for it', () => {
- const help: CustomProfileItem = { label: 'Help', path: 'help', content: Help body
};
+ const help: CustomProfilePage = { label: 'Help', path: 'help', content: Help body
};
render( );
const first = mountInto(emitted?.[0].mount);
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..37a9cdb2912
--- /dev/null
+++ b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx
@@ -0,0 +1,372 @@
+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 { openDialog } from '../../../__tests__/feature/dialog';
+import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi';
+import {
+ fapiClient,
+ fapiEmailAddress,
+ fapiEnvironment,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
+import { openMenu } from '../../../__tests__/feature/menu';
+import { renderWithClerk } from '../../../__tests__/feature/render';
+import { OrganizationProfile } from '../../organization-profile/organization-profile';
+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 keysMemberships = [fapiMembership(acme, { permissions: ['org:sys_api_keys:read'] })];
+const withOrganizationApiKeys: FakeFapiSeed = {
+ environment: fapiEnvironment({
+ api_keys_settings: { user_api_keys_enabled: false, orgs_api_keys_enabled: true },
+ }),
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_1',
+ user: { ...alice, organization_memberships: keysMemberships },
+ last_active_organization_id: 'org_1',
+ }),
+ ]),
+ memberships: keysMemberships,
+};
+
+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 }));
+}
+
+async function leaveOrganization() {
+ const user = userEvent.setup();
+ 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' }));
+}
+
+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 both profiles with URLs from the provider', async () => {
+ const { navigate } = await renderWithProfile( , {
+ userProfileUrl: '/account',
+ organizationProfileUrl: '/org',
+ });
+
+ await clickSettings(switcherTrigger);
+
+ expect(navigate).toHaveBeenCalledWith('/org');
+ });
+
+ it.each([
+ { name: 'separate', combined: false, expected: ['General'] },
+ { name: 'combined', combined: true, expected: ['Account', 'Security', 'General'] },
+ ])("hides the organization's API keys from the profile a switcher opens: $name", async ({ combined, expected }) => {
+ await renderWithProfile(
+ ,
+ { combined, organizationProfileProps: { apiKeysProps: { hide: true } } },
+ withOrganizationApiKeys,
+ );
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('General')).toBeInTheDocument();
+ expect(tabs()).toEqual(expected);
+ });
+
+ it("hands the provider's organization options to Clerk's modal without newProfiles", async () => {
+ const organizationProfileProps = { afterLeaveOrganizationUrl: '/left', apiKeysProps: { hide: true } };
+ const { clerk } = await renderWithProfile(
+ ,
+ { organizationProfileProps },
+ withOrganizationApiKeys,
+ {},
+ );
+ const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
+
+ await clickSettings(switcherTrigger);
+
+ expect(openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining(organizationProfileProps));
+ });
+
+ it('routes the one profile with a URL, and opens the other as a modal', async () => {
+ const { navigate } = await renderWithProfile( , { userProfileUrl: '/account' });
+
+ await chooseSetting(buttonTrigger, 'Manage profile');
+ expect(navigate).toHaveBeenCalledWith('/account');
+
+ await chooseSetting(buttonTrigger, 'Manage organization');
+ expect(await selectedTab('General')).toBeInTheDocument();
+ });
+
+ it('opens a combined profile on the first organization page in the configured order', async () => {
+ await renderWithProfile( , {
+ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] },
+ });
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('Terms')).toBeInTheDocument();
+ });
+
+ it('skips a page this organization does not have when opening a combined profile', async () => {
+ await renderWithProfile( , {
+ organizationProfileProps: { pageOrder: ['apiKeys', 'members'] },
+ });
+
+ await clickSettings(switcherTrigger);
+
+ expect(await selectedTab('Members')).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 OrganizationProfile under a provider profile', () => {
+ const audit = { ...terms, label: 'Audit', path: 'audit' };
+
+ it("uses the provider's profile props", async () => {
+ await renderWithProfile( , {
+ organizationProfileProps: {
+ customPages: [audit],
+ pageOrder: ['audit'],
+ },
+ });
+
+ await waitFor(() => expect(tabs()).toEqual(['Audit', 'General', 'Members']));
+ });
+
+ it('lets its own props win over the provider', async () => {
+ await renderWithProfile( , {
+ organizationProfileProps: { customPages: [audit] },
+ });
+
+ await waitFor(() => expect(tabs()).toEqual(['General', 'Members', 'Own']));
+ });
+
+ it('lands where the provider says after leaving the organization', async () => {
+ const { navigate } = await renderWithProfile( , {
+ organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
+ });
+ await leaveOrganization();
+
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
+ });
+});
+
+describe('a mounted UserProfile under a provider profile', () => {
+ it('fills in what its own organizationProfileProps leave out from the provider', async () => {
+ await renderWithProfile( , {
+ organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }], pageOrder: ['general'] },
+ });
+
+ await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Members', 'General', 'Audit']));
+ });
+
+ it('lands where the provider says after leaving the organization', async () => {
+ const { navigate } = await renderWithProfile( , {
+ organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
+ });
+ await leaveOrganization();
+
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
+ });
+
+ 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", async () => {
+ await renderWithProfile( , {
+ userProfileProps: { customPages: [terms] },
+ 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..0bdc0f823a5 100644
--- a/packages/mosaic/src/features/switcher/switcher.model.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.model.tsx
@@ -2,11 +2,10 @@ 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,
+ CustomPage,
OrganizationResource,
OrganizationSwitcherProps,
UserButtonProps as ClerkUserButtonProps,
- UserProfileModalProps,
UserResource,
} from '@clerk/shared/types';
import { populateParamFromObject } from '@clerk/shared/url';
@@ -19,15 +18,17 @@ import { useGetPortalContainer } from '../../primitives/portal';
import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages';
import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions';
import { organizationProfileModal } from '../organization-profile/organization-profile.modal';
-import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types';
+import type { MosaicProfile } from '../profile-pages/use-mosaic-profile';
+import { userProfileModalOptions } from '../profile-pages/use-mosaic-profile';
+import { ORGANIZATION_PAGE_PREFIX } from '../user-profile/user-profile.layout';
import { userProfileModal } from '../user-profile/user-profile.modal';
-import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types';
import type {
SwitcherBrandingProps,
SwitcherCallbacks,
SwitcherData,
SwitcherInvitation,
SwitcherMembership,
+ SwitcherMode,
SwitcherSession,
SwitcherSuggestion,
} from './switcher.types';
@@ -55,35 +56,6 @@ export type SwitcherModel =
organizationsEnabled: boolean;
});
-/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */
-type UserProfileMode =
- | {
- /** Full URL or path of your own user profile page. Setting it routes there instead of opening Clerk's modal. */
- userProfileUrl: string;
- /** How the user profile opens: `modal` opens Clerk's, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */
- userProfileMode?: 'navigation';
- }
- | {
- /** Full URL or path of your own user profile page. Setting it routes there instead of opening Clerk's modal. */
- userProfileUrl?: never;
- /** How the user profile opens: `modal` opens Clerk's, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */
- userProfileMode?: 'modal';
- };
-
-type OrganizationProfileMode =
- | {
- /** Full URL or path of your own organization profile page. Setting it routes there instead of opening Clerk's modal. */
- organizationProfileUrl: string;
- /** How the organization profile opens: `modal` opens Clerk's, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */
- organizationProfileMode?: 'navigation';
- }
- | {
- /** Full URL or path of your own organization profile page. Setting it routes there instead of opening Clerk's modal. */
- organizationProfileUrl?: never;
- /** How the organization profile opens: `modal` opens Clerk's, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */
- organizationProfileMode?: 'modal';
- };
-
type CreateOrganizationMode =
| {
/** Full URL or path of your own create-organization page. Setting it routes there instead of opening Clerk's modal. */
@@ -98,34 +70,29 @@ type CreateOrganizationMode =
createOrganizationMode?: 'modal';
};
-export type SwitcherModelOptions = UserProfileMode &
- OrganizationSwitcherModelOptions &
+export type SwitcherModelOptions = OrganizationSwitcherModelOptions &
Pick;
-export type OrganizationSwitcherModelOptions = OrganizationProfileMode &
- CreateOrganizationMode &
+export type OrganizationSwitcherModelOptions = CreateOrganizationMode &
Pick<
OrganizationSwitcherProps,
| 'afterSelectOrganizationUrl'
| 'afterSelectPersonalUrl'
| 'afterCreateOrganizationUrl'
- | 'afterLeaveOrganizationUrl'
| 'skipInvitationScreen'
| 'hidePersonal'
>;
-/** Props forwarded to the profile modals this button opens. */
-export interface SwitcherModalProps {
- 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;
+/** What the profiles this button opens are built from. */
+export interface SwitcherProfileContext {
+ profile: MosaicProfile;
+ /** Custom pages in the form Clerk's modals take, already bridged, since only the wrapper can render the portals behind them. */
+ bridgedCustomPages: {
+ userProfile: CustomPage[] | undefined;
+ organizationProfile: CustomPage[] | undefined;
};
+ /** Which switchers the opening menu carries. Only a menu with both has one Settings row for two targets. */
+ menuMode: SwitcherMode;
}
export function resolveAfterUrl(
@@ -141,10 +108,6 @@ export 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,12 +117,13 @@ function openOrNavigate({
navigate,
}: {
url: string | undefined;
- mode: 'navigation' | 'modal' | undefined;
+ mode?: 'navigation' | 'modal';
openModal: () => void;
buildUrl: () => string;
navigate: (to: string) => unknown;
}): () => void {
- return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl());
+ const opensModal = (mode ?? (url ? 'navigation' : 'modal')) === 'modal';
+ return opensModal ? () => openModal() : () => void navigate(url ?? buildUrl());
}
function displayName(user: UserResource): string {
@@ -189,12 +153,14 @@ 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
- * on them.
+ * @param context - The provider's profile and what the wrapper bridged from it. 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 {
+export function useSwitcherModel(
+ options: SwitcherModelOptions,
+ { profile, bridgedCustomPages, menuMode }: SwitcherProfileContext,
+): SwitcherModel {
const { isLoaded: isUserLoaded, user } = useUser();
const { isLoaded: isSessionLoaded, session } = useSession();
// The active org names the trigger. That is not a request to turn Organizations on.
@@ -206,10 +172,17 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const userProfile = useModal(userProfileModal);
const organizationProfile = useModal(organizationProfileModal);
const { newProfiles } = useMosaicExperimental();
+ const {
+ combined = true,
+ userProfileProps,
+ organizationProfileProps,
+ userProfileUrl,
+ organizationProfileUrl,
+ } = profile;
// 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();
- const signInUrl = () => options?.signInUrl ?? clerk.buildSignInUrl();
+ const signInUrl = () => options.signInUrl ?? clerk.buildSignInUrl();
// Don't fetch orgsLists until we know orgs are enabled.
// This wont delay rendering of the trigger, or even the popup shell, since the "ready" status
// does not depend on this.
@@ -217,37 +190,43 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
enabled: Boolean(environment?.organizationSettings.enabled),
});
- const afterLeaveOrganizationUrl = options?.afterLeaveOrganizationUrl;
- const mosaicUserProfile = modals?.newProfiles?.userProfile;
- const mosaicOrganizationProfile = { ...modals?.newProfiles?.organizationProfile, afterLeaveOrganizationUrl };
+ const mosaicUserProfile = userProfileModalOptions(profile);
const manageAccount = openOrNavigate({
- url: options?.userProfileUrl,
- mode: options?.userProfileMode,
+ url: userProfileUrl,
openModal: () =>
newProfiles
- ? userProfile.open(
- { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile },
- getContainer?.(),
- )
- : clerk.openUserProfile({ getContainer, ...modals?.userProfile }),
+ ? userProfile.open(mosaicUserProfile, getContainer?.())
+ : clerk.openUserProfile({
+ getContainer,
+ customPages: bridgedCustomPages.userProfile,
+ additionalOAuthScopes: userProfileProps?.additionalOAuthScopes,
+ apiKeysProps: userProfileProps?.apiKeysProps,
+ }),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
const manageOrganization = openOrNavigate({
- url: options?.organizationProfileUrl,
- mode: options?.organizationProfileMode,
+ url: organizationProfileUrl,
openModal: () => {
if (!newProfiles) {
- clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl });
- } else if (mosaicUserProfile?.mode === 'combined') {
+ clerk.openOrganizationProfile({
+ getContainer,
+ customPages: bridgedCustomPages.organizationProfile,
+ afterLeaveOrganizationUrl: organizationProfileProps?.afterLeaveOrganizationUrl,
+ apiKeysProps: organizationProfileProps?.apiKeysProps,
+ });
+ } else if (combined) {
userProfile.open(
- { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' },
+ {
+ ...mosaicUserProfile,
+ page: `${ORGANIZATION_PAGE_PREFIX}${organizationProfileProps?.pageOrder?.[0] ?? 'general'}`,
+ },
getContainer?.(),
);
} else {
- organizationProfile.open(mosaicOrganizationProfile, getContainer?.());
+ organizationProfile.open(organizationProfileProps, getContainer?.());
}
},
buildUrl: () => clerk.buildOrganizationProfileUrl(),
@@ -255,19 +234,16 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
});
const managesBothInOneProfile =
- newProfiles &&
- mosaicUserProfile?.mode === 'combined' &&
- opensModal(options?.userProfileUrl, options?.userProfileMode) &&
- opensModal(options?.organizationProfileUrl, options?.organizationProfileMode);
+ newProfiles && menuMode === 'combined' && combined && !userProfileUrl && !organizationProfileUrl;
const createOrganization = openOrNavigate({
- url: options?.createOrganizationUrl,
- mode: options?.createOrganizationMode,
+ url: options.createOrganizationUrl,
+ mode: options.createOrganizationMode,
openModal: () =>
clerk.openCreateOrganization({
getContainer,
- afterCreateOrganizationUrl: options?.afterCreateOrganizationUrl,
- skipInvitationScreen: options?.skipInvitationScreen,
+ afterCreateOrganizationUrl: options.afterCreateOrganizationUrl,
+ skipInvitationScreen: options.skipInvitationScreen,
}),
buildUrl: () => clerk.buildCreateOrganizationUrl(),
navigate: router.navigate,
@@ -331,9 +307,9 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
const afterSelectUrl = async (organizationId: string | null): Promise => {
if (!organizationId) {
- return resolveAfterUrl(options?.afterSelectPersonalUrl, user);
+ return resolveAfterUrl(options.afterSelectPersonalUrl, user);
}
- if (!options?.afterSelectOrganizationUrl) {
+ if (!options.afterSelectOrganizationUrl) {
return undefined;
}
const selected =
@@ -350,7 +326,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
activeOrganization: organization ? toMembership(organization) : null,
// The user resource settles this before the paginated list answers; the count covers a stale resource.
hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0,
- hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false),
+ hidePersonal: forceOrganizationSelection || (options.hidePersonal ?? false),
// Only true before the first page lands, which is the one window where empty and pending look alike.
organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading,
memberships,
@@ -374,7 +350,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
await router.navigate(buildTaskUrl(task, { base: signInUrl() }));
return;
}
- const afterSwitchSessionUrl = options?.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;
+ const afterSwitchSessionUrl = options.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl;
if (!afterSwitchSessionUrl) {
return;
}
diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx
index fe0598ab6f9..7f97b50b537 100644
--- a/packages/mosaic/src/features/switcher/switcher.pages.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx
@@ -6,7 +6,7 @@ import {
disabledUserAPIKeysFeature,
disabledUserBillingFeature,
} from '@clerk/shared/internal/clerk-js/componentGuards';
-import { useClerk } from '@clerk/shared/react';
+import { useClerk, useSession } from '@clerk/shared/react';
import type { CustomPage } from '@clerk/shared/types';
import type { ReactNode } from 'react';
import { useCallback, useState } from 'react';
@@ -14,9 +14,10 @@ import { createPortal } from 'react-dom';
import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
import { applyOrder } from '../../utils/apply-order';
+import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions';
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 { 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
@@ -42,6 +43,9 @@ export function useUserProfilePages(): UserProfilePageId[] {
export function useOrganizationProfilePages(): OrganizationProfilePageId[] {
const clerk = useClerk();
const environment = useMosaicEnvironment();
+ const { session } = useSession();
+ const canManageSSOBypass =
+ session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.ssoBypassManage }) ?? false;
const pages: OrganizationProfilePageId[] = ['general', 'members'];
if (!disabledOrganizationBillingFeature(clerk, environment)) {
@@ -50,15 +54,19 @@ export function useOrganizationProfilePages(): OrganizationProfilePageId[] {
if (!disabledOrganizationAPIKeysFeature(clerk, environment)) {
pages.push('apiKeys');
}
- if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) {
+ if (
+ !disabledSelfServeSSOFeature(clerk, environment) ||
+ !disabledSelfServeDirectorySyncFeature(clerk, environment) ||
+ canManageSSOBypass
+ ) {
pages.push('security');
}
return pages;
}
export interface CustomPagesOptions {
- /** Pages and links of the consumer's own. */
- items: CustomProfileItem[] | undefined;
+ /** Pages of the consumer's own. */
+ items: CustomProfilePage[] | 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. */
@@ -72,8 +80,6 @@ export interface CustomPagesBridge {
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;
@@ -132,21 +138,21 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption
// 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}`);
+ const content = 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,
+ url: item.path,
mountIcon: icon.mount,
unmountIcon: icon.unmount,
- ...(content && { mount: content.mount, unmount: content.unmount }),
+ 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)]),
+ 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 e5ea93af61b..0190e871ee3 100644
--- a/packages/mosaic/src/features/switcher/switcher.tsx
+++ b/packages/mosaic/src/features/switcher/switcher.tsx
@@ -1,10 +1,8 @@
'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';
@@ -13,45 +11,9 @@ 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.
*
@@ -64,18 +26,10 @@ export type SwitcherProps = SwitcherModelOptions &
export function Switcher(
props: SwitcherProps & { mode: SwitcherMode; triggerVariant?: SwitcherTriggerVariant },
): ReactElement | null {
- const {
- showTriggerLabel,
- triggerBadge,
- triggerVariant,
- mode,
- userProfileProps,
- organizationProfileProps,
- customMenuItems,
- menuItemOrder,
- fallback,
- ...options
- } = props;
+ const { showTriggerLabel, triggerBadge, triggerVariant, mode, customMenuItems, menuItemOrder, fallback, ...options } =
+ props;
+ const profile = useMosaicProfile();
+ const { userProfileProps, organizationProfileProps } = profile;
// The portals must outlive the popover, so custom pages are bridged here rather than inside it.
const userProfile = useCustomPages({
items: userProfileProps?.customPages,
@@ -94,31 +48,12 @@ export function Switcher(
>
);
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,
- },
- 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,
- },
+ profile,
+ bridgedCustomPages: {
+ userProfile: userProfile.customPages,
+ organizationProfile: organizationProfile.customPages,
},
+ menuMode: mode,
});
const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder });
@@ -150,7 +85,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..b20f5cc8723 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;
@@ -164,7 +165,7 @@ describe('UserButton', () => {
it('renders nothing while Clerk is still loading', async () => {
serveFapi(signedIn());
const client = holdRequests('get', '/v1/client');
- const rendering = renderWithClerk(tree({ mode }));
+ const rendering = renderWithClerk(tree({ combined: mode === 'combined' }));
await waitFor(() => expect(client.requests).toHaveLength(1));
expect(host()).toBeEmptyDOMElement();
@@ -175,14 +176,17 @@ describe('UserButton', () => {
});
it('renders nothing, not even its fallback, when nobody is signed in', async () => {
- await renderUserButton({ mode, fallback: Loading }, signedIn({ client: fapiClient() }));
+ await renderUserButton(
+ { combined: mode === 'combined', fallback: Loading },
+ signedIn({ client: fapiClient() }),
+ );
expect(host()).toBeEmptyDOMElement();
});
it('leaves organizations out entirely when the instance has them disabled', async () => {
await renderUserButton(
- { mode },
+ { combined: mode === 'combined' },
signedIn({ environment: fapiEnvironment({ organization_settings: { enabled: false } }) }),
);
@@ -268,7 +272,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 +505,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 +528,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 +548,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 +590,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 +602,7 @@ describe('UserButton', () => {
]),
memberships,
}),
+ { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } },
);
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await open();
@@ -676,7 +680,7 @@ describe('UserButton', () => {
});
it('navigates to manage the account when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' });
+ const { clerk, navigate } = await renderUserButton({}, undefined, { userProfileUrl: '/account' });
const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {});
const user = await open();
@@ -687,7 +691,7 @@ describe('UserButton', () => {
});
it('navigates to manage the organization when a URL routes it', async () => {
- const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' });
+ const { clerk, navigate } = await renderUserButton({}, undefined, { organizationProfileUrl: '/org' });
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
const user = await open();
@@ -904,7 +908,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 +919,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 +928,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 +937,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 +953,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 +1085,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 +1346,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 +1388,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 +1402,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 +1440,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..b940498f204 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);
@@ -157,10 +162,10 @@ describe('UserButton with experimental.newProfiles', () => {
expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument();
});
- it('renders custom pages in their order, and leaves out links', async () => {
- await renderWithNewProfiles({
+ it('renders custom pages in their order', async () => {
+ await renderWithNewProfiles({}, undefined, {
userProfileProps: {
- customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }],
+ customPages: [terms],
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' }));
@@ -184,7 +191,7 @@ describe('UserButton with experimental.newProfiles', () => {
});
it('still asks which to manage when the organization profile routes to a page', async () => {
- const { navigate } = await renderWithNewProfiles({ organizationProfileUrl: '/org' });
+ const { navigate } = await renderWithNewProfiles({}, undefined, { organizationProfileUrl: '/org' });
const user = await open();
await openSettings(user, 'Manage organization');
@@ -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..a547c08fa09 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: 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
@@ -62,13 +56,12 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps;
* ```
*
* @example
- * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole
- * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param`
- * template, a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands.
+ * `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` template,
+ * a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands. Set
+ * `userProfileUrl` and `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to
+ * pages of your own.
* ```tsx
*
@@ -82,18 +75,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 +87,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/__tests__/user-profile.modal-return.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
index a296493d559..1c2532650fc 100644
--- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
+++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx
@@ -6,9 +6,17 @@ import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';
import { publishableKey, serveFapi } from '../../../__tests__/feature/fake-fapi';
-import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi';
+import {
+ fapiClient,
+ fapiEnvironment,
+ fapiMembership,
+ fapiOrganization,
+ fapiSession,
+ fapiUser,
+} from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import { MosaicProvider } from '../../../mosaic-provider';
+import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
beforeAll(async () => {
await import('../user-profile.modal-content');
@@ -150,6 +158,51 @@ describe('UserProfile modal return', () => {
expect(window.history.state).not.toHaveProperty('__NA');
});
+ describe("with the provider's profile", () => {
+ const memberships = [fapiMembership(fapiOrganization({ id: 'org_1', name: 'Acme' }))];
+
+ function serveInOrganization() {
+ serveFapi({
+ environment: fapiEnvironment(),
+ client: fapiClient([
+ fapiSession({
+ id: 'sess_1',
+ user: fapiUser({ id: 'user_1', organization_memberships: memberships }),
+ last_active_organization_id: 'org_1',
+ }),
+ ]),
+ memberships,
+ });
+ }
+
+ async function tabsAfterReturning(profile: MosaicProfile) {
+ serveInOrganization();
+ visit('?__clerk_mosaic_modal=user-profile%2Faccount');
+
+ await renderWithClerk(Page
, undefined, undefined, undefined, profile);
+
+ const dialog = await screen.findByRole('dialog');
+ await within(dialog).findByRole('tab', { name: 'Account' });
+ return within(dialog)
+ .getAllByRole('tab')
+ .map(tab => tab.textContent);
+ }
+
+ it('keeps the custom pages', async () => {
+ const tabs = await tabsAfterReturning({
+ userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
+ });
+
+ expect(tabs).toContain('Terms');
+ });
+
+ it('stays account-only when the provider turns combined off', async () => {
+ const tabs = await tabsAfterReturning({ combined: false });
+
+ expect(tabs).not.toContain('General');
+ });
+ });
+
it('ignores an unknown value', async () => {
serve();
visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections');
diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
index 01bb0db3647..d0fd10c14f4 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts
@@ -1,6 +1,7 @@
import React from 'react';
import { useModal } from '../../modals/use-modal';
+import { useMosaicProfile, userProfileModalOptions } from '../profile-pages/use-mosaic-profile';
import { userProfileModal } from './user-profile.modal';
import type { UserProfileOptions } from './user-profile.provider';
@@ -18,6 +19,7 @@ export function withUserProfileModalReturn(url: string, mode: UserProfileOptions
export function UserProfileModalReturn(): null {
const { open } = useModal(userProfileModal);
+ const profile = useMosaicProfile();
React.useEffect(() => {
queueMicrotask(() => {
@@ -27,9 +29,9 @@ export function UserProfileModalReturn(): null {
}
url.searchParams.delete(MODAL_PARAM);
window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url);
- open({ page: 'account' });
+ open({ ...userProfileModalOptions(profile), page: 'account' });
});
- }, [open]);
+ }, [open, profile]);
return null;
}
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..754efaa863e 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.tsx
+++ b/packages/mosaic/src/features/user-profile/user-profile.tsx
@@ -8,9 +8,9 @@ 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 { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
import { useUserProfilePages } from '../switcher/switcher.pages';
-import type { SwitcherMode } from '../switcher/switcher.types';
-import { userProfilePageIds } from './user-profile.layout';
+import { ORGANIZATION_PAGE_PREFIX, userProfilePageIds } from './user-profile.layout';
import type { UserProfileOptions } from './user-profile.provider';
import { UserProfileProvider } from './user-profile.provider';
import type { UserProfileViewProps } from './user-profile.view';
@@ -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,36 @@ export interface UserProfileProps
* ```
*/
export function UserProfile(props: UserProfileProps) {
+ const { combined, userProfileProps, organizationProfileProps: providerOrganization } = useMosaicProfile();
+ const {
+ combined: ownCombined = combined,
+ customPages = userProfileProps?.customPages,
+ pageOrder = userProfileProps?.pageOrder,
+ additionalOAuthScopes = userProfileProps?.additionalOAuthScopes,
+ apiKeysProps = userProfileProps?.apiKeysProps,
+ organizationProfileProps: {
+ customPages: organizationCustomPages = providerOrganization?.customPages,
+ pageOrder: organizationPageOrder = providerOrganization?.pageOrder,
+ apiKeysProps: organizationApiKeysProps = providerOrganization?.apiKeysProps,
+ afterLeaveOrganizationUrl = providerOrganization?.afterLeaveOrganizationUrl,
+ } = {},
+ ...rest
+ } = props;
+
return (
);
@@ -131,7 +152,7 @@ export function UserProfile(props: UserProfileProps) {
export function UserProfileSurface({
presentation,
- mode = 'combined',
+ combined = true,
organizationProfileProps,
customPages,
pageOrder,
@@ -168,13 +189,13 @@ export function UserProfileSurface({
additionalOAuthScopes={additionalOAuthScopes}
mode={presentation}
>
- {mode === 'user' ? (
-
- ) : (
+ {combined ? (
+ ) : (
+
)}
);
@@ -205,9 +226,13 @@ function CombinedUserProfileView({
model.status === 'ready'
? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }
: undefined;
+ const pageIds = model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization });
const navigation = useProfilePagesController(
initialPage,
- model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization }),
+ pageIds,
+ initialPage.startsWith(ORGANIZATION_PAGE_PREFIX)
+ ? pageIds?.find(id => id.startsWith(ORGANIZATION_PAGE_PREFIX))
+ : undefined,
);
return (
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..9e3dc819612 100644
--- a/packages/mosaic/src/features/user-profile/user-profile.types.ts
+++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts
@@ -35,22 +35,7 @@ export interface CustomProfilePage {
label: string;
/** Where the page lives, relative to the profile root. Absolute URLs are rejected. */
path: string;
- href?: never;
icon?: ReactNode;
/** Rendered as the page itself. */
content: ReactNode;
}
-
-/** A row in the profile's navigation that leaves for somewhere else. */
-export interface CustomProfileLink {
- /** Names the row in the profile's navigation. */
- label: string;
- /** Identifies the row, for ordering. */
- path: string;
- /** Where the row goes. */
- href: string;
- icon?: ReactNode;
- content?: never;
-}
-
-export type CustomProfileItem = CustomProfilePage | CustomProfileLink;
diff --git a/packages/mosaic/src/hooks/use-mosaic-experimental.ts b/packages/mosaic/src/hooks/use-mosaic-experimental.ts
index 8acce7161c4..1073abde38d 100644
--- a/packages/mosaic/src/hooks/use-mosaic-experimental.ts
+++ b/packages/mosaic/src/hooks/use-mosaic-experimental.ts
@@ -3,8 +3,7 @@ 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`.
+ * `OrganizationSwitcher`, instead of Clerk's profile modals.
*
* @default false
*/
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..4c8a6748c14 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`, `UserProfile` and `OrganizationProfile` 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/mosaic/src/utils/permissions.ts b/packages/mosaic/src/utils/permissions.ts
index 9836133848f..121b42224dd 100644
--- a/packages/mosaic/src/utils/permissions.ts
+++ b/packages/mosaic/src/utils/permissions.ts
@@ -5,4 +5,5 @@ export const ORGANIZATION_PERMISSIONS = {
membershipsManage: 'org:sys_memberships:manage',
profileManage: 'org:sys_profile:manage',
profileDelete: 'org:sys_profile:delete',
+ ssoBypassManage: 'org:sys_entconns_sso_bypass:manage',
} as const;
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/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx
index 70a536937e0..843650fd9fa 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')),
@@ -52,6 +53,7 @@ const docModules: Record> = {
'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')),
},
misc: {
+ 'mosaic-provider': dynamic(() => import('../stories/mosaic-provider.mdx')),
'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')),
'create-organization': dynamic(() => import('../stories/create-organization.mdx')),
'organization-list': dynamic(() => import('../stories/organization-list.mdx')),
diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts
index 460afde6778..3fcb60718a5 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -162,6 +162,7 @@ import {
} from '../stories/localization.stories';
import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories';
import { meta as menuMeta } from '../stories/menu.stories';
+import { meta as mosaicProviderMeta } from '../stories/mosaic-provider.stories';
import {
Default as OrganizationListDefault,
Empty as OrganizationListEmpty,
@@ -246,6 +247,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 +1114,7 @@ const organizationProfileMembersPanelModule: StoryModule = {
export const registry: StoryModule[] = [
// User Button
userButtonModule,
+ { meta: profilePermutationsMeta },
// User Profile
userProfileModule,
// User Profile · Panels
@@ -1144,6 +1147,7 @@ export const registry: StoryModule[] = [
organizationProfileProfileSectionModule,
organizationProfileDangerSectionModule,
// Misc
+ { meta: mosaicProviderMeta },
organizationSwitcherModule,
createOrganizationModule,
organizationListModule,
diff --git a/packages/swingset/src/stories/mosaic-provider.mdx b/packages/swingset/src/stories/mosaic-provider.mdx
new file mode 100644
index 00000000000..279948f7b4a
--- /dev/null
+++ b/packages/swingset/src/stories/mosaic-provider.mdx
@@ -0,0 +1,87 @@
+# MosaicProvider
+
+`MosaicProvider` holds the settings shared by the Mosaic components below it: reading direction, icons,
+localization, experimental features and the profile. It also renders the toasts and profile modals those
+components open.
+
+## Usage
+
+Wrap the part of your app that uses Mosaic. Every prop is optional.
+
+```tsx
+import { MosaicProvider, UserButton } from '@clerk/mosaic';
+
+
+
+ ;
+```
+
+## Props
+
+| Prop | Type | Default | Description |
+| -------------- | --------------------- | ------- | ----------------------------------------------------------------------------- |
+| `children` | `ReactNode` | — | The components that use these settings. |
+| `direction` | `'ltr' \| 'rtl'` | `'ltr'` | The direction arrow-key navigation follows. Set it to match the page's `dir`. |
+| `icons` | `MosaicIconOverrides` | — | Glyph overrides by name. See [Icon](/components/icon). |
+| `localization` | `MosaicLocalization` | — | Locale and strings. See [Localization](/localization/localization). |
+| `experimental` | `MosaicExperimental` | — | Features still being tried out. Names and behavior can change in any release. |
+| `profile` | `MosaicProfile` | — | The profile shared by the profile components, described below. |
+
+## Experimental
+
+| Option | Type | Default | Description |
+| ------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- |
+| `newProfiles` | `boolean` | `false` | `UserButton` and `OrganizationSwitcher` open Mosaic's own `UserProfile` and `OrganizationProfile` instead of Clerk's modals. |
+
+## Profile
+
+`profile` configures the profile that `UserButton`, `OrganizationSwitcher`, `UserProfile` and
+`OrganizationProfile` share. `UserProfile` and `OrganizationProfile` use it for any prop they don't set
+themselves.
+
+```tsx
+
+
+
+
+```
+
+`combined` on `MosaicProvider` decides what the profile shows, and `combined` on `UserButton` decides what
+its menu lists. [Profile permutations](/user-button/profile-permutations) shows each pairing.
+
+`profile.combined` takes effect only with `experimental.newProfiles`. Without it, `UserButton` and
+`OrganizationSwitcher` open Clerk's modals.
+
+| Option | Type | Default | Description |
+| -------------------------- | -------------------------- | ------- | --------------------------------------------------------------------------------- |
+| `combined` | `boolean` | `true` | Whether one profile carries the account's pages and the active organization's. |
+| `userProfileUrl` | `string` | — | Your own user profile page. Setting it routes there instead of opening the modal. |
+| `organizationProfileUrl` | `string` | — | Your own organization profile page. Setting it routes there instead of the modal. |
+| `userProfileProps` | `ProfileUserProps` | — | Options for the user profile. |
+| `organizationProfileProps` | `ProfileOrganizationProps` | — | Options for the organization profile, or its pages in a combined profile. |
+
+### `userProfileProps`
+
+| Option | Type | Default | Description |
+| ----------------------- | ----------------------- | ------- | ------------------------------------------------------------ |
+| `customPages` | `CustomProfilePage[]` | — | Your own pages inside the profile. |
+| `pageOrder` | `string[]` | — | Navigation order, by built-in page id or custom page `path`. |
+| `additionalOAuthScopes` | `AdditionalOAuthScopes` | — | Extra access to request from each OAuth provider. |
+| `apiKeysProps` | `APIKeysPageProps` | — | Options for the API keys page. |
+
+### `organizationProfileProps`
+
+| Option | Type | Default | Description |
+| --------------------------- | --------------------- | ------- | ------------------------------------------------------------ |
+| `customPages` | `CustomProfilePage[]` | — | Your own pages inside the profile. |
+| `pageOrder` | `string[]` | — | Navigation order, by built-in page id or custom page `path`. |
+| `afterLeaveOrganizationUrl` | `string` | — | Where the user lands after leaving the organization. |
+| `apiKeysProps` | `APIKeysPageProps` | — | Options for the API keys page. |
diff --git a/packages/swingset/src/stories/mosaic-provider.stories.tsx b/packages/swingset/src/stories/mosaic-provider.stories.tsx
new file mode 100644
index 00000000000..57be1422df8
--- /dev/null
+++ b/packages/swingset/src/stories/mosaic-provider.stories.tsx
@@ -0,0 +1,8 @@
+import type { StoryMeta } from '@/lib/types';
+
+export const meta: StoryMeta = {
+ group: 'Misc',
+ title: 'MosaicProvider',
+ status: 'stable',
+ source: 'packages/mosaic/src/mosaic-provider.tsx',
+};
diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx
new file mode 100644
index 00000000000..a94f17882ff
--- /dev/null
+++ b/packages/swingset/src/stories/profile-permutations.mdx
@@ -0,0 +1,161 @@
+import * as Stories from './profile-permutations.stories';
+
+# Profile permutations
+
+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. Profile URLs, custom pages, page order and where leaving an organization lands
+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 |
+
+Setting only one URL routes that target to your page, while the other still opens the modal with all of the
+profile's pages.
+
+## 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..2e0075e5d31
--- /dev/null
+++ b/packages/swingset/src/stories/profile-permutations.stories.tsx
@@ -0,0 +1,220 @@
+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 {
+ 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?: 'combined' | 'user';
+ 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' && Boolean(prototype.activeOrganization);
+
+ const manageAccount = () => {
+ prototype.onManageAccount?.();
+ userProfile.open({ combined });
+ };
+ const manageOrganization = () => {
+ prototype.onManageOrganization?.();
+ if (profile === 'combined') {
+ userProfile.open({ 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 (
+
+ );
+}
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() {
userProfile.open({ mode: 'user', customPages: [integrationsPage] })}
+ onClick={() => userProfile.open({ combined: false, customPages: [integrationsPage] })}
onPointerEnter={userProfile.preload}
>
Open account only