From 43573615e5146577314ed92e645b8aa9918a1921 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 18:49:06 -0600 Subject: [PATCH 01/12] feat(mosaic): export UserProfile --- .changeset/mosaic-user-profile-export.md | 2 ++ packages/mosaic/src/index.ts | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 .changeset/mosaic-user-profile-export.md diff --git a/.changeset/mosaic-user-profile-export.md b/.changeset/mosaic-user-profile-export.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-export.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index b9690e71e22..68e3b41677b 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -4,6 +4,8 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher'; export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; +export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; +export { UserProfile } from './features/user-profile/user-profile'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; From 58bdb9772e8c70bd210c8bd958378f8c7eb71c6e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 18:49:07 -0600 Subject: [PATCH 02/12] feat(mosaic): open the Mosaic UserProfile from UserButton --- .../src/features/switcher/switcher.model.tsx | 23 ++++-- .../mosaic/src/features/switcher/switcher.tsx | 32 ++------ .../__tests__/user-button.feature.test.tsx | 82 +++++++++---------- .../user-profile.modal-content.tsx | 24 ++++++ .../user-profile/user-profile.modal.ts | 8 ++ packages/mosaic/src/mosaic-provider.tsx | 3 +- packages/swingset/src/stories/user-button.mdx | 4 +- 7 files changed, 99 insertions(+), 77 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile.modal.ts diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 413cc0bf287..794e6383ab8 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -6,15 +6,17 @@ import type { OrganizationResource, OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, - UserProfileModalProps, UserResource, } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../hooks/use-mosaic-router'; +import { useModal } from '../../modals/use-modal'; import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; +import { userProfileModal } from '../user-profile/user-profile.modal'; +import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types'; import type { SwitcherBrandingProps, SwitcherCallbacks, @@ -109,7 +111,10 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode & /** Props forwarded to the profile modals this button opens. */ export interface SwitcherModalProps { - userProfile?: Pick; + userProfile?: Pick< + UserProfileModalOptions, + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' + >; organizationProfile?: Pick; } @@ -173,8 +178,8 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. Custom pages arrive already bridged, since only - * the wrapper can render the portals behind them. + * @param modals - Props forwarded to the profile modals. The organization profile's custom pages arrive + * already bridged, since only the wrapper can render the portals behind them. */ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); @@ -185,6 +190,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch }); const clerk = useClerk(); const router = useMosaicRouter(); + const userProfile = useModal(userProfileModal); // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); @@ -199,7 +205,14 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer, ...modals?.userProfile }), + openModal: () => + userProfile.open( + { + ...modals?.userProfile, + organizationProfileProps: { afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl }, + }, + getContainer?.(), + ), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index aac782c9ad1..1c736311605 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -3,22 +3,22 @@ import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; import type { OrganizationProfilePageId } from './switcher.pages'; -import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; +import { useCustomPages, useOrganizationProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; /** What a profile opened from the switcher takes beyond the profile component's own props. */ -export interface SwitcherProfilePages { +export interface SwitcherProfilePages { /** * Provide custom pages and links to be rendered inside the profile. */ - customPages?: CustomProfileItem[]; + customPages?: Item[]; /** * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is @@ -32,8 +32,8 @@ export interface SwitcherProfilePages { /** Options for the underlying `` component. */ export interface SwitcherUserProfileProps extends - SwitcherProfilePages, - Pick {} + SwitcherProfilePages, + Pick {} /** Options for the underlying `` component. */ export interface SwitcherOrganizationProfileProps @@ -77,30 +77,14 @@ export function Switcher( ...options } = props; // The portals must outlive the popover, so custom pages are bridged here rather than inside it. - const userProfile = useCustomPages({ - items: userProfileProps?.customPages, - order: userProfileProps?.pageOrder, - builtInPages: useUserProfilePages(), - }); const organizationProfile = useCustomPages({ items: organizationProfileProps?.customPages, order: organizationProfileProps?.pageOrder, builtInPages: useOrganizationProfilePages(), }); - const portals = ( - <> - {userProfile.portals} - {organizationProfile.portals} - - ); + const portals = <>{organizationProfile.portals}; const model = useSwitcherModel(options, { - userProfile: { - customPages: userProfile.customPages, - additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, - apiKeysProps: userProfileProps?.apiKeysProps, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: userProfileProps?.appearance, - }, + userProfile: { ...userProfileProps, mode: mode === 'user' ? 'user' : 'combined' }, organizationProfile: { customPages: organizationProfile.customPages, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 4ebb5a240dd..51eab70def5 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -2,6 +2,7 @@ import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; import { deferred } from '../../../__tests__/async'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; @@ -111,6 +112,13 @@ const reading = (...names: string[]) => .filter(node => !node.closest('.cl-user-button-header')) .map(node => node.textContent); +async function wideViewport() { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +} + +const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); + const current = () => Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent); @@ -465,28 +473,30 @@ describe('UserButton', () => { describe('handing off', () => { it('opens the UserProfile modal and closes when managing the account', async () => { + await wideViewport(); const { clerk, navigate } = await renderUserButton(); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); const user = await open(); await openSettings(user, 'Manage profile'); - expect(openUserProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(openUserProfile).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); await waitFor(() => expect(popup()).toBeNull()); }); - it('hands the opened profile the portal root the app provides', async () => { + it('opens the profile into the portal root the app provides', async () => { + await wideViewport(); const container = document.body.appendChild(document.createElement('div')); onTestFinished(() => container.remove()); serveFapi(signedIn()); - const { clerk } = await renderWithClerk({tree()}); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + await renderWithClerk({tree()}); const user = await open(); await openSettings(user, 'Manage profile'); - expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container); + expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); }); it('runs a custom menu item and closes', async () => { @@ -500,62 +510,43 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); - it('renders a custom page into the element the opened profile hands back', async () => { - const { clerk } = await renderUserButton({ - userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, - }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + it('renders a custom page inside the opened profile', async () => { + await wideViewport(); + await renderUserButton({ userProfileProps: { customPages: [terms] } }); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); - - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages).toHaveLength(1); - expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + await user.click(await screen.findByRole('tab', { name: 'Terms' })); - const el = document.createElement('div'); - document.body.appendChild(el); - act(() => { - customPages[0]?.mount?.(el); - }); - - expect(within(el).getByText('Terms body')).toBeInTheDocument(); + expect(screen.getByText('Terms body')).toBeVisible(); }); - it('opens the profile with its pages in the order it was given', async () => { - const { clerk } = await renderUserButton({ - userProfileProps: { - customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], - pageOrder: ['account', 'terms'], - }, - }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + it('opens the profile on the first page of the order it was given', async () => { + await wideViewport(); + await renderUserButton({ userProfileProps: { customPages: [terms], pageOrder: ['terms', 'account'] } }); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); }); it.each([ - { features: {}, pages: ['Terms', 'account', 'security'] }, - { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] }, - ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => { - const { clerk } = await renderUserButton( + { features: {}, tabs: ['Terms', 'Account', 'Security', 'General', 'Members'] }, + { features: { apiKeys: true }, tabs: ['Terms', 'Account', 'Security', 'API Keys', 'General', 'Members'] }, + ])('orders the UserProfile pages the instance turns on: $features', async ({ features, tabs }) => { + await wideViewport(); + await renderUserButton( { userProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, signedIn({ environment: instanceWith(features) }), ); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); + await screen.findByRole('tab', { name: 'Terms' }); - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); + expect(profileTabs()).toEqual(tabs); }); it.each([ @@ -932,8 +923,8 @@ describe('UserButton', () => { }); it('manages the account from its settings row, and signs out at the foot', async () => { - const { clerk } = await renderUserButton({ mode: 'user' }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + await wideViewport(); + await renderUserButton({ mode: 'user' }); const user = await open(); expect( @@ -944,7 +935,8 @@ describe('UserButton', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(openUserProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security']); }); it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx new file mode 100644 index 00000000000..f0830a0b37e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx @@ -0,0 +1,24 @@ +import { useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { UserProfile } from './user-profile'; +import type { UserProfileModalContentProps } from './user-profile.modal.types'; + +export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement { + const { isLoaded, user } = useUser(); + const signedOut = isLoaded && !user; + + React.useEffect(() => { + if (signedOut) { + close(); + } + }, [signedOut, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.ts new file mode 100644 index 00000000000..636d8aa504a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { UserProfileModalOptions } from './user-profile.modal.types'; + +export const userProfileModal = defineModalSurface({ + id: 'userProfile', + variant: 'profile', + load: () => import('./user-profile.modal-content'), +}); diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 7eb3f61e87f..686bb3c6f09 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; +import { userProfileModal } from './features/user-profile/user-profile.modal'; import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; @@ -44,4 +45,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos ); } -const modalSurfaces: ModalSurfaces = []; +const modalSurfaces: ModalSurfaces = [userProfileModal]; diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index 1e91a2c0bae..fba984c2386 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -219,8 +219,8 @@ all accounts**. The foot keeps **Sign out**. ## Profile modal Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a -dialog. The exported UserButton still opens the clerk-js profile. This example backs the modal with -fixture data, so it runs without a Clerk session. The profile is combined: the active organization's +dialog. The exported UserButton opens Mosaic's `UserProfile` the same way, against the signed-in +user. This example backs the modal with fixture data, so it runs without a Clerk session. The profile is combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls `open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or `open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a From 30e6511d71098038a8ffb24b03e73adacc1da399 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 18:49:08 -0600 Subject: [PATCH 03/12] test(mosaic): cover UserProfile and the UserButton profile modal in integration tests --- .../src/app/user-profile/page.tsx | 11 +++ integration/tests/mosaic/user-button.test.ts | 21 ++++- integration/tests/mosaic/user-profile.test.ts | 82 +++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx create mode 100644 integration/tests/mosaic/user-profile.test.ts diff --git a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx new file mode 100644 index 00000000000..a23e6aeb5d6 --- /dev/null +++ b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { MosaicProvider, UserProfile } from '@clerk/mosaic'; + +export default function Page() { + return ( + + custom-page-content

}]} /> +
+ ); +} diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index c4d5e6ed310..2f0f51065ef 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User async function openProfileSettings(page: Page) { await popup(page).getByRole('button', { name: 'Settings' }).click(); const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' }); - await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor(); + const profile = page.getByRole('tab', { name: 'Account', exact: true }); + await profileSettings.or(profile).first().waitFor(); if (await profileSettings.isVisible()) { await profileSettings.click(); } @@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await expect(page.getByText('custom-link-target')).toBeVisible(); }); + test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }); + + await trigger(page).click(); + await openProfileSettings(page); + + await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-userProfile-root')).toHaveCount(0); + await expect(popup(page)).toBeHidden(); + }); + test('renders a custom page inside the UserProfile modal', async ({ page, context }) => { - const u = await signIn({ page, context }, '/custom'); + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }, '/custom'); await trigger(page).click(); await openProfileSettings(page); - await u.po.userProfile.waitForUserProfileModal(); - await page.locator('.cl-userProfile-root').getByText('Custom page').click(); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }); diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts new file mode 100644 index 00000000000..3eab59203f2 --- /dev/null +++ b/integration/tests/mosaic/user-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed'); + } + }); + + const path = '/user-profile'; + const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await expect(tab(page, 'Account')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'Account')).toHaveCount(0); + }); + + test('opens on the account page with the signed-in user', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test("lists the active organization's pages after the account's", async ({ page, context }) => { + await signIn({ page, context }); + + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + + await tab(page, 'General').click(); + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); From ff244d2802f4b8dadc0bddaac95247a2e0743a3e Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 19:29:23 -0600 Subject: [PATCH 04/12] feat(mosaic): export OrganizationProfile and open the Mosaic profiles from the switchers --- .../organization-profile.modal-content.tsx | 27 +++ .../organization-profile.modal.ts | 8 + .../organization-switcher.feature.test.tsx | 31 ++- .../__tests__/switcher.pages.test.tsx | 190 ------------------ .../src/features/switcher/switcher.model.tsx | 35 ++-- .../src/features/switcher/switcher.pages.tsx | 128 +----------- .../mosaic/src/features/switcher/switcher.tsx | 63 ++---- .../__tests__/user-button.feature.test.tsx | 76 ++----- .../user-profile/user-profile.types.ts | 14 -- packages/mosaic/src/index.ts | 2 + packages/mosaic/src/mosaic-provider.tsx | 3 +- 11 files changed, 115 insertions(+), 462 deletions(-) create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx create mode 100644 packages/mosaic/src/features/organization-profile/organization-profile.modal.ts delete mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx new file mode 100644 index 00000000000..b7ff553c1d7 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx @@ -0,0 +1,27 @@ +import { useOrganization } from '@clerk/shared/react'; +import React from 'react'; + +import { OrganizationProfile } from './organization-profile'; +import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types'; + +export default function OrganizationProfileModalContent({ + options, + close, +}: OrganizationProfileModalContentProps): React.ReactElement { + const { isLoaded, organization } = useOrganization(); + const inactive = isLoaded && !organization; + + React.useEffect(() => { + if (inactive) { + close(); + } + }, [inactive, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts new file mode 100644 index 00000000000..c9b211967fc --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { OrganizationProfileModalOptions } from './organization-profile.modal.types'; + +export const organizationProfileModal = defineModalSurface({ + id: 'organizationProfile', + variant: 'profile', + load: () => import('./organization-profile.modal-content'), +}); diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 278fd3d049e..aea996da81e 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -1,7 +1,9 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { openDialog } from '../../../__tests__/feature/dialog'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, @@ -45,6 +47,11 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi const bobSession = fapiSession({ id: 'sess_2', user: bob }); const personalSession = fapiSession({ id: 'sess_1', user: alice }); +async function wideViewport() { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +} + function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { return { client: fapiClient([aliceSession, bobSession]), @@ -128,8 +135,8 @@ describe('OrganizationSwitcher', () => { }); it('heads the surface with the active organization, and manages it from the rows beneath', async () => { - const { clerk } = await renderSwitcher({}, noOffers()); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + await wideViewport(); + await renderSwitcher({}, noOffers()); const user = await openWithList(); const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup(); @@ -144,7 +151,8 @@ describe('OrganizationSwitcher', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(openOrganizationProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull(); }); it('falls back to the account, with nothing to manage, where no organization is active', async () => { @@ -215,16 +223,19 @@ describe('OrganizationSwitcher', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other')); }); - it('tells the organization profile where to land after leaving', async () => { - const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { + await wideViewport(); + const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); const user = await openWithList(); await user.click(screen.getByRole('button', { name: 'Settings' })); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); - expect(openOrganizationProfile).toHaveBeenCalledWith( - expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }), - ); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); }); it('tells create-organization where to land and whether to skip inviting members', async () => { diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx deleted file mode 100644 index 8db622b685e..00000000000 --- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import type { CustomPage } from '@clerk/shared/types'; -import { act, render, screen, within } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import type { CustomProfileItem } from '../../user-profile/user-profile.types'; -import type { CustomPagesOptions } from '../switcher.pages'; -import { useCustomPages } from '../switcher.pages'; - -// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to -// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the -// tests exercise the same handshake the real modal performs. -function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { - const el = document.createElement('div'); - document.body.appendChild(el); - act(() => callback?.(el)); - return el; -} - -function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { - act(() => callback?.(el)); - el.remove(); -} - -let emitted: CustomPage[] | undefined; - -function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { - const { customPages, portals } = useCustomPages({ items, order, builtInPages }); - emitted = customPages; - return
{portals}
; -} - -const terms: CustomProfileItem = { - label: 'Terms', - path: 'terms', - icon: terms icon, - content:

Terms body

, -}; - -const docs: CustomProfileItem = { - label: 'Docs', - path: 'docs', - href: 'https://clerk.com/docs', - icon: docs icon, -}; - -beforeEach(() => { - emitted = undefined; -}); - -describe('useCustomPages', () => { - it('sends nothing when there are no custom pages', () => { - render(); - - expect(emitted).toBeUndefined(); - expect(screen.getByTestId('host')).toBeEmptyDOMElement(); - }); - - it('sends a page as its path and a link as its href', () => { - render(); - - expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); - }); - - // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what - // make an item a page. A link carrying them would be routed to instead of followed. - it('sends content callbacks for a page and none for a link', () => { - render(); - - const [page, link] = emitted ?? []; - expect(page.mount).toBeTypeOf('function'); - expect(page.unmount).toBeTypeOf('function'); - expect(link.mount).toBeUndefined(); - expect(link.unmount).toBeUndefined(); - }); - - // Without them clerk-js drops the page as invalid, so `icon` could not be optional. - it('sends the icon callbacks even for an item with no icon', () => { - render(Terms body

}]} />); - - const [page] = emitted ?? []; - expect(page.mountIcon).toBeTypeOf('function'); - expect(page.unmountIcon).toBeTypeOf('function'); - - const el = mountInto(page.mountIcon); - expect(el).toBeEmptyDOMElement(); - }); - - it('renders an icon into its own element, apart from the content', () => { - render(); - - const content = mountInto(emitted?.[0].mount); - const icon = mountInto(emitted?.[0].mountIcon); - - expect(within(icon).getByText('terms icon')).toBeInTheDocument(); - expect(within(content).queryByText('terms icon')).toBeNull(); - }); - - it('keeps each page in the element that asked for it', () => { - const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; - render(); - - const first = mountInto(emitted?.[0].mount); - const second = mountInto(emitted?.[1].mount); - - expect(within(first).getByText('Terms body')).toBeInTheDocument(); - expect(within(second).getByText('Help body')).toBeInTheDocument(); - }); - - it('stops rendering content once clerk-js gives the element back', () => { - render(); - - const el = mountInto(emitted?.[0].mount); - expect(within(el).getByText('Terms body')).toBeInTheDocument(); - - unmountFrom(emitted?.[0].unmount, el); - - expect(screen.queryByText('Terms body')).toBeNull(); - }); - - // The profile is opened once with the callbacks from that render, and never handed a later set. - // They have to keep working against the current content, or a page re-rendered while the profile - // is open goes stale. - it('renders updated content through the callbacks the profile was opened with', () => { - const { rerender } = render(); - const el = mountInto(emitted?.[0].mount); - - rerender(Revised terms

}]} />); - - expect(within(el).getByText('Revised terms')).toBeInTheDocument(); - }); - - describe('order', () => { - it('leaves the built-in pages alone when no order is given', () => { - render(); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); - }); - - // Anything more than the id and clerk-js reads it as a custom page. - it('sends a built-in page as its id alone', () => { - render(); - - expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); - }); - - // Unsent built-ins jump to the front, so one left out of the order would not stay put. - it('sends the pages left out of the order after the ones in it', () => { - render( - , - ); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); - }); - - it('drops an id that belongs to no page', () => { - render( - , - ); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); - }); - - it('sends a page once even when the order names it twice', () => { - render(); - - expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); - }); - - it('renders a reordered page into the element clerk-js hands back', () => { - render( - , - ); - - const el = mountInto(emitted?.[1].mount); - - expect(within(el).getByText('Terms body')).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 794e6383ab8..698b7957a57 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -2,7 +2,6 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; import type { - OrganizationProfileModalProps, OrganizationResource, OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, @@ -15,6 +14,8 @@ import { useMosaicRouter } from '../../hooks/use-mosaic-router'; import { useModal } from '../../modals/use-modal'; import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; +import { organizationProfileModal } from '../organization-profile/organization-profile.modal'; +import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types'; import { userProfileModal } from '../user-profile/user-profile.modal'; import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types'; import type { @@ -115,7 +116,7 @@ export interface SwitcherModalProps { UserProfileModalOptions, 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' >; - organizationProfile?: Pick; + organizationProfile?: Pick; } function resolveAfterUrl( @@ -178,8 +179,8 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. The organization profile's custom pages arrive - * already bridged, since only the wrapper can render the portals behind them. + * @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the + * organization's pages, so managing the organization opens it on them. */ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); @@ -191,6 +192,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const clerk = useClerk(); const router = useMosaicRouter(); const userProfile = useModal(userProfileModal); + const organizationProfile = useModal(organizationProfileModal); // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); @@ -202,17 +204,15 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch enabled: Boolean(environment?.organizationSettings.enabled), }); + const organizationProfileProps = { + ...modals?.organizationProfile, + afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, + }; + const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => - userProfile.open( - { - ...modals?.userProfile, - organizationProfileProps: { afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl }, - }, - getContainer?.(), - ), + openModal: () => userProfile.open({ ...modals?.userProfile, organizationProfileProps }, getContainer?.()), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -221,11 +221,12 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, openModal: () => - clerk.openOrganizationProfile({ - getContainer, - ...modals?.organizationProfile, - afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, - }), + modals?.userProfile?.mode === 'combined' + ? userProfile.open( + { ...modals.userProfile, organizationProfileProps, page: 'organization/general' }, + getContainer?.(), + ) + : organizationProfile.open(organizationProfileProps, getContainer?.()), buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index 301e64b0d0c..4a7b6cc63e8 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -1,24 +1,12 @@ import { - disabledOrganizationAPIKeysFeature, - disabledOrganizationBillingFeature, - disabledSelfServeDirectorySyncFeature, - disabledSelfServeSSOFeature, disabledUserAPIKeysFeature, disabledUserBillingFeature, } from '@clerk/shared/internal/clerk-js/componentGuards'; import { useClerk } from '@clerk/shared/react'; -import type { CustomPage } from '@clerk/shared/types'; -import type { ReactNode } from 'react'; -import { useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; -import { applyOrder } from '../../utils/apply-order'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; -import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; - -/** A built-in OrganizationProfile page, by the id its navigation uses. */ -export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security'; +import type { UserProfilePageId } from '../user-profile/user-profile.types'; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -39,117 +27,3 @@ export function useUserProfilePages(): UserProfilePageId[] { }; return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]); } - -/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */ -export function useOrganizationProfilePages(): OrganizationProfilePageId[] { - const clerk = useClerk(); - const environment = useMosaicEnvironment(); - - const pages: OrganizationProfilePageId[] = ['general', 'members']; - if (!disabledOrganizationBillingFeature(clerk, environment)) { - pages.push('billing'); - } - if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { - pages.push('apiKeys'); - } - if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) { - pages.push('security'); - } - return pages; -} - -export interface CustomPagesOptions { - /** Pages and links of the consumer's own. */ - items: CustomProfileItem[] | undefined; - /** The order the profile's navigation should run in, by id. */ - order: readonly string[] | undefined; - /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ - builtInPages: readonly string[]; -} - -export interface CustomPagesBridge { - /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ - customPages: CustomPage[] | undefined; - /** Render these for as long as the profile can be open, or its pages come up blank. */ - portals: ReactNode[]; -} - -const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; - -function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { - const container = containers.get(id); - return container ? createPortal(node, container, id) : null; -} - -/** - * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. - * - * The profile opens in clerk-js's own React root, which cannot render a node from the host app's - * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the - * page belongs and hands it over, and the host tree portals the content into it from here. The - * portals therefore have to stay mounted in the host tree the whole time the profile is open, which - * is why they come back out rather than being rendered here. - * - * This is the shape of the bridge only for as long as the profile renders outside the host tree. A - * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the - * props a consumer writes. - */ -export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { - const [containers, setContainers] = useState>(new Map()); - - // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep - // working: the portal re-reads its container from state on every render of the host tree. - const bind = useCallback( - (id: string) => ({ - mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), - unmount: () => - setContainers(prev => { - const next = new Map(prev); - next.delete(id); - return next; - }), - }), - [], - ); - - const byId = new Map((items ?? []).map(item => [item.path, item])); - // clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a - // built-in left out of the order has to be sent anyway to keep it behind the pages that were named. - // Without an order there is nothing to hold in place, so only the custom pages go out. - const ids = order?.length ? applyOrder(order, [...builtInPages, ...byId.keys()], id => id) : [...byId.keys()]; - - if (!ids.length) { - return { customPages: undefined, portals: [] }; - } - - const customPages = ids.map(id => { - const item = byId.get(id); - // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and - // it reads as a custom page instead. - if (!item) { - return { label: id }; - } - - // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an - // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put - // through them; without them, leaving `icon` off would silently cost you the page. - const icon = bind(`icon:${id}`); - const content = isLink(item) ? undefined : bind(`content:${id}`); - - return { - label: item.label, - // A page is routed to by its path; a link is followed to wherever it points. - url: isLink(item) ? item.href : item.path, - mountIcon: icon.mount, - unmountIcon: icon.unmount, - ...(content && { mount: content.mount, unmount: content.unmount }), - }; - }); - - const portals = (items ?? []).flatMap(item => [ - portalInto(containers, `icon:${item.path}`, item.icon), - ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), - ]); - - return { customPages, portals }; -} diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index 1c736311605..c1e7c9ec0b2 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -1,28 +1,27 @@ 'use client'; -import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; +import type { UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; +import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; -import type { OrganizationProfilePageId } from './switcher.pages'; -import { useCustomPages, useOrganizationProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; /** What a profile opened from the switcher takes beyond the profile component's own props. */ -export interface SwitcherProfilePages { +export interface SwitcherProfilePages { /** - * Provide custom pages and links to be rendered inside the profile. + * Provide custom pages to be rendered inside the profile. */ - customPages?: Item[]; + customPages?: CustomProfilePage[]; /** * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is - * the page the profile opens on, so it cannot be a link. + * the page the profile opens on. * * @default undefined */ @@ -31,13 +30,10 @@ export interface SwitcherProfilePages` component. */ export interface SwitcherUserProfileProps - extends - SwitcherProfilePages, - Pick {} + extends SwitcherProfilePages, Pick {} /** Options for the underlying `` component. */ -export interface SwitcherOrganizationProfileProps - extends SwitcherProfilePages, Pick {} +export type SwitcherOrganizationProfileProps = SwitcherProfilePages; export type SwitcherProps = SwitcherModelOptions & SwitcherTriggerProps & @@ -49,7 +45,7 @@ export type SwitcherProps = SwitcherModelOptions & userProfileProps?: SwitcherUserProfileProps; /** * Specify options for the underlying component. - * e.g., organizationProfileProps={{appearance: {...}}} + * e.g., organizationProfileProps={{pageOrder: ['members', 'general']}} */ organizationProfileProps?: SwitcherOrganizationProfileProps; /** @@ -76,48 +72,29 @@ export function Switcher( fallback, ...options } = props; - // The portals must outlive the popover, so custom pages are bridged here rather than inside it. - const organizationProfile = useCustomPages({ - items: organizationProfileProps?.customPages, - order: organizationProfileProps?.pageOrder, - builtInPages: useOrganizationProfilePages(), - }); - const portals = <>{organizationProfile.portals}; const model = useSwitcherModel(options, { - userProfile: { ...userProfileProps, mode: mode === 'user' ? 'user' : 'combined' }, - organizationProfile: { - customPages: organizationProfile.customPages, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: organizationProfileProps?.appearance, - }, + userProfile: { ...userProfileProps, mode: mode === 'combined' ? 'combined' : 'user' }, + organizationProfile: organizationProfileProps, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); if (controller.status === 'loading') { - return ( - <> - {fallback} - {portals} - - ); + return <>{fallback}; } // Signed out is an answer, so the placeholder goes too rather than promising a button. if (controller.status === 'hidden') { - return <>{portals}; + return null; } const { status: _status, ...viewController } = controller; return ( - <> - - {portals} - + ); } diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 51eab70def5..2259d4dbba7 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1,4 +1,4 @@ -import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; +import type { UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; @@ -549,58 +549,16 @@ describe('UserButton', () => { expect(profileTabs()).toEqual(tabs); }); - it.each([ - { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] }, - { - features: { billing: true, apiKeys: true }, - organization: {}, - pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'], - }, - { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] }, - { - features: { sso: true }, - organization: { self_serve_sso_enabled: true }, - pages: ['Terms', 'general', 'members', 'security'], - }, - { - features: { sso: true, directorySync: true }, - organization: {}, - pages: ['Terms', 'general', 'members'], - }, - { - features: { sso: true, directorySync: true }, - organization: { self_serve_directory_sync_enabled: true }, - pages: ['Terms', 'general', 'members', 'security'], - }, - ])( - 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization', - async ({ features, organization, pages }) => { - const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization }); - const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })]; - const { clerk } = await renderUserButton( - { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, - signedIn({ - environment: instanceWith(features), - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: { ...alice, organization_memberships: memberships }, - last_active_organization_id: 'org_1', - }), - ]), - memberships, - }), - ); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); - const user = await open(); + it("opens the combined profile on the organization's General page, with its own pages in order", async () => { + await wideViewport(); + await renderUserButton({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + const user = await open(); - await openSettings(user, 'Manage organization'); - await waitFor(() => expect(popup()).toBeNull()); + await openSettings(user, 'Manage organization'); - const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); - }, - ); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); + }); it('opens the InviteMembers modal and closes', async () => { const { clerk, navigate } = await renderUserButton(); @@ -667,25 +625,23 @@ describe('UserButton', () => { }); it('navigates to manage the account when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const { navigate } = await renderUserButton({ userProfileUrl: '/account' }); const user = await open(); await openSettings(user, 'Manage profile'); expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); + expect(screen.queryByRole('tab')).toBeNull(); }); it('navigates to manage the organization when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const { navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); const user = await open(); await openSettings(user, 'Manage organization'); expect(navigate).toHaveBeenCalledWith('/org'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); + expect(screen.queryByRole('tab')).toBeNull(); }); it('sends add-account to the sign-in page', async () => { @@ -964,8 +920,8 @@ describe('UserButton', () => { }); it('heads the surface with the active organization, badged with the account, managing both', async () => { - const { clerk } = await renderUserButton(); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + await wideViewport(); + await renderUserButton(); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); const user = await open(); @@ -987,7 +943,7 @@ describe('UserButton', () => { 'Sign out', ]); await openSettings(user, 'Manage organization'); - expect(openOrganizationProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); }); it('keeps the identifier beneath the organization even when it is all that names the account', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index dcd57d0fa44..77ff769e925 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -40,17 +40,3 @@ export interface CustomProfilePage { /** Rendered as the page itself. */ content: ReactNode; } - -/** A row in the profile's navigation that leaves for somewhere else. */ -export interface CustomProfileLink { - /** Names the row in the profile's navigation. */ - label: string; - /** Identifies the row, for ordering. */ - path: string; - /** Where the row goes. */ - href: string; - icon?: ReactNode; - content?: never; -} - -export type CustomProfileItem = CustomProfilePage | CustomProfileLink; diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index 68e3b41677b..ad03bffa48f 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -4,6 +4,8 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher'; export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; +export type { OrganizationProfileProps } from './features/organization-profile/organization-profile'; +export { OrganizationProfile } from './features/organization-profile/organization-profile'; export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; export { UserProfile } from './features/user-profile/user-profile'; export { MosaicProvider } from './mosaic-provider'; diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 686bb3c6f09..c3e65bde3e7 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; +import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; import { userProfileModal } from './features/user-profile/user-profile.modal'; import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; @@ -45,4 +46,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos ); } -const modalSurfaces: ModalSurfaces = [userProfileModal]; +const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal]; From 59e9b4d78a7cb622b8133e71a9538ec65fdff40a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 19:29:24 -0600 Subject: [PATCH 05/12] test(mosaic): cover OrganizationProfile and the switcher profile modals in integration tests --- .../src/app/organization-profile/page.tsx | 13 +++ .../tests/mosaic/organization-profile.test.ts | 82 +++++++++++++++++++ .../mosaic/organization-switcher.test.ts | 6 +- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx create mode 100644 integration/tests/mosaic/organization-profile.test.ts diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx new file mode 100644 index 00000000000..3efd034696b --- /dev/null +++ b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic'; + +export default function Page() { + return ( + + custom-page-content

}]} + /> +
+ ); +} diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts new file mode 100644 index 00000000000..a7d0cda9388 --- /dev/null +++ b/integration/tests/mosaic/organization-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed'); + } + }); + + const path = '/organization-profile'; + const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + await expect(tab(page, 'General')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'General')).toHaveCount(0); + }); + + test('opens on the general page with the active organization', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('switches to the members page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Members').click(); + + await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); diff --git a/integration/tests/mosaic/organization-switcher.test.ts b/integration/tests/mosaic/organization-switcher.test.ts index 0b091106da6..c19fa9f8e9c 100644 --- a/integration/tests/mosaic/organization-switcher.test.ts +++ b/integration/tests/mosaic/organization-switcher.test.ts @@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })( }); test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); await signIn({ page, context }); await trigger(page).click(); await popup(page).getByRole('button', { name: 'Settings' }).click(); - const profile = page.locator('.cl-organizationProfile-root'); - await profile.getByText('Custom page').click(); + await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }, From fff800948a546b6da9d19bd6aa61ceb1ac7ff2d7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 12:38:00 -0600 Subject: [PATCH 06/12] fix(mosaic): render the profile opened from UserButton in modal mode --- .../__tests__/user-button.feature.test.tsx | 20 +++++++++++++++++++ .../user-profile.modal-content.tsx | 5 +++-- .../features/user-profile/user-profile.tsx | 16 ++++++++++++--- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 2259d4dbba7..4b394a78907 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -21,6 +21,7 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; +import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -486,6 +487,25 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); + it('opens the profile as a modal, so an OAuth connection returns to it', async () => { + await wideViewport(); + await renderUserButton({}, withConnectedAccounts([])); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + await user.click(await screen.findByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + + const body = new URLSearchParams(await request.requests[0]?.text()); + const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); + expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ + componentName: 'UserProfile', + socialProvider: 'github', + }); + request.fail(); + }); + it('opens the profile into the portal root the app provides', async () => { await wideViewport(); const container = document.body.appendChild(document.createElement('div')); diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx index f0830a0b37e..c9d35d442de 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx @@ -1,7 +1,7 @@ import { useUser } from '@clerk/shared/react'; import React from 'react'; -import { UserProfile } from './user-profile'; +import { UserProfileSurface } from './user-profile'; import type { UserProfileModalContentProps } from './user-profile.modal.types'; export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement { @@ -16,8 +16,9 @@ export default function UserProfileModalContent({ options, close }: UserProfileM const { page, ...props } = options ?? {}; return ( - ); diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 99e07acb914..d352d11380c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -120,7 +120,17 @@ export interface UserProfileProps * * ``` */ -export function UserProfile({ +export function UserProfile(props: UserProfileProps) { + return ( + + ); +} + +export function UserProfileSurface({ + presentation, mode = 'combined', organizationProfileProps, customPages, @@ -129,7 +139,7 @@ export function UserProfile({ additionalOAuthScopes, fallback, apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {}, -}: UserProfileProps) { +}: UserProfileProps & { presentation: NonNullable }) { const model = useUserProfileUserModel(); const builtInPages = useUserProfilePages(); @@ -156,7 +166,7 @@ export function UserProfile({ return ( {mode === 'user' ? ( From 4d8889123e7b3e653db04737fc3712ff71c26b96 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 13:11:20 -0600 Subject: [PATCH 07/12] feat(mosaic): open the Mosaic profiles from the switchers only behind experimental.newProfiles --- .../mosaic/src/__tests__/feature/render.tsx | 9 +- .../organization-switcher.feature.test.tsx | 62 ++++-- .../__tests__/switcher.pages.test.tsx | 190 +++++++++++++++++ .../src/features/switcher/switcher.model.tsx | 59 ++++-- .../src/features/switcher/switcher.pages.tsx | 128 +++++++++++- .../mosaic/src/features/switcher/switcher.tsx | 94 +++++++-- .../__tests__/user-button.feature.test.tsx | 178 +++++++++------- .../user-button.new-profiles.feature.test.tsx | 192 ++++++++++++++++++ .../user-profile/user-profile.types.ts | 14 ++ .../src/hooks/use-mosaic-experimental.ts | 20 ++ packages/mosaic/src/mosaic-provider.tsx | 22 +- packages/swingset/src/stories/user-button.mdx | 7 +- 12 files changed, 837 insertions(+), 138 deletions(-) create mode 100644 packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx create mode 100644 packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx create mode 100644 packages/mosaic/src/hooks/use-mosaic-experimental.ts diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index 4ed0152569b..933e4dbffca 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { vi } from 'vitest'; +import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental'; import type { MosaicLocalization } from '../../localization'; import { MosaicProvider } from '../../mosaic-provider'; import { publishableKey } from './fake-fapi'; @@ -12,6 +13,7 @@ export async function renderWithClerk( ui: ReactElement, options?: Parameters[0], localization?: MosaicLocalization, + experimental?: MosaicExperimental, ) { const clerk = new Clerk(publishableKey()); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -21,7 +23,12 @@ export async function renderWithClerk( clerk={clerk} clerkStatus={clerk.status} > - {element} + + {element} + ); let current = ui; diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index aea996da81e..36ba0bc99fa 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -47,11 +47,6 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi const bobSession = fapiSession({ id: 'sess_2', user: bob }); const personalSession = fapiSession({ id: 'sess_1', user: alice }); -async function wideViewport() { - await page.viewport(1280, 800); - onTestFinished(() => page.viewport(414, 896)); -} - function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { return { client: fapiClient([aliceSession, bobSession]), @@ -135,8 +130,8 @@ describe('OrganizationSwitcher', () => { }); it('heads the surface with the active organization, and manages it from the rows beneath', async () => { - await wideViewport(); - await renderSwitcher({}, noOffers()); + const { clerk } = await renderSwitcher({}, noOffers()); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await openWithList(); const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup(); @@ -151,8 +146,7 @@ describe('OrganizationSwitcher', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); - expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull(); + expect(openOrganizationProfile).toHaveBeenCalled(); }); it('falls back to the account, with nothing to manage, where no organization is active', async () => { @@ -223,19 +217,16 @@ describe('OrganizationSwitcher', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other')); }); - it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { - await wideViewport(); - const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); + it('tells the organization profile where to land after leaving', async () => { + const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await openWithList(); await user.click(screen.getByRole('button', { name: 'Settings' })); - const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { - role: 'alertdialog', - }); - await user.type(within(dialog).getByRole('textbox'), 'Acme'); - await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); - await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); + expect(openOrganizationProfile).toHaveBeenCalledWith( + expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }), + ); }); it('tells create-organization where to land and whether to skip inviting members', async () => { @@ -384,3 +375,38 @@ describe('OrganizationSwitcher custom trigger badges', () => { expect(trigger()).toHaveAccessibleName('Open organization menu for Other'); }); }); + +describe('OrganizationSwitcher with experimental.newProfiles', () => { + async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + serveFapi(noOffers()); + return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }); + } + + it('opens the OrganizationProfile, without the account pages, from Settings', async () => { + const { clerk } = await renderWithNewProfiles(); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile'); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull(); + expect(openOrganizationProfile).not.toHaveBeenCalled(); + }); + + it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { + const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' }); + const user = await openWithList(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); + }); +}); diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx new file mode 100644 index 00000000000..8db622b685e --- /dev/null +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx @@ -0,0 +1,190 @@ +import type { CustomPage } from '@clerk/shared/types'; +import { act, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { CustomProfileItem } from '../../user-profile/user-profile.types'; +import type { CustomPagesOptions } from '../switcher.pages'; +import { useCustomPages } from '../switcher.pages'; + +// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to +// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the +// tests exercise the same handshake the real modal performs. +function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => callback?.(el)); + return el; +} + +function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { + act(() => callback?.(el)); + el.remove(); +} + +let emitted: CustomPage[] | undefined; + +function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { + const { customPages, portals } = useCustomPages({ items, order, builtInPages }); + emitted = customPages; + return
{portals}
; +} + +const terms: CustomProfileItem = { + label: 'Terms', + path: 'terms', + icon: terms icon, + content:

Terms body

, +}; + +const docs: CustomProfileItem = { + label: 'Docs', + path: 'docs', + href: 'https://clerk.com/docs', + icon: docs icon, +}; + +beforeEach(() => { + emitted = undefined; +}); + +describe('useCustomPages', () => { + it('sends nothing when there are no custom pages', () => { + render(); + + expect(emitted).toBeUndefined(); + expect(screen.getByTestId('host')).toBeEmptyDOMElement(); + }); + + it('sends a page as its path and a link as its href', () => { + render(); + + expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what + // make an item a page. A link carrying them would be routed to instead of followed. + it('sends content callbacks for a page and none for a link', () => { + render(); + + const [page, link] = emitted ?? []; + expect(page.mount).toBeTypeOf('function'); + expect(page.unmount).toBeTypeOf('function'); + expect(link.mount).toBeUndefined(); + expect(link.unmount).toBeUndefined(); + }); + + // Without them clerk-js drops the page as invalid, so `icon` could not be optional. + it('sends the icon callbacks even for an item with no icon', () => { + render(Terms body

}]} />); + + const [page] = emitted ?? []; + expect(page.mountIcon).toBeTypeOf('function'); + expect(page.unmountIcon).toBeTypeOf('function'); + + const el = mountInto(page.mountIcon); + expect(el).toBeEmptyDOMElement(); + }); + + it('renders an icon into its own element, apart from the content', () => { + render(); + + const content = mountInto(emitted?.[0].mount); + const icon = mountInto(emitted?.[0].mountIcon); + + expect(within(icon).getByText('terms icon')).toBeInTheDocument(); + expect(within(content).queryByText('terms icon')).toBeNull(); + }); + + it('keeps each page in the element that asked for it', () => { + const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; + render(); + + const first = mountInto(emitted?.[0].mount); + const second = mountInto(emitted?.[1].mount); + + expect(within(first).getByText('Terms body')).toBeInTheDocument(); + expect(within(second).getByText('Help body')).toBeInTheDocument(); + }); + + it('stops rendering content once clerk-js gives the element back', () => { + render(); + + const el = mountInto(emitted?.[0].mount); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + + unmountFrom(emitted?.[0].unmount, el); + + expect(screen.queryByText('Terms body')).toBeNull(); + }); + + // The profile is opened once with the callbacks from that render, and never handed a later set. + // They have to keep working against the current content, or a page re-rendered while the profile + // is open goes stale. + it('renders updated content through the callbacks the profile was opened with', () => { + const { rerender } = render(); + const el = mountInto(emitted?.[0].mount); + + rerender(Revised terms

}]} />); + + expect(within(el).getByText('Revised terms')).toBeInTheDocument(); + }); + + describe('order', () => { + it('leaves the built-in pages alone when no order is given', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); + }); + + // Anything more than the id and clerk-js reads it as a custom page. + it('sends a built-in page as its id alone', () => { + render(); + + expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); + }); + + // Unsent built-ins jump to the front, so one left out of the order would not stay put. + it('sends the pages left out of the order after the ones in it', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); + }); + + it('drops an id that belongs to no page', () => { + render( + , + ); + + expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); + }); + + it('sends a page once even when the order names it twice', () => { + render(); + + expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); + }); + + it('renders a reordered page into the element clerk-js hands back', () => { + render( + , + ); + + const el = mountInto(emitted?.[1].mount); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 698b7957a57..cde2769e7f9 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -2,14 +2,17 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; import type { + OrganizationProfileModalProps, OrganizationResource, OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, + UserProfileModalProps, UserResource, } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { useMosaicExperimental } from '../../hooks/use-mosaic-experimental'; import { useMosaicRouter } from '../../hooks/use-mosaic-router'; import { useModal } from '../../modals/use-modal'; import { useGetPortalContainer } from '../../primitives/portal'; @@ -112,11 +115,16 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode & /** Props forwarded to the profile modals this button opens. */ export interface SwitcherModalProps { - userProfile?: Pick< - UserProfileModalOptions, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' - >; - organizationProfile?: Pick; + userProfile?: Pick; + organizationProfile?: Pick; + /** What Mosaic's own profiles take when `experimental.newProfiles` opens them instead. */ + newProfiles?: { + userProfile?: Pick< + UserProfileModalOptions, + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' + >; + organizationProfile?: Pick; + }; } function resolveAfterUrl( @@ -179,8 +187,10 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the - * organization's pages, so managing the organization opens it on them. + * @param modals - Props forwarded to the profile modals. Custom pages for Clerk's arrive already bridged, + * since only the wrapper can render the portals behind them. With `experimental.newProfiles`, a + * `combined` user profile also carries the organization's pages, so managing the organization opens it + * on them. */ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); @@ -193,6 +203,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const router = useMosaicRouter(); const userProfile = useModal(userProfileModal); const organizationProfile = useModal(organizationProfileModal); + const { newProfiles } = useMosaicExperimental(); // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); @@ -204,15 +215,20 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch enabled: Boolean(environment?.organizationSettings.enabled), }); - const organizationProfileProps = { - ...modals?.organizationProfile, - afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, - }; + const afterLeaveOrganizationUrl = options?.afterLeaveOrganizationUrl; + const mosaicUserProfile = modals?.newProfiles?.userProfile; + const mosaicOrganizationProfile = { ...modals?.newProfiles?.organizationProfile, afterLeaveOrganizationUrl }; const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => userProfile.open({ ...modals?.userProfile, organizationProfileProps }, getContainer?.()), + openModal: () => + newProfiles + ? userProfile.open( + { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile }, + getContainer?.(), + ) + : clerk.openUserProfile({ getContainer, ...modals?.userProfile }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -220,13 +236,18 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const manageOrganization = openOrNavigate({ url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, - openModal: () => - modals?.userProfile?.mode === 'combined' - ? userProfile.open( - { ...modals.userProfile, organizationProfileProps, page: 'organization/general' }, - getContainer?.(), - ) - : organizationProfile.open(organizationProfileProps, getContainer?.()), + openModal: () => { + if (!newProfiles) { + clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl }); + } else if (mosaicUserProfile?.mode === 'combined') { + userProfile.open( + { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' }, + getContainer?.(), + ); + } else { + organizationProfile.open(mosaicOrganizationProfile, getContainer?.()); + } + }, buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index 4a7b6cc63e8..301e64b0d0c 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -1,12 +1,24 @@ import { + disabledOrganizationAPIKeysFeature, + disabledOrganizationBillingFeature, + disabledSelfServeDirectorySyncFeature, + disabledSelfServeSSOFeature, disabledUserAPIKeysFeature, disabledUserBillingFeature, } from '@clerk/shared/internal/clerk-js/componentGuards'; import { useClerk } from '@clerk/shared/react'; +import type { CustomPage } from '@clerk/shared/types'; +import type { ReactNode } from 'react'; +import { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { applyOrder } from '../../utils/apply-order'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; -import type { UserProfilePageId } from '../user-profile/user-profile.types'; +import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; + +/** A built-in OrganizationProfile page, by the id its navigation uses. */ +export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security'; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -27,3 +39,117 @@ export function useUserProfilePages(): UserProfilePageId[] { }; return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]); } + +/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */ +export function useOrganizationProfilePages(): OrganizationProfilePageId[] { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + const pages: OrganizationProfilePageId[] = ['general', 'members']; + if (!disabledOrganizationBillingFeature(clerk, environment)) { + pages.push('billing'); + } + if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { + pages.push('apiKeys'); + } + if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) { + pages.push('security'); + } + return pages; +} + +export interface CustomPagesOptions { + /** Pages and links of the consumer's own. */ + items: CustomProfileItem[] | undefined; + /** The order the profile's navigation should run in, by id. */ + order: readonly string[] | undefined; + /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ + builtInPages: readonly string[]; +} + +export interface CustomPagesBridge { + /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ + customPages: CustomPage[] | undefined; + /** Render these for as long as the profile can be open, or its pages come up blank. */ + portals: ReactNode[]; +} + +const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; + +function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { + const container = containers.get(id); + return container ? createPortal(node, container, id) : null; +} + +/** + * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. + * + * The profile opens in clerk-js's own React root, which cannot render a node from the host app's + * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the + * page belongs and hands it over, and the host tree portals the content into it from here. The + * portals therefore have to stay mounted in the host tree the whole time the profile is open, which + * is why they come back out rather than being rendered here. + * + * This is the shape of the bridge only for as long as the profile renders outside the host tree. A + * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the + * props a consumer writes. + */ +export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { + const [containers, setContainers] = useState>(new Map()); + + // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep + // working: the portal re-reads its container from state on every render of the host tree. + const bind = useCallback( + (id: string) => ({ + mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), + unmount: () => + setContainers(prev => { + const next = new Map(prev); + next.delete(id); + return next; + }), + }), + [], + ); + + const byId = new Map((items ?? []).map(item => [item.path, item])); + // clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a + // built-in left out of the order has to be sent anyway to keep it behind the pages that were named. + // Without an order there is nothing to hold in place, so only the custom pages go out. + const ids = order?.length ? applyOrder(order, [...builtInPages, ...byId.keys()], id => id) : [...byId.keys()]; + + if (!ids.length) { + return { customPages: undefined, portals: [] }; + } + + const customPages = ids.map(id => { + const item = byId.get(id); + // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and + // it reads as a custom page instead. + if (!item) { + return { label: id }; + } + + // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an + // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put + // through them; without them, leaving `icon` off would silently cost you the page. + const icon = bind(`icon:${id}`); + const content = isLink(item) ? undefined : bind(`content:${id}`); + + return { + label: item.label, + // A page is routed to by its path; a link is followed to wherever it points. + url: isLink(item) ? item.href : item.path, + mountIcon: icon.mount, + unmountIcon: icon.unmount, + ...(content && { mount: content.mount, unmount: content.unmount }), + }; + }); + + const portals = (items ?? []).flatMap(item => [ + portalInto(containers, `icon:${item.path}`, item.icon), + ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), + ]); + + return { customPages, portals }; +} diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index c1e7c9ec0b2..bd5694e8124 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -1,13 +1,14 @@ 'use client'; -import type { UserProfileProps } from '@clerk/shared/types'; +import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; -import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; +import type { OrganizationProfilePageId } from './switcher.pages'; +import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; @@ -15,13 +16,13 @@ import { SwitcherView } from './switcher.view'; /** What a profile opened from the switcher takes beyond the profile component's own props. */ export interface SwitcherProfilePages { /** - * Provide custom pages to be rendered inside the profile. + * Provide custom pages and links to be rendered inside the profile. */ - customPages?: CustomProfilePage[]; + customPages?: CustomProfileItem[]; /** * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is - * the page the profile opens on. + * the page the profile opens on, so it cannot be a link. * * @default undefined */ @@ -30,10 +31,13 @@ export interface SwitcherProfilePages { /** Options for the underlying `` component. */ export interface SwitcherUserProfileProps - extends SwitcherProfilePages, Pick {} + extends + SwitcherProfilePages, + Pick {} /** Options for the underlying `` component. */ -export type SwitcherOrganizationProfileProps = SwitcherProfilePages; +export interface SwitcherOrganizationProfileProps + extends SwitcherProfilePages, Pick {} export type SwitcherProps = SwitcherModelOptions & SwitcherTriggerProps & @@ -45,7 +49,7 @@ export type SwitcherProps = SwitcherModelOptions & userProfileProps?: SwitcherUserProfileProps; /** * Specify options for the underlying component. - * e.g., organizationProfileProps={{pageOrder: ['members', 'general']}} + * e.g., organizationProfileProps={{appearance: {...}}} */ organizationProfileProps?: SwitcherOrganizationProfileProps; /** @@ -72,29 +76,81 @@ export function Switcher( fallback, ...options } = props; + // The portals must outlive the popover, so custom pages are bridged here rather than inside it. + const userProfile = useCustomPages({ + items: userProfileProps?.customPages, + order: userProfileProps?.pageOrder, + builtInPages: useUserProfilePages(), + }); + const organizationProfile = useCustomPages({ + items: organizationProfileProps?.customPages, + order: organizationProfileProps?.pageOrder, + builtInPages: useOrganizationProfilePages(), + }); + const portals = ( + <> + {userProfile.portals} + {organizationProfile.portals} + + ); const model = useSwitcherModel(options, { - userProfile: { ...userProfileProps, mode: mode === 'combined' ? 'combined' : 'user' }, - organizationProfile: organizationProfileProps, + userProfile: { + customPages: userProfile.customPages, + additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, + apiKeysProps: userProfileProps?.apiKeysProps, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. + appearance: userProfileProps?.appearance, + }, + organizationProfile: { + customPages: organizationProfile.customPages, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. + appearance: organizationProfileProps?.appearance, + }, + newProfiles: { + userProfile: { + customPages: pagesOnly(userProfileProps?.customPages), + pageOrder: userProfileProps?.pageOrder, + additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, + apiKeysProps: userProfileProps?.apiKeysProps, + mode: mode === 'combined' ? 'combined' : 'user', + }, + organizationProfile: { + customPages: pagesOnly(organizationProfileProps?.customPages), + pageOrder: organizationProfileProps?.pageOrder, + }, + }, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); if (controller.status === 'loading') { - return <>{fallback}; + return ( + <> + {fallback} + {portals} + + ); } // Signed out is an answer, so the placeholder goes too rather than promising a button. if (controller.status === 'hidden') { - return null; + return <>{portals}; } const { status: _status, ...viewController } = controller; return ( - + <> + + {portals} + ); } + +function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined { + return items?.filter((item): item is CustomProfilePage => item.href === undefined); +} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 4b394a78907..4ebb5a240dd 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1,8 +1,7 @@ -import type { UserJSON, Web3WalletJSON } from '@clerk/shared/types'; +import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; -import { page } from 'vitest/browser'; import { deferred } from '../../../__tests__/async'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; @@ -21,7 +20,6 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; -import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -113,13 +111,6 @@ const reading = (...names: string[]) => .filter(node => !node.closest('.cl-user-button-header')) .map(node => node.textContent); -async function wideViewport() { - await page.viewport(1280, 800); - onTestFinished(() => page.viewport(414, 896)); -} - -const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); - const current = () => Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent); @@ -474,49 +465,28 @@ describe('UserButton', () => { describe('handing off', () => { it('opens the UserProfile modal and closes when managing the account', async () => { - await wideViewport(); const { clerk, navigate } = await renderUserButton(); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); - expect(openUserProfile).not.toHaveBeenCalled(); + expect(openUserProfile).toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); await waitFor(() => expect(popup()).toBeNull()); }); - it('opens the profile as a modal, so an OAuth connection returns to it', async () => { - await wideViewport(); - await renderUserButton({}, withConnectedAccounts([])); - const request = holdRequests('post', '/v1/me/external_accounts'); - const user = await open(); - - await user.click(screen.getByRole('button', { name: 'Settings' })); - await user.click(await screen.findByRole('button', { name: 'Connect GitHub' })); - await waitFor(() => expect(request.requests).toHaveLength(1)); - - const body = new URLSearchParams(await request.requests[0]?.text()); - const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); - expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ - componentName: 'UserProfile', - socialProvider: 'github', - }); - request.fail(); - }); - - it('opens the profile into the portal root the app provides', async () => { - await wideViewport(); + it('hands the opened profile the portal root the app provides', async () => { const container = document.body.appendChild(document.createElement('div')); onTestFinished(() => container.remove()); serveFapi(signedIn()); - await renderWithClerk({tree()}); + const { clerk } = await renderWithClerk({tree()}); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); + expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container); }); it('runs a custom menu item and closes', async () => { @@ -530,55 +500,116 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); - it('renders a custom page inside the opened profile', async () => { - await wideViewport(); - await renderUserButton({ userProfileProps: { customPages: [terms] } }); + it('renders a custom page into the element the opened profile hands back', async () => { + const { clerk } = await renderUserButton({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - await user.click(await screen.findByRole('tab', { name: 'Terms' })); + await waitFor(() => expect(popup()).toBeNull()); - expect(screen.getByText('Terms body')).toBeVisible(); + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages).toHaveLength(1); + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + const el = document.createElement('div'); + document.body.appendChild(el); + act(() => { + customPages[0]?.mount?.(el); + }); + + expect(within(el).getByText('Terms body')).toBeInTheDocument(); }); - it('opens the profile on the first page of the order it was given', async () => { - await wideViewport(); - await renderUserButton({ userProfileProps: { customPages: [terms], pageOrder: ['terms', 'account'] } }); + it('opens the profile with its pages in the order it was given', async () => { + const { clerk } = await renderUserButton({ + userProfileProps: { + customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], + pageOrder: ['account', 'terms'], + }, + }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); + await waitFor(() => expect(popup()).toBeNull()); - expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); - expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); }); it.each([ - { features: {}, tabs: ['Terms', 'Account', 'Security', 'General', 'Members'] }, - { features: { apiKeys: true }, tabs: ['Terms', 'Account', 'Security', 'API Keys', 'General', 'Members'] }, - ])('orders the UserProfile pages the instance turns on: $features', async ({ features, tabs }) => { - await wideViewport(); - await renderUserButton( + { features: {}, pages: ['Terms', 'account', 'security'] }, + { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] }, + ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => { + const { clerk } = await renderUserButton( { userProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, signedIn({ environment: instanceWith(features) }), ); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - await screen.findByRole('tab', { name: 'Terms' }); + await waitFor(() => expect(popup()).toBeNull()); - expect(profileTabs()).toEqual(tabs); + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); }); - it("opens the combined profile on the organization's General page, with its own pages in order", async () => { - await wideViewport(); - await renderUserButton({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); - const user = await open(); + it.each([ + { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] }, + { + features: { billing: true, apiKeys: true }, + organization: {}, + pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'], + }, + { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] }, + { + features: { sso: true }, + organization: { self_serve_sso_enabled: true }, + pages: ['Terms', 'general', 'members', 'security'], + }, + { + features: { sso: true, directorySync: true }, + organization: {}, + pages: ['Terms', 'general', 'members'], + }, + { + features: { sso: true, directorySync: true }, + organization: { self_serve_directory_sync_enabled: true }, + pages: ['Terms', 'general', 'members', 'security'], + }, + ])( + 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization', + async ({ features, organization, pages }) => { + const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization }); + const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })]; + const { clerk } = await renderUserButton( + { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, + signedIn({ + environment: instanceWith(features), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: { ...alice, organization_memberships: memberships }, + last_active_organization_id: 'org_1', + }), + ]), + memberships, + }), + ); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const user = await open(); - await openSettings(user, 'Manage organization'); + await openSettings(user, 'Manage organization'); + await waitFor(() => expect(popup()).toBeNull()); - expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); - expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); - }); + const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); + }, + ); it('opens the InviteMembers modal and closes', async () => { const { clerk, navigate } = await renderUserButton(); @@ -645,23 +676,25 @@ describe('UserButton', () => { }); it('navigates to manage the account when a URL routes it', async () => { - const { navigate } = await renderUserButton({ userProfileUrl: '/account' }); + const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); expect(navigate).toHaveBeenCalledWith('/account'); - expect(screen.queryByRole('tab')).toBeNull(); + expect(openUserProfile).not.toHaveBeenCalled(); }); it('navigates to manage the organization when a URL routes it', async () => { - const { navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); + const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage organization'); expect(navigate).toHaveBeenCalledWith('/org'); - expect(screen.queryByRole('tab')).toBeNull(); + expect(openOrganizationProfile).not.toHaveBeenCalled(); }); it('sends add-account to the sign-in page', async () => { @@ -899,8 +932,8 @@ describe('UserButton', () => { }); it('manages the account from its settings row, and signs out at the foot', async () => { - await wideViewport(); - await renderUserButton({ mode: 'user' }); + const { clerk } = await renderUserButton({ mode: 'user' }); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); expect( @@ -911,8 +944,7 @@ describe('UserButton', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); - expect(profileTabs()).toEqual(['Account', 'Security']); + expect(openUserProfile).toHaveBeenCalled(); }); it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { @@ -940,8 +972,8 @@ describe('UserButton', () => { }); it('heads the surface with the active organization, badged with the account, managing both', async () => { - await wideViewport(); - await renderUserButton(); + const { clerk } = await renderUserButton(); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); const user = await open(); @@ -963,7 +995,7 @@ describe('UserButton', () => { 'Sign out', ]); await openSettings(user, 'Manage organization'); - expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(openOrganizationProfile).toHaveBeenCalled(); }); it('keeps the identifier beneath the organization even when it is all that names the account', async () => { diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx new file mode 100644 index 00000000000..0bc73ec3945 --- /dev/null +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -0,0 +1,192 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; + +import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiInvitation, + fapiMembership, + fapiOrganization, + fapiSession, + fapiSuggestion, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { openMenu } from '../../../__tests__/feature/menu'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { PortalProvider } from '../../../primitives/portal'; +import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; +import type { UserButtonProps } from '../user-button'; +import { UserButton } from '../user-button'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); +const other = fapiOrganization({ id: 'org_9', name: 'Other' }); +const beta = fapiOrganization({ id: 'org_2', name: 'Beta' }); +const gamma = fapiOrganization({ id: 'org_3', name: 'Gamma' }); + +const aliceMemberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] }), fapiMembership(other)]; + +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + username: 'alice', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: aliceMemberships, +}); + +const bob = fapiUser({ + id: 'user_2', + first_name: 'Bob', + last_name: 'Jones', + email_addresses: [fapiEmailAddress({ id: 'idn_bob', email_address: 'bob@example.com' })], +}); + +const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' }); +const bobSession = fapiSession({ id: 'sess_2', user: bob }); + +function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { + return { + client: fapiClient([aliceSession, bobSession]), + memberships: aliceMemberships, + invitations: [fapiInvitation('inv_1', gamma)], + suggestions: [fapiSuggestion('sug_1', beta)], + ...overrides, + }; +} + +function tree(props: UserButtonProps = {}) { + return ( +
+ +
+ ); +} + +type User = ReturnType; + +const trigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const popup = () => screen.queryByRole('dialog', { name: 'Account' }); + +async function open() { + const user = userEvent.setup(); + await user.click(trigger()); + expect(popup()).toBeInTheDocument(); + return user; +} + +async function openSettings(user: User, label: string) { + await openMenu(user, within(requiredPopup()).getByRole('button', { name: 'Settings' })); + await user.click(screen.getByRole('menuitem', { name: label })); +} + +function requiredPopup() { + const surface = popup(); + if (!surface) { + throw new Error('expected the popover to be open'); + } + return surface; +} + +const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; +describe('UserButton with experimental.newProfiles', () => { + const newProfiles = { newProfiles: true }; + + async function renderWithNewProfiles(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + serveFapi(seed); + return renderWithClerk(tree(props), undefined, undefined, newProfiles); + } + + const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); + + it('opens the combined UserProfile when managing the account, and closes', async () => { + const { clerk, navigate } = await renderWithNewProfiles(); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); + const user = await open(); + + await openSettings(user, 'Manage profile'); + + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'General', 'Members']); + expect(openUserProfile).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + await waitFor(() => expect(popup()).toBeNull()); + }); + + it('opens the profile as a modal, so an OAuth connection returns to it', async () => { + await renderWithNewProfiles({}, withConnectedAccounts([])); + const request = holdRequests('post', '/v1/me/external_accounts'); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + await user.click(await screen.findByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + + const body = new URLSearchParams(await request.requests[0]?.text()); + const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); + expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ + componentName: 'UserProfile', + socialProvider: 'github', + }); + request.fail(); + }); + + it('opens the profile into the portal root the app provides', async () => { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); + const container = document.body.appendChild(document.createElement('div')); + onTestFinished(() => container.remove()); + serveFapi(signedIn()); + await renderWithClerk( + {tree()}, + undefined, + undefined, + newProfiles, + ); + const user = await open(); + + await openSettings(user, 'Manage profile'); + + expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); + }); + + it('renders custom pages in their order, and leaves out links', async () => { + await renderWithNewProfiles({ + userProfileProps: { + customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }], + pageOrder: ['terms', 'account'], + }, + }); + const user = await open(); + + await openSettings(user, 'Manage profile'); + + expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('Terms body')).toBeVisible(); + expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); + }); + + it("opens the combined profile on the organization's General page, with its own pages in order", async () => { + await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + const user = await open(); + + await openSettings(user, 'Manage organization'); + + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); + }); + + it("leaves the organization's pages out in user mode", async () => { + await renderWithNewProfiles({ mode: 'user' }); + const user = await open(); + + await user.click(screen.getByRole('button', { name: 'Settings' })); + + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index 77ff769e925..dcd57d0fa44 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -40,3 +40,17 @@ export interface CustomProfilePage { /** Rendered as the page itself. */ content: ReactNode; } + +/** A row in the profile's navigation that leaves for somewhere else. */ +export interface CustomProfileLink { + /** Names the row in the profile's navigation. */ + label: string; + /** Identifies the row, for ordering. */ + path: string; + /** Where the row goes. */ + href: string; + icon?: ReactNode; + content?: never; +} + +export type CustomProfileItem = CustomProfilePage | CustomProfileLink; diff --git a/packages/mosaic/src/hooks/use-mosaic-experimental.ts b/packages/mosaic/src/hooks/use-mosaic-experimental.ts new file mode 100644 index 00000000000..8acce7161c4 --- /dev/null +++ b/packages/mosaic/src/hooks/use-mosaic-experimental.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +export interface MosaicExperimental { + /** + * Opens Mosaic's own `UserProfile` and `OrganizationProfile` from `UserButton` and + * `OrganizationSwitcher`, instead of Clerk's profile modals. They skip `appearance` and the link + * entries of `customPages`. + * + * @default false + */ + newProfiles?: boolean; +} + +const MosaicExperimentalContext = createContext({}); + +export const MosaicExperimentalProvider = MosaicExperimentalContext.Provider; + +export function useMosaicExperimental(): MosaicExperimental { + return useContext(MosaicExperimentalContext); +} diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index c3e65bde3e7..3d7355893ba 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -3,6 +3,8 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; import { userProfileModal } from './features/user-profile/user-profile.modal'; +import type { MosaicExperimental } from './hooks/use-mosaic-experimental'; +import { MosaicExperimentalProvider } from './hooks/use-mosaic-experimental'; import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; @@ -21,24 +23,36 @@ export interface MosaicProviderProps { icons?: MosaicIconOverrides; /** Locale and strings for everything Mosaic renders; see `MosaicLocalization`. */ localization?: MosaicLocalization; + /** Features still being tried out. Their names and behavior can change in any release. */ + experimental?: MosaicExperimental; } // Exported Mosaic components annotate a React return type on purpose. The package sets // `jsxImportSource: '@emotion/react'`, so an inferred return leaks Emotion's `JSX.Element` into the // published `.d.ts`, which React 19 consumers reject as a JSX element type. -export function MosaicProvider({ children, direction, icons, localization }: MosaicProviderProps): React.ReactElement { +export function MosaicProvider({ + children, + direction, + icons, + localization, + experimental, +}: MosaicProviderProps): React.ReactElement { const iconsValue = React.useMemo(() => icons ?? {}, [icons]); const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); const [now] = React.useState(() => new Date()); + const newProfiles = experimental?.newProfiles; + const experimentalValue = React.useMemo(() => ({ newProfiles }), [newProfiles]); return ( - - {children} - + + + {children} + + diff --git a/packages/swingset/src/stories/user-button.mdx b/packages/swingset/src/stories/user-button.mdx index fba984c2386..4c4eb01c34e 100644 --- a/packages/swingset/src/stories/user-button.mdx +++ b/packages/swingset/src/stories/user-button.mdx @@ -219,9 +219,10 @@ all accounts**. The foot keeps **Sign out**. ## Profile modal Mosaic's profile modals are mounted by `MosaicProvider`, so neither the opener nor the app renders a -dialog. The exported UserButton opens Mosaic's `UserProfile` the same way, against the signed-in -user. This example backs the modal with fixture data, so it runs without a Clerk session. The profile is combined: the active organization's -pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls +dialog. With `experimental={{ newProfiles: true }}` on `MosaicProvider`, the exported UserButton opens +Mosaic's `UserProfile` the same way, against the signed-in user; without it, it opens Clerk's profile. +This example backs the modal with fixture data, so it runs without a Clerk session. The profile is +combined: the active organization's pages follow the account's. **Manage account** calls `open()`, **Manage organization** calls `open({ page: 'organization/general' })`, and the app's own buttons call `open({ page })` or `open({ mode: 'user' })` on the same modal. Hovering one preloads the profile, so it opens without a wait. From d344f06f77ff1f345aa4b2862a24641d2c639c93 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 13:11:22 -0600 Subject: [PATCH 08/12] test(mosaic): turn on experimental.newProfiles in the Mosaic integration app --- .../templates/next-app-router-mosaic/src/app/custom/page.tsx | 2 +- .../next-app-router-mosaic/src/app/mosaic-user-button.tsx | 2 +- .../src/app/organization-switcher/page.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx index 3656841dc26..8fc90b59a4a 100644 --- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx @@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic'; export default function Page() { return ( - + + ); diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx index 401ff9bcefc..876c036cf59 100644 --- a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx @@ -4,7 +4,7 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic'; export default function Page() { return ( - + Date: Thu, 29 Oct 2026 13:35:28 -0600 Subject: [PATCH 09/12] feat(mosaic): open the combined profile straight from Settings behind experimental.newProfiles --- .../src/features/switcher/switcher.model.tsx | 15 +++++++++--- .../user-button.new-profiles.feature.test.tsx | 24 +++++++++++++------ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index cde2769e7f9..26b51fd4ffd 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -140,6 +140,10 @@ function resolveAfterUrl( return undefined; } +function opensModal(url: string | undefined, mode: 'navigation' | 'modal' | undefined): boolean { + return (mode ?? (url ? 'navigation' : 'modal')) === 'modal'; +} + /** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ function openOrNavigate({ url, @@ -154,8 +158,7 @@ function openOrNavigate({ buildUrl: () => string; navigate: (to: string) => unknown; }): () => void { - const resolved = mode ?? (url ? 'navigation' : 'modal'); - return resolved === 'navigation' ? () => void navigate(url ?? buildUrl()) : () => openModal(); + return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl()); } const INVITE_MEMBERS_PERMISSION = 'org:sys_memberships:manage'; @@ -252,6 +255,12 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch navigate: router.navigate, }); + const managesBothInOneProfile = + newProfiles && + mosaicUserProfile?.mode === 'combined' && + opensModal(options?.userProfileUrl, options?.userProfileMode) && + opensModal(options?.organizationProfileUrl, options?.organizationProfileMode); + const createOrganization = openOrNavigate({ url: options?.createOrganizationUrl, mode: options?.createOrganizationMode, @@ -383,7 +392,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch // Single-session apps cannot hold a second account, so both actions are meaningless there. onSignOutAll: singleSessionMode ? undefined : () => clerk.signOut({ redirectUrl: clerk.buildAfterSignOutUrl() }), onManageAccount: manageAccount, - onManageOrganization: manageOrganization, + onManageOrganization: managesBothInOneProfile ? undefined : manageOrganization, // Invite has no page of its own to route to, so it opens its modal even when management is routed. onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, // Covers both restricted instances and users at their creation limit. diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx index 0bc73ec3945..b41f67b573a 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -103,13 +103,14 @@ describe('UserButton with experimental.newProfiles', () => { const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); - it('opens the combined UserProfile when managing the account, and closes', async () => { + it('opens the combined UserProfile straight from Settings, without a menu, and closes', async () => { const { clerk, navigate } = await renderWithNewProfiles(); const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); const user = await open(); - await openSettings(user, 'Manage profile'); + await user.click(screen.getByRole('button', { name: 'Settings' })); + expect(screen.queryByRole('menu')).toBeNull(); expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); expect(profileTabs()).toEqual(['Account', 'Security', 'General', 'Members']); expect(openUserProfile).not.toHaveBeenCalled(); @@ -149,7 +150,7 @@ describe('UserButton with experimental.newProfiles', () => { ); const user = await open(); - await openSettings(user, 'Manage profile'); + await user.click(screen.getByRole('button', { name: 'Settings' })); expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); }); @@ -163,23 +164,32 @@ describe('UserButton with experimental.newProfiles', () => { }); const user = await open(); - await openSettings(user, 'Manage profile'); + await user.click(screen.getByRole('button', { name: 'Settings' })); expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByText('Terms body')).toBeVisible(); expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); }); - it("opens the combined profile on the organization's General page, with its own pages in order", async () => { + it("lists the organization's own pages in order in the combined profile", async () => { await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); const user = await open(); - await openSettings(user, 'Manage organization'); + await user.click(screen.getByRole('button', { name: 'Settings' })); - expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); }); + it('still asks which to manage when the organization profile routes to a page', async () => { + const { navigate } = await renderWithNewProfiles({ organizationProfileUrl: '/org' }); + const user = await open(); + + await openSettings(user, 'Manage organization'); + + expect(navigate).toHaveBeenCalledWith('/org'); + }); + it("leaves the organization's pages out in user mode", async () => { await renderWithNewProfiles({ mode: 'user' }); const user = await open(); From a59f604b3f2d8f96394ba36f7df9edcf463fafb1 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 13:35:29 -0600 Subject: [PATCH 10/12] chore(swingset): turn on experimental.newProfiles in the live UserButton --- .../src/app/(clerk)/live/user-button/user-button/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx index dcd7d510253..37670fef95f 100644 --- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx @@ -9,7 +9,7 @@ export default function UserButtonLivePage() { const { isLoaded, isSignedIn } = useUser(); return ( - +

User button

From b9cc624f3d57a69812358ef1be43e794cbe3ef42 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 14:40:36 -0600 Subject: [PATCH 11/12] test(mosaic): warm up the lazily loaded profile modals in feature tests --- packages/mosaic/vitest.config.mts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index 4d9b79a4415..8bf6f87ada0 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -60,6 +60,11 @@ export default defineConfig({ { extends: true, publicDir: 'test/public', + server: { + warmup: { + clientFiles: ['./src/**/*.modal-content.tsx'], + }, + }, optimizeDeps: { include: [ '@clerk/clerk-js', From 41bcfb6adaa3c720c2b642d20b7e1b1ac566308b Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Thu, 29 Oct 2026 14:46:20 -0600 Subject: [PATCH 12/12] test(mosaic): find profile tabs in the profile navigation in integration tests --- integration/tests/mosaic/organization-profile.test.ts | 2 +- integration/tests/mosaic/user-profile.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts index a7d0cda9388..b4d0fecb7a3 100644 --- a/integration/tests/mosaic/organization-profile.test.ts +++ b/integration/tests/mosaic/organization-profile.test.ts @@ -34,7 +34,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic Orga }); const path = '/organization-profile'; - const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true }); async function signIn({ page, context }: { page: Page; context: BrowserContext }) { const u = await createTestUtils({ app, page, context }); diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts index 3eab59203f2..6d89555bf08 100644 --- a/integration/tests/mosaic/user-profile.test.ts +++ b/integration/tests/mosaic/user-profile.test.ts @@ -34,7 +34,7 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User }); const path = '/user-profile'; - const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true }); async function signIn({ page, context }: { page: Page; context: BrowserContext }) { const u = await createTestUtils({ app, page, context });