diff --git a/.changeset/mosaic-profile-provider.md b/.changeset/mosaic-profile-provider.md new file mode 100644 index 00000000000..2cef3b13977 --- /dev/null +++ b/.changeset/mosaic-profile-provider.md @@ -0,0 +1,25 @@ +--- +'@clerk/mosaic': minor +--- + +Configure the profile on `MosaicProvider` with the new `profile` prop, so `UserButton`, `OrganizationSwitcher` and `UserProfile` share one setup. This makes pairings like a user-only `UserButton` beside an `OrganizationSwitcher` with one combined profile possible. + +```tsx + + + + +``` + +Breaking changes: + +- `UserButton`: `mode` is replaced by `combined`, which only decides what the menu lists. `mode='user'` becomes `combined={false}`, and to keep the account-only profile too, also set `profile={{ combined: false }}` on `MosaicProvider`. +- `UserButton` and `OrganizationSwitcher`: `userProfileProps` and `organizationProfileProps` move to `MosaicProvider`'s `profile`. +- `UserButton` and `OrganizationSwitcher`: `userProfileUrl` and `organizationProfileUrl` move to `MosaicProvider`'s `profile`. +- `UserButton` and `OrganizationSwitcher`: `userProfileMode` and `organizationProfileMode` are removed. Setting a URL routes there, and leaving it out opens the modal. +- `UserButton` and `OrganizationSwitcher`: `appearance` is no longer accepted in `userProfileProps` or `organizationProfileProps`. +- `UserButton` and `OrganizationSwitcher`: link items (`href`) are no longer accepted in `customPages`. +- `UserButton` and `OrganizationSwitcher`: `afterLeaveOrganizationUrl` moves to `profile.organizationProfileProps`, which also takes `apiKeysProps`. +- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning. `OrganizationProfile` picks them up the same way. +- With `experimental.newProfiles`, `OrganizationSwitcher` now opens the combined profile by default. Set `profile={{ combined: false }}` for the standalone organization profile. +- The `UserProfileMode` type is removed. diff --git a/.changeset/mosaic-switcher-sso-bypass-page-order.md b/.changeset/mosaic-switcher-sso-bypass-page-order.md new file mode 100644 index 00000000000..a115dfa8f25 --- /dev/null +++ b/.changeset/mosaic-switcher-sso-bypass-page-order.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Fix the organization profile's page order when `OrganizationSwitcher` or `UserButton` opens the legacy profile for a member who can manage the SSO bypass allowlist. The Security page now stays where `pageOrder` puts it instead of moving to the top. diff --git a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx index 8fc90b59a4a..766d3f60a90 100644 --- a/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/custom/page.tsx @@ -4,13 +4,15 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic'; export default function Page() { return ( - - custom-page-content

}], - }} - /> + }, + }} + > +
); } diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx index 876c036cf59..67dab4c1f5c 100644 --- a/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx +++ b/integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx @@ -4,12 +4,16 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic'; export default function Page() { return ( - + custom-page-content

}], + }, + }} + > custom-page-content

}], - }} />
); diff --git a/packages/mosaic/docs/models.md b/packages/mosaic/docs/models.md index 545dfb49553..2bb142bb915 100644 --- a/packages/mosaic/docs/models.md +++ b/packages/mosaic/docs/models.md @@ -72,9 +72,9 @@ drops the fallback instead of holding the space open. Keep the two apart. pair so it cannot contradict itself: ```ts - type UserProfileMode = - | { userProfileUrl: string; userProfileMode?: 'navigation' } - | { userProfileUrl?: never; userProfileMode?: 'modal' }; + type CreateOrganizationMode = + | { createOrganizationUrl: string; createOrganizationMode?: 'navigation' } + | { createOrganizationUrl?: never; createOrganizationMode?: 'modal' }; ``` ## Rules diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index cbcf75842b2..b0aed1469b3 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react'; import type { ReactElement } from 'react'; import { vi } from 'vitest'; +import type { MosaicProfile } from '../../features/profile-pages/use-mosaic-profile'; import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental'; import type { MosaicLocalization } from '../../localization'; import { MosaicProvider } from '../../mosaic-provider'; @@ -14,6 +15,7 @@ export async function renderWithClerk( options?: Parameters[0], localization?: MosaicLocalization, experimental?: MosaicExperimental, + profile?: MosaicProfile, ) { const clerk = new Clerk(publishableKey()); const navigate = vi.fn((_to: string) => Promise.resolve()); @@ -26,6 +28,7 @@ export async function renderWithClerk( {element} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 9d736de54df..c4d69f58f5f 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; +import { useMosaicProfile } from '../profile-pages/use-mosaic-profile'; import { organizationProfilePageIds } from './organization-profile.layout'; import { useOrganizationProfileModel } from './organization-profile.model'; import type { OrganizationProfileOptions } from './organization-profile.provider'; @@ -79,14 +80,16 @@ export interface OrganizationProfileProps * * ``` */ -export function OrganizationProfile({ - customPages, - pageOrder, - defaultPage, - afterLeaveOrganizationUrl, - fallback, - apiKeysProps, -}: OrganizationProfileProps) { +export function OrganizationProfile(props: OrganizationProfileProps) { + const { organizationProfileProps } = useMosaicProfile(); + const { + customPages = organizationProfileProps?.customPages, + pageOrder = organizationProfileProps?.pageOrder, + defaultPage, + afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl, + fallback, + apiKeysProps = organizationProfileProps?.apiKeysProps, + } = props; const model = useOrganizationProfileModel(); const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined; const navigation = useProfilePagesController( diff --git a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 4c617211702..f8272ca6270 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -17,6 +17,7 @@ import { fapiUser, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; import type { OrganizationSwitcherProps } from '../organization-switcher'; import { OrganizationSwitcher } from '../organization-switcher'; @@ -71,9 +72,13 @@ function tree(props: OrganizationSwitcherProps = {}) { ); } -function renderSwitcher(props: OrganizationSwitcherProps = {}, seed: FakeFapiSeed = signedIn()) { +function renderSwitcher( + props: OrganizationSwitcherProps = {}, + seed: FakeFapiSeed = signedIn(), + profile?: MosaicProfile, +) { const fapi = serveFapi(seed); - return renderWithClerk(tree(props)).then(view => ({ ...view, fapi })); + return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi })); } const host = () => screen.getByTestId('host'); @@ -222,7 +227,9 @@ describe('OrganizationSwitcher', () => { }); it('tells the organization profile where to land after leaving', async () => { - const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); + const { clerk } = await renderSwitcher({}, noOffers(), { + organizationProfileProps: { afterLeaveOrganizationUrl: '/left' }, + }); const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await openWithList(); @@ -298,6 +305,38 @@ describe('OrganizationSwitcher', () => { expect(reading('Settings', 'Terms of service', 'Support')).toEqual(['Support', 'Terms of service', 'Settings']); }); + describe('the organization profile page order', () => { + const termsPage = { label: 'Terms', path: 'terms', icon: , content:

Terms body

}; + + async function openedPageLabels(permissions: string[]) { + const memberships = [fapiMembership(acme, { permissions }), fapiMembership(other)]; + const user = fapiUser({ ...alice, organization_memberships: memberships }); + const session = fapiSession({ id: 'sess_1', user, last_active_organization_id: 'org_1' }); + const { clerk } = await renderSwitcher({}, noOffers({ client: fapiClient([session]), memberships }), { + organizationProfileProps: { customPages: [termsPage], pageOrder: ['terms', 'security'] }, + }); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const clicker = await openWithList(); + + await clicker.click(screen.getByRole('button', { name: 'Settings' })); + + return openOrganizationProfile.mock.calls[0]?.[0]?.customPages?.map(page => page.label); + } + + it('keeps Security where it was asked for when the member can manage the SSO bypass allowlist', async () => { + expect(await openedPageLabels(['org:sys_entconns_sso_bypass:manage'])).toEqual([ + 'Terms', + 'security', + 'general', + 'members', + ]); + }); + + it('leaves Security out when the organization profile will not show it', async () => { + expect(await openedPageLabels([])).toEqual(['Terms', 'general', 'members']); + }); + }); + it('themes its parts through organization-switcher slots', async () => { await renderSwitcher({}, noOffers()); await openWithList(); @@ -378,11 +417,11 @@ describe('OrganizationSwitcher custom trigger badges', () => { }); describe('OrganizationSwitcher with experimental.newProfiles', () => { - async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) { + async function renderWithNewProfiles(profile: MosaicProfile = { combined: false }) { await page.viewport(1280, 800); onTestFinished(() => page.viewport(414, 896)); serveFapi(noOffers()); - return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }); + return renderWithClerk(tree(), undefined, undefined, { newProfiles: true }, profile); } it('opens the OrganizationProfile, without the account pages, from Settings', async () => { @@ -397,8 +436,14 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => { expect(openOrganizationProfile).not.toHaveBeenCalled(); }); - it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { - const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' }); + it.each([ + { name: 'separate', combined: false }, + { name: 'combined', combined: true }, + ])('lands on afterLeaveOrganizationUrl after leaving from the opened profile: $name', async ({ combined }) => { + const { navigate } = await renderWithNewProfiles({ + combined, + organizationProfileProps: { afterLeaveOrganizationUrl: '/left' }, + }); const user = await openWithList(); await user.click(screen.getByRole('button', { name: 'Settings' })); diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx index ace9a4a3f94..0e4ff5630b4 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.tsx @@ -8,11 +8,11 @@ import type { OrganizationSwitcherModelOptions } from '../switcher/switcher.mode import type { SwitcherMenuProps } from '../switcher/switcher.types'; import type { SwitcherTriggerProps } from '../switcher/switcher.view'; -/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ +/** Everything `` takes: trigger content, and the app's own menu rows. */ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions & SwitcherTriggerProps & SwitcherMenuProps & - Pick; + Pick; /** * The active organization's avatar, and the menu behind it: switch organization or back to the @@ -35,11 +35,11 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions & * ``` * * @example - * Passing a URL routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl` - * is where switching organization lands, and takes a `:param` template, a plain path, or a function. + * `createOrganizationUrl` routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl` + * is where switching organization lands, and takes a `:param` template, a plain path, or a function. Set + * `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to a page of your own. * ```tsx * , content: }], - * pageOrder: ['general', 'members', 'audit'], - * }} * customMenuItems={[{ id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }]} * /> * ``` diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts index bfd0d37f898..de00da1ecf5 100644 --- a/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts +++ b/packages/mosaic/src/features/profile-pages/profile-pages.controller.ts @@ -1,12 +1,20 @@ import { useState } from 'react'; -export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) { +export function useProfilePagesController( + initialPage: string, + pageIds: readonly string[] | undefined, + openingFallbackPage?: string, +) { const [activePage, setActivePage] = useState(initialPage); + const [isOpening, setIsOpening] = useState(true); if (pageIds) { - const [firstPage] = pageIds; - if (firstPage !== undefined && !pageIds.includes(activePage)) { - setActivePage(firstPage); + if (isOpening) { + setIsOpening(false); + } + const fallbackPage = (isOpening ? openingFallbackPage : undefined) ?? pageIds[0]; + if (fallbackPage !== undefined && !pageIds.includes(activePage)) { + setActivePage(fallbackPage); } } diff --git a/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts new file mode 100644 index 00000000000..820d90ff8ce --- /dev/null +++ b/packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts @@ -0,0 +1,76 @@ +import { createContext, useContext } from 'react'; + +import type { OrganizationProfileProps } from '../organization-profile/organization-profile'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; +import type { UserProfileProps } from '../user-profile/user-profile'; +import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; + +export interface MosaicProfile { + /** + * Whether one profile carries the account's pages and the active organization's. `false` keeps them + * in separate profiles. It decides the profile's shape, not what `UserButton`'s menu lists. + * + * @default true + */ + combined?: boolean; + /** + * Options for the user profile, whichever component opens or mounts it. + */ + userProfileProps?: ProfileUserProps; + /** + * Options for the organization profile, or the organization's pages in a combined profile, + * whichever component opens or mounts it. + */ + organizationProfileProps?: ProfileOrganizationProps; + /** + * Full URL or path of your own user profile page. Setting it routes there instead of opening the modal. + */ + userProfileUrl?: string; + /** + * Full URL or path of your own organization profile page. Setting it routes there instead of opening the modal. + */ + organizationProfileUrl?: string; +} + +/** What a profile takes beyond the profile component's own props. */ +export interface ProfilePages { + /** + * Provide custom pages to be rendered inside the profile. + */ + customPages?: CustomProfilePage[]; + /** + * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the + * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is + * the page the profile opens on. + * + * @default undefined + */ + pageOrder?: (PageId | (string & {}))[]; +} + +/** Options for the underlying `` component. */ +export interface ProfileUserProps + extends ProfilePages, Pick {} + +/** Options for the underlying `` component. */ +export interface ProfileOrganizationProps + extends + ProfilePages, + Pick {} + +const MosaicProfileContext = createContext({}); + +export const MosaicProfileProvider = MosaicProfileContext.Provider; + +export function useMosaicProfile(): MosaicProfile { + return useContext(MosaicProfileContext); +} + +/** What the user profile modal opens with, whichever path opens it. */ +export function userProfileModalOptions({ + combined = true, + userProfileProps, + organizationProfileProps, +}: MosaicProfile) { + return { ...userProfileProps, combined, organizationProfileProps }; +} diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx index 8db622b685e..4a4f7fb5626 100644 --- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx @@ -2,7 +2,7 @@ import type { CustomPage } from '@clerk/shared/types'; import { act, render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it } from 'vitest'; -import type { CustomProfileItem } from '../../user-profile/user-profile.types'; +import type { CustomProfilePage } from '../../user-profile/user-profile.types'; import type { CustomPagesOptions } from '../switcher.pages'; import { useCustomPages } from '../switcher.pages'; @@ -29,18 +29,18 @@ function Harness({ items, order, builtInPages = ['account', 'security'] }: Parti return
{portals}
; } -const terms: CustomProfileItem = { +const terms: CustomProfilePage = { label: 'Terms', path: 'terms', icon: terms icon, content:

Terms body

, }; -const docs: CustomProfileItem = { +const docs: CustomProfilePage = { label: 'Docs', path: 'docs', - href: 'https://clerk.com/docs', icon: docs icon, + content:

Docs body

, }; beforeEach(() => { @@ -55,23 +55,21 @@ describe('useCustomPages', () => { expect(screen.getByTestId('host')).toBeEmptyDOMElement(); }); - it('sends a page as its path and a link as its href', () => { + it('sends a page as its path', () => { render(); - expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); + expect(emitted?.map(page => page.url)).toEqual(['terms', 'docs']); expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); }); // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what - // make an item a page. A link carrying them would be routed to instead of followed. - it('sends content callbacks for a page and none for a link', () => { - render(); + // make an item a page. + it('sends content callbacks for a page', () => { + render(); - const [page, link] = emitted ?? []; + const [page] = emitted ?? []; expect(page.mount).toBeTypeOf('function'); expect(page.unmount).toBeTypeOf('function'); - expect(link.mount).toBeUndefined(); - expect(link.unmount).toBeUndefined(); }); // Without them clerk-js drops the page as invalid, so `icon` could not be optional. @@ -97,7 +95,7 @@ describe('useCustomPages', () => { }); it('keeps each page in the element that asked for it', () => { - const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; + const help: CustomProfilePage = { label: 'Help', path: 'help', content:

Help body

}; render(); const first = mountInto(emitted?.[0].mount); diff --git a/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx new file mode 100644 index 00000000000..37a9cdb2912 --- /dev/null +++ b/packages/mosaic/src/features/switcher/__tests__/switcher.profile.feature.test.tsx @@ -0,0 +1,372 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; + +import { openDialog } from '../../../__tests__/feature/dialog'; +import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; +import { openMenu } from '../../../__tests__/feature/menu'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { OrganizationProfile } from '../../organization-profile/organization-profile'; +import { OrganizationSwitcher } from '../../organization-switcher/organization-switcher'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; +import { UserButton } from '../../user-button/user-button'; +import { UserProfile } from '../../user-profile/user-profile'; + +const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3 }); +const memberships = [fapiMembership(acme, { permissions: ['org:sys_memberships:manage'] })]; +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_alice', email_address: 'alice@example.com' })], + organization_memberships: memberships, +}); + +const inOrganization: FakeFapiSeed = { + client: fapiClient([fapiSession({ id: 'sess_1', user: alice, last_active_organization_id: 'org_1' })]), + memberships, +}; +const inPersonalAccount: FakeFapiSeed = { + client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), + memberships, +}; + +const keysMemberships = [fapiMembership(acme, { permissions: ['org:sys_api_keys:read'] })]; +const withOrganizationApiKeys: FakeFapiSeed = { + environment: fapiEnvironment({ + api_keys_settings: { user_api_keys_enabled: false, orgs_api_keys_enabled: true }, + }), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: { ...alice, organization_memberships: keysMemberships }, + last_active_organization_id: 'org_1', + }), + ]), + memberships: keysMemberships, +}; + +const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; +const accountTabs = ['Account', 'Security']; +const organizationTabs = ['General', 'Members']; +const allTabs = [...accountTabs, ...organizationTabs]; +const separate: MosaicProfile = { combined: false }; + +async function renderWithProfile( + ui: ReactElement, + profile?: MosaicProfile, + seed: FakeFapiSeed = inOrganization, + experimental: { newProfiles?: boolean } = { newProfiles: true }, +) { + serveFapi(seed); + return renderWithClerk(ui, undefined, undefined, experimental, profile); +} + +const buttonTrigger = () => screen.getByRole('button', { name: /Open account menu/ }); +const switcherTrigger = () => screen.getByRole('button', { name: /Open organization menu/ }); +const settings = () => screen.getByRole('button', { name: 'Settings' }); +const tabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); +const selectedTab = (name: string) => screen.findByRole('tab', { name, selected: true }); + +async function clickSettings(trigger: () => HTMLElement) { + const user = userEvent.setup(); + await user.click(trigger()); + await user.click(settings()); + return user; +} + +async function chooseSetting(trigger: () => HTMLElement, label: string) { + const user = userEvent.setup(); + await user.click(trigger()); + await openMenu(user, settings()); + await user.click(screen.getByRole('menuitem', { name: label })); +} + +async function leaveOrganization() { + const user = userEvent.setup(); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); +} + +beforeEach(async () => { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +}); + +describe('the profile the switchers open', () => { + it.each([ + { name: 'combined by default', profile: undefined, expected: allTabs }, + { name: 'combined when asked', profile: { combined: true }, expected: allTabs }, + { name: 'separate when opted out', profile: separate, expected: accountTabs }, + ])('a user-only button opens the account pages first: $name', async ({ profile, expected }) => { + await renderWithProfile(, profile); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(expected); + }); + + it('a combined button opens one profile straight from Settings', async () => { + await renderWithProfile(); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('a combined button offers two targets over separate profiles', async () => { + await renderWithProfile(, separate); + + await chooseSetting(buttonTrigger, 'Manage organization'); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(organizationTabs); + }); + + it('a lone switcher lands on the organization pages of the combined profile', async () => { + await renderWithProfile(); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('a lone switcher opens the standalone organization profile when opted out', async () => { + await renderWithProfile(, separate); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(organizationTabs); + }); + + it.each([ + { name: 'a user-only button', button: }, + { name: 'a combined button', button: }, + ])('a switcher beside $name keeps its Settings row', async ({ button }) => { + await renderWithProfile( + <> + + {button} + , + ); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(allTabs); + }); + + it('leaves the organization pages out of the combined profile on a personal account', async () => { + await renderWithProfile(, undefined, inPersonalAccount); + + await clickSettings(buttonTrigger); + + expect(await selectedTab('Account')).toBeInTheDocument(); + expect(tabs()).toEqual(accountTabs); + }); + + it('routes both profiles with URLs from the provider', async () => { + const { navigate } = await renderWithProfile(, { + userProfileUrl: '/account', + organizationProfileUrl: '/org', + }); + + await clickSettings(switcherTrigger); + + expect(navigate).toHaveBeenCalledWith('/org'); + }); + + it.each([ + { name: 'separate', combined: false, expected: ['General'] }, + { name: 'combined', combined: true, expected: ['Account', 'Security', 'General'] }, + ])("hides the organization's API keys from the profile a switcher opens: $name", async ({ combined, expected }) => { + await renderWithProfile( + , + { combined, organizationProfileProps: { apiKeysProps: { hide: true } } }, + withOrganizationApiKeys, + ); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('General')).toBeInTheDocument(); + expect(tabs()).toEqual(expected); + }); + + it("hands the provider's organization options to Clerk's modal without newProfiles", async () => { + const organizationProfileProps = { afterLeaveOrganizationUrl: '/left', apiKeysProps: { hide: true } }; + const { clerk } = await renderWithProfile( + , + { organizationProfileProps }, + withOrganizationApiKeys, + {}, + ); + const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + + await clickSettings(switcherTrigger); + + expect(openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining(organizationProfileProps)); + }); + + it('routes the one profile with a URL, and opens the other as a modal', async () => { + const { navigate } = await renderWithProfile(, { userProfileUrl: '/account' }); + + await chooseSetting(buttonTrigger, 'Manage profile'); + expect(navigate).toHaveBeenCalledWith('/account'); + + await chooseSetting(buttonTrigger, 'Manage organization'); + expect(await selectedTab('General')).toBeInTheDocument(); + }); + + it('opens a combined profile on the first organization page in the configured order', async () => { + await renderWithProfile(, { + organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] }, + }); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('Terms')).toBeInTheDocument(); + }); + + it('skips a page this organization does not have when opening a combined profile', async () => { + await renderWithProfile(, { + organizationProfileProps: { pageOrder: ['apiKeys', 'members'] }, + }); + + await clickSettings(switcherTrigger); + + expect(await selectedTab('Members')).toBeInTheDocument(); + }); + + it.each([ + { name: 'the button', ui: , trigger: buttonTrigger }, + { name: 'the switcher', ui: , trigger: switcherTrigger }, + ])('applies the custom pages and order set on the provider when $name opens it', async ({ ui, trigger }) => { + await renderWithProfile(ui, { + userProfileProps: { customPages: [terms], pageOrder: ['account', 'terms'] }, + organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }], pageOrder: ['audit'] }, + }); + + await clickSettings(trigger); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Terms', 'Security', 'Audit', 'General', 'Members'])); + }); + + it("hands the provider's custom pages to Clerk's modals without newProfiles", async () => { + const { clerk } = await renderWithProfile( + , + { userProfileProps: { customPages: [terms] } }, + inOrganization, + {}, + ); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + + await clickSettings(buttonTrigger); + + const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; + expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + + const el = document.body.appendChild(document.createElement('div')); + onTestFinished(() => el.remove()); + act(() => { + customPages[0]?.mount?.(el); + }); + expect(within(el).getByText('Terms body')).toBeInTheDocument(); + }); +}); + +describe('a mounted OrganizationProfile under a provider profile', () => { + const audit = { ...terms, label: 'Audit', path: 'audit' }; + + it("uses the provider's profile props", async () => { + await renderWithProfile(, { + organizationProfileProps: { + customPages: [audit], + pageOrder: ['audit'], + }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Audit', 'General', 'Members'])); + }); + + it('lets its own props win over the provider', async () => { + await renderWithProfile(, { + organizationProfileProps: { customPages: [audit] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['General', 'Members', 'Own'])); + }); + + it('lands where the provider says after leaving the organization', async () => { + const { navigate } = await renderWithProfile(, { + organizationProfileProps: { afterLeaveOrganizationUrl: '/left' }, + }); + await leaveOrganization(); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); + }); +}); + +describe('a mounted UserProfile under a provider profile', () => { + it('fills in what its own organizationProfileProps leave out from the provider', async () => { + await renderWithProfile(, { + organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }], pageOrder: ['general'] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Members', 'General', 'Audit'])); + }); + + it('lands where the provider says after leaving the organization', async () => { + const { navigate } = await renderWithProfile(, { + organizationProfileProps: { afterLeaveOrganizationUrl: '/left' }, + }); + await leaveOrganization(); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); + }); + + it.each([ + { name: 'follows the provider', profile: separate, combined: undefined, expected: accountTabs }, + { name: 'defaults to combined', profile: undefined, combined: undefined, expected: allTabs }, + { name: 'wins over the provider when combined', profile: separate, combined: true, expected: allTabs }, + { name: 'wins over the provider when not', profile: { combined: true }, combined: false, expected: accountTabs }, + ])('$name', async ({ profile, combined, expected }) => { + await renderWithProfile(, profile); + + await waitFor(() => expect(tabs()).toEqual(expected)); + }); + + it("uses the provider's profile props", async () => { + await renderWithProfile(, { + userProfileProps: { customPages: [terms] }, + organizationProfileProps: { customPages: [{ ...terms, label: 'Audit', path: 'audit' }] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members', 'Audit'])); + }); + + it('lets its own props win over the provider', async () => { + await renderWithProfile(, { + userProfileProps: { customPages: [terms] }, + }); + + await waitFor(() => expect(tabs()).toEqual(['Account', 'Security', 'Own', 'General', 'Members'])); + }); +}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index b12cbd6fb3d..0bdc0f823a5 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -2,11 +2,10 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; import type { - OrganizationProfileModalProps, + CustomPage, OrganizationResource, OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, - UserProfileModalProps, UserResource, } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; @@ -19,15 +18,17 @@ import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions'; import { organizationProfileModal } from '../organization-profile/organization-profile.modal'; -import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types'; +import type { MosaicProfile } from '../profile-pages/use-mosaic-profile'; +import { userProfileModalOptions } from '../profile-pages/use-mosaic-profile'; +import { ORGANIZATION_PAGE_PREFIX } from '../user-profile/user-profile.layout'; import { userProfileModal } from '../user-profile/user-profile.modal'; -import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types'; import type { SwitcherBrandingProps, SwitcherCallbacks, SwitcherData, SwitcherInvitation, SwitcherMembership, + SwitcherMode, SwitcherSession, SwitcherSuggestion, } from './switcher.types'; @@ -55,35 +56,6 @@ export type SwitcherModel = organizationsEnabled: boolean; }); -/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ -type UserProfileMode = - | { - /** Full URL or path of your own user profile page. Setting it routes there instead of opening Clerk's modal. */ - userProfileUrl: string; - /** How the user profile opens: `modal` opens Clerk's, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */ - userProfileMode?: 'navigation'; - } - | { - /** Full URL or path of your own user profile page. Setting it routes there instead of opening Clerk's modal. */ - userProfileUrl?: never; - /** How the user profile opens: `modal` opens Clerk's, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */ - userProfileMode?: 'modal'; - }; - -type OrganizationProfileMode = - | { - /** Full URL or path of your own organization profile page. Setting it routes there instead of opening Clerk's modal. */ - organizationProfileUrl: string; - /** How the organization profile opens: `modal` opens Clerk's, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */ - organizationProfileMode?: 'navigation'; - } - | { - /** Full URL or path of your own organization profile page. Setting it routes there instead of opening Clerk's modal. */ - organizationProfileUrl?: never; - /** How the organization profile opens: `modal` opens Clerk's, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */ - organizationProfileMode?: 'modal'; - }; - type CreateOrganizationMode = | { /** Full URL or path of your own create-organization page. Setting it routes there instead of opening Clerk's modal. */ @@ -98,34 +70,29 @@ type CreateOrganizationMode = createOrganizationMode?: 'modal'; }; -export type SwitcherModelOptions = UserProfileMode & - OrganizationSwitcherModelOptions & +export type SwitcherModelOptions = OrganizationSwitcherModelOptions & Pick; -export type OrganizationSwitcherModelOptions = OrganizationProfileMode & - CreateOrganizationMode & +export type OrganizationSwitcherModelOptions = CreateOrganizationMode & Pick< OrganizationSwitcherProps, | 'afterSelectOrganizationUrl' | 'afterSelectPersonalUrl' | 'afterCreateOrganizationUrl' - | 'afterLeaveOrganizationUrl' | 'skipInvitationScreen' | 'hidePersonal' >; -/** Props forwarded to the profile modals this button opens. */ -export interface SwitcherModalProps { - userProfile?: Pick; - organizationProfile?: Pick; - /** What Mosaic's own profiles take when `experimental.newProfiles` opens them instead. */ - newProfiles?: { - userProfile?: Pick< - UserProfileModalOptions, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' - >; - organizationProfile?: Pick; +/** What the profiles this button opens are built from. */ +export interface SwitcherProfileContext { + profile: MosaicProfile; + /** Custom pages in the form Clerk's modals take, already bridged, since only the wrapper can render the portals behind them. */ + bridgedCustomPages: { + userProfile: CustomPage[] | undefined; + organizationProfile: CustomPage[] | undefined; }; + /** Which switchers the opening menu carries. Only a menu with both has one Settings row for two targets. */ + menuMode: SwitcherMode; } export function resolveAfterUrl( @@ -141,10 +108,6 @@ export function resolveAfterUrl( return undefined; } -function opensModal(url: string | undefined, mode: 'navigation' | 'modal' | undefined): boolean { - return (mode ?? (url ? 'navigation' : 'modal')) === 'modal'; -} - /** Opens the modal unless a URL routes instead. An explicit mode wins; a URL on its own means navigation. */ function openOrNavigate({ url, @@ -154,12 +117,13 @@ function openOrNavigate({ navigate, }: { url: string | undefined; - mode: 'navigation' | 'modal' | undefined; + mode?: 'navigation' | 'modal'; openModal: () => void; buildUrl: () => string; navigate: (to: string) => unknown; }): () => void { - return opensModal(url, mode) ? () => openModal() : () => void navigate(url ?? buildUrl()); + const opensModal = (mode ?? (url ? 'navigation' : 'modal')) === 'modal'; + return opensModal ? () => openModal() : () => void navigate(url ?? buildUrl()); } function displayName(user: UserResource): string { @@ -189,12 +153,14 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. Custom pages for Clerk's arrive already bridged, - * since only the wrapper can render the portals behind them. With `experimental.newProfiles`, a - * `combined` user profile also carries the organization's pages, so managing the organization opens it - * on them. + * @param context - The provider's profile and what the wrapper bridged from it. With + * `experimental.newProfiles`, a combined user profile also carries the organization's pages, so + * managing the organization opens it on them. */ -export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { +export function useSwitcherModel( + options: SwitcherModelOptions, + { profile, bridgedCustomPages, menuMode }: SwitcherProfileContext, +): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); // The active org names the trigger. That is not a request to turn Organizations on. @@ -206,10 +172,17 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const userProfile = useModal(userProfileModal); const organizationProfile = useModal(organizationProfileModal); const { newProfiles } = useMosaicExperimental(); + const { + combined = true, + userProfileProps, + organizationProfileProps, + userProfileUrl, + organizationProfileUrl, + } = profile; // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); - const signInUrl = () => options?.signInUrl ?? clerk.buildSignInUrl(); + const signInUrl = () => options.signInUrl ?? clerk.buildSignInUrl(); // Don't fetch orgsLists until we know orgs are enabled. // This wont delay rendering of the trigger, or even the popup shell, since the "ready" status // does not depend on this. @@ -217,37 +190,43 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch enabled: Boolean(environment?.organizationSettings.enabled), }); - const afterLeaveOrganizationUrl = options?.afterLeaveOrganizationUrl; - const mosaicUserProfile = modals?.newProfiles?.userProfile; - const mosaicOrganizationProfile = { ...modals?.newProfiles?.organizationProfile, afterLeaveOrganizationUrl }; + const mosaicUserProfile = userProfileModalOptions(profile); const manageAccount = openOrNavigate({ - url: options?.userProfileUrl, - mode: options?.userProfileMode, + url: userProfileUrl, openModal: () => newProfiles - ? userProfile.open( - { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile }, - getContainer?.(), - ) - : clerk.openUserProfile({ getContainer, ...modals?.userProfile }), + ? userProfile.open(mosaicUserProfile, getContainer?.()) + : clerk.openUserProfile({ + getContainer, + customPages: bridgedCustomPages.userProfile, + additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, + apiKeysProps: userProfileProps?.apiKeysProps, + }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); const manageOrganization = openOrNavigate({ - url: options?.organizationProfileUrl, - mode: options?.organizationProfileMode, + url: organizationProfileUrl, openModal: () => { if (!newProfiles) { - clerk.openOrganizationProfile({ getContainer, ...modals?.organizationProfile, afterLeaveOrganizationUrl }); - } else if (mosaicUserProfile?.mode === 'combined') { + clerk.openOrganizationProfile({ + getContainer, + customPages: bridgedCustomPages.organizationProfile, + afterLeaveOrganizationUrl: organizationProfileProps?.afterLeaveOrganizationUrl, + apiKeysProps: organizationProfileProps?.apiKeysProps, + }); + } else if (combined) { userProfile.open( - { ...mosaicUserProfile, organizationProfileProps: mosaicOrganizationProfile, page: 'organization/general' }, + { + ...mosaicUserProfile, + page: `${ORGANIZATION_PAGE_PREFIX}${organizationProfileProps?.pageOrder?.[0] ?? 'general'}`, + }, getContainer?.(), ); } else { - organizationProfile.open(mosaicOrganizationProfile, getContainer?.()); + organizationProfile.open(organizationProfileProps, getContainer?.()); } }, buildUrl: () => clerk.buildOrganizationProfileUrl(), @@ -255,19 +234,16 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch }); const managesBothInOneProfile = - newProfiles && - mosaicUserProfile?.mode === 'combined' && - opensModal(options?.userProfileUrl, options?.userProfileMode) && - opensModal(options?.organizationProfileUrl, options?.organizationProfileMode); + newProfiles && menuMode === 'combined' && combined && !userProfileUrl && !organizationProfileUrl; const createOrganization = openOrNavigate({ - url: options?.createOrganizationUrl, - mode: options?.createOrganizationMode, + url: options.createOrganizationUrl, + mode: options.createOrganizationMode, openModal: () => clerk.openCreateOrganization({ getContainer, - afterCreateOrganizationUrl: options?.afterCreateOrganizationUrl, - skipInvitationScreen: options?.skipInvitationScreen, + afterCreateOrganizationUrl: options.afterCreateOrganizationUrl, + skipInvitationScreen: options.skipInvitationScreen, }), buildUrl: () => clerk.buildCreateOrganizationUrl(), navigate: router.navigate, @@ -331,9 +307,9 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch const afterSelectUrl = async (organizationId: string | null): Promise => { if (!organizationId) { - return resolveAfterUrl(options?.afterSelectPersonalUrl, user); + return resolveAfterUrl(options.afterSelectPersonalUrl, user); } - if (!options?.afterSelectOrganizationUrl) { + if (!options.afterSelectOrganizationUrl) { return undefined; } const selected = @@ -350,7 +326,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch activeOrganization: organization ? toMembership(organization) : null, // The user resource settles this before the paginated list answers; the count covers a stale resource. hasOrganizations: user.organizationMemberships.length > 0 || (userMemberships.count ?? 0) > 0, - hidePersonal: forceOrganizationSelection || (options?.hidePersonal ?? false), + hidePersonal: forceOrganizationSelection || (options.hidePersonal ?? false), // Only true before the first page lands, which is the one window where empty and pending look alike. organizationsLoading: userMemberships.isLoading || userInvitations.isLoading || userSuggestions.isLoading, memberships, @@ -374,7 +350,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch await router.navigate(buildTaskUrl(task, { base: signInUrl() })); return; } - const afterSwitchSessionUrl = options?.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl; + const afterSwitchSessionUrl = options.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl; if (!afterSwitchSessionUrl) { return; } diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index fe0598ab6f9..7f97b50b537 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -6,7 +6,7 @@ import { disabledUserAPIKeysFeature, disabledUserBillingFeature, } from '@clerk/shared/internal/clerk-js/componentGuards'; -import { useClerk } from '@clerk/shared/react'; +import { useClerk, useSession } from '@clerk/shared/react'; import type { CustomPage } from '@clerk/shared/types'; import type { ReactNode } from 'react'; import { useCallback, useState } from 'react'; @@ -14,9 +14,10 @@ import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { applyOrder } from '../../utils/apply-order'; +import { ORGANIZATION_PERMISSIONS } from '../../utils/permissions'; import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; -import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -42,6 +43,9 @@ export function useUserProfilePages(): UserProfilePageId[] { export function useOrganizationProfilePages(): OrganizationProfilePageId[] { const clerk = useClerk(); const environment = useMosaicEnvironment(); + const { session } = useSession(); + const canManageSSOBypass = + session?.checkAuthorization({ permission: ORGANIZATION_PERMISSIONS.ssoBypassManage }) ?? false; const pages: OrganizationProfilePageId[] = ['general', 'members']; if (!disabledOrganizationBillingFeature(clerk, environment)) { @@ -50,15 +54,19 @@ export function useOrganizationProfilePages(): OrganizationProfilePageId[] { if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { pages.push('apiKeys'); } - if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) { + if ( + !disabledSelfServeSSOFeature(clerk, environment) || + !disabledSelfServeDirectorySyncFeature(clerk, environment) || + canManageSSOBypass + ) { pages.push('security'); } return pages; } export interface CustomPagesOptions { - /** Pages and links of the consumer's own. */ - items: CustomProfileItem[] | undefined; + /** Pages of the consumer's own. */ + items: CustomProfilePage[] | undefined; /** The order the profile's navigation should run in, by id. */ order: readonly string[] | undefined; /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ @@ -72,8 +80,6 @@ export interface CustomPagesBridge { portals: ReactNode[]; } -const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; - function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { const container = containers.get(id); return container ? createPortal(node, container, id) : null; @@ -132,21 +138,21 @@ export function useCustomPages({ items, order, builtInPages }: CustomPagesOption // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put // through them; without them, leaving `icon` off would silently cost you the page. const icon = bind(`icon:${id}`); - const content = isLink(item) ? undefined : bind(`content:${id}`); + const content = bind(`content:${id}`); return { label: item.label, - // A page is routed to by its path; a link is followed to wherever it points. - url: isLink(item) ? item.href : item.path, + url: item.path, mountIcon: icon.mount, unmountIcon: icon.unmount, - ...(content && { mount: content.mount, unmount: content.unmount }), + mount: content.mount, + unmount: content.unmount, }; }); const portals = (items ?? []).flatMap(item => [ portalInto(containers, `icon:${item.path}`, item.icon), - ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), + portalInto(containers, `content:${item.path}`, item.content), ]); return { customPages, portals }; diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index e5ea93af61b..0190e871ee3 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -1,10 +1,8 @@ 'use client'; -import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; -import type { CustomProfileItem, CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; +import { useMosaicProfile } from '../profile-pages/use-mosaic-profile'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; @@ -13,45 +11,9 @@ import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; -/** What a profile opened from the switcher takes beyond the profile component's own props. */ -export interface SwitcherProfilePages { - /** - * Provide custom pages and links to be rendered inside the profile. - */ - customPages?: CustomProfileItem[]; - /** - * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the - * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is - * the page the profile opens on, so it cannot be a link. - * - * @default undefined - */ - pageOrder?: (PageId | (string & {}))[]; -} - -/** Options for the underlying `` component. */ -export interface SwitcherUserProfileProps - extends - SwitcherProfilePages, - Pick {} - -/** Options for the underlying `` component. */ -export interface SwitcherOrganizationProfileProps - extends SwitcherProfilePages, Pick {} - export type SwitcherProps = SwitcherModelOptions & SwitcherTriggerProps & SwitcherMenuProps & { - /** - * Specify options for the underlying component. - * e.g., - */ - userProfileProps?: SwitcherUserProfileProps; - /** - * Specify options for the underlying component. - * e.g., organizationProfileProps={{appearance: {...}}} - */ - organizationProfileProps?: SwitcherOrganizationProfileProps; /** * Fallback while loading. * @@ -64,18 +26,10 @@ export type SwitcherProps = SwitcherModelOptions & export function Switcher( props: SwitcherProps & { mode: SwitcherMode; triggerVariant?: SwitcherTriggerVariant }, ): ReactElement | null { - const { - showTriggerLabel, - triggerBadge, - triggerVariant, - mode, - userProfileProps, - organizationProfileProps, - customMenuItems, - menuItemOrder, - fallback, - ...options - } = props; + const { showTriggerLabel, triggerBadge, triggerVariant, mode, customMenuItems, menuItemOrder, fallback, ...options } = + props; + const profile = useMosaicProfile(); + const { userProfileProps, organizationProfileProps } = profile; // The portals must outlive the popover, so custom pages are bridged here rather than inside it. const userProfile = useCustomPages({ items: userProfileProps?.customPages, @@ -94,31 +48,12 @@ export function Switcher( ); const model = useSwitcherModel(options, { - userProfile: { - customPages: userProfile.customPages, - additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, - apiKeysProps: userProfileProps?.apiKeysProps, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: userProfileProps?.appearance, - }, - organizationProfile: { - customPages: organizationProfile.customPages, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: organizationProfileProps?.appearance, - }, - newProfiles: { - userProfile: { - customPages: pagesOnly(userProfileProps?.customPages), - pageOrder: userProfileProps?.pageOrder, - additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, - apiKeysProps: userProfileProps?.apiKeysProps, - mode: mode === 'combined' ? 'combined' : 'user', - }, - organizationProfile: { - customPages: pagesOnly(organizationProfileProps?.customPages), - pageOrder: organizationProfileProps?.pageOrder, - }, + profile, + bridgedCustomPages: { + userProfile: userProfile.customPages, + organizationProfile: organizationProfile.customPages, }, + menuMode: mode, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); @@ -150,7 +85,3 @@ export function Switcher( ); } - -function pagesOnly(items: CustomProfileItem[] | undefined): CustomProfilePage[] | undefined { - return items?.filter((item): item is CustomProfilePage => item.href === undefined); -} diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 757efb0102f..b20f5cc8723 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -20,6 +20,7 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -67,9 +68,9 @@ function tree(props: UserButtonProps = {}) { ); } -function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { +function renderUserButton(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn(), profile?: MosaicProfile) { const fapi = serveFapi(seed); - return renderWithClerk(tree(props)).then(view => ({ ...view, fapi })); + return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi })); } type User = ReturnType; @@ -164,7 +165,7 @@ describe('UserButton', () => { it('renders nothing while Clerk is still loading', async () => { serveFapi(signedIn()); const client = holdRequests('get', '/v1/client'); - const rendering = renderWithClerk(tree({ mode })); + const rendering = renderWithClerk(tree({ combined: mode === 'combined' })); await waitFor(() => expect(client.requests).toHaveLength(1)); expect(host()).toBeEmptyDOMElement(); @@ -175,14 +176,17 @@ describe('UserButton', () => { }); it('renders nothing, not even its fallback, when nobody is signed in', async () => { - await renderUserButton({ mode, fallback: Loading }, signedIn({ client: fapiClient() })); + await renderUserButton( + { combined: mode === 'combined', fallback: Loading }, + signedIn({ client: fapiClient() }), + ); expect(host()).toBeEmptyDOMElement(); }); it('leaves organizations out entirely when the instance has them disabled', async () => { await renderUserButton( - { mode }, + { combined: mode === 'combined' }, signedIn({ environment: fapiEnvironment({ organization_settings: { enabled: false } }) }), ); @@ -268,7 +272,7 @@ describe('UserButton', () => { }); it('lists no organizations on an account-only surface', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); const user = await open(); const menu = await openAccounts(user); @@ -501,7 +505,7 @@ describe('UserButton', () => { }); it('renders a custom page into the element the opened profile hands back', async () => { - const { clerk } = await renderUserButton({ + const { clerk } = await renderUserButton({}, undefined, { userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); @@ -524,7 +528,7 @@ describe('UserButton', () => { }); it('opens the profile with its pages in the order it was given', async () => { - const { clerk } = await renderUserButton({ + const { clerk } = await renderUserButton({}, undefined, { userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], pageOrder: ['account', 'terms'], @@ -544,10 +548,9 @@ describe('UserButton', () => { { features: {}, pages: ['Terms', 'account', 'security'] }, { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] }, ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => { - const { clerk } = await renderUserButton( - { userProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, - signedIn({ environment: instanceWith(features) }), - ); + const { clerk } = await renderUserButton({}, signedIn({ environment: instanceWith(features) }), { + userProfileProps: { customPages: [terms], pageOrder: ['terms'] }, + }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); @@ -587,7 +590,7 @@ describe('UserButton', () => { const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization }); const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })]; const { clerk } = await renderUserButton( - { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, + {}, signedIn({ environment: instanceWith(features), client: fapiClient([ @@ -599,6 +602,7 @@ describe('UserButton', () => { ]), memberships, }), + { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, ); const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await open(); @@ -676,7 +680,7 @@ describe('UserButton', () => { }); it('navigates to manage the account when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' }); + const { clerk, navigate } = await renderUserButton({}, undefined, { userProfileUrl: '/account' }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); @@ -687,7 +691,7 @@ describe('UserButton', () => { }); it('navigates to manage the organization when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); + const { clerk, navigate } = await renderUserButton({}, undefined, { organizationProfileUrl: '/org' }); const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); const user = await open(); @@ -904,7 +908,7 @@ describe('UserButton', () => { describe('in user mode', () => { it('heads the surface with the account, never the active organization', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); await open(); expect(within(requiredPopup()).getByText('Alice Smith')).toBeInTheDocument(); @@ -915,7 +919,7 @@ describe('UserButton', () => { it('drops the identifier line when it would only repeat the name', async () => { const nameless = fapiUser({ ...alice, first_name: null, last_name: null }); await renderUserButton( - { mode: 'user' }, + { combined: false }, signedIn({ client: fapiClient([fapiSession({ ...aliceSession, user: nameless }), bobSession]) }), ); await open(); @@ -924,7 +928,7 @@ describe('UserButton', () => { }); it('lists no organizations and offers no way to make one', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); await open(); expect(screen.queryByText('Personal account')).toBeNull(); @@ -933,7 +937,7 @@ describe('UserButton', () => { }); it('manages the account from its settings row, and signs out at the foot', async () => { - const { clerk } = await renderUserButton({ mode: 'user' }); + const { clerk } = await renderUserButton({ combined: false }); const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); @@ -949,7 +953,7 @@ describe('UserButton', () => { }); it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); const user = await open(); const items = within(await openAccounts(user)).getAllByRole('menuitem'); @@ -1081,7 +1085,7 @@ describe('UserButton', () => { ['username', { first_name: null, last_name: null }, 'alice'], ['email address', { first_name: null, last_name: null, username: null }, 'alice@example.com'], ])('names it by its %s', async (_source, overrides, name) => { - await renderUserButton({ mode: 'user' }, signedInAs(overrides)); + await renderUserButton({ combined: false }, signedInAs(overrides)); expect(trigger()).toHaveAccessibleName(`Open account menu for ${name}`); }); @@ -1342,8 +1346,8 @@ describe('UserButton', () => { describe('the trigger', () => { it.each([ - [{ mode: 'combined' as const }, 'Acme'], - [{ mode: 'user' as const }, 'Alice Smith'], + [{ combined: true }, 'Acme'], + [{ combined: false }, 'Alice Smith'], ])('with %o names %s beside the avatar', async (props, name) => { await renderUserButton(props); @@ -1384,7 +1388,7 @@ describe('UserButton', () => { }); it('keeps the condensed trigger in user mode', async () => { - await renderUserButton({ mode: 'user', triggerVariant: 'full' }); + await renderUserButton({ combined: false, triggerVariant: 'full' }); expect(trigger()).toHaveAttribute('data-variant', 'condensed'); }); @@ -1398,19 +1402,22 @@ describe('UserButton', () => { }); it('names no organization beneath the account in user mode', async () => { - await renderUserButton({ mode: 'user' }); + await renderUserButton({ combined: false }); expect(within(trigger()).queryByText('Acme')).toBeNull(); }); it('still names the account in user mode where personal is hidden and none is active', async () => { - await renderUserButton({ mode: 'user', hidePersonal: true }, signedIn({ client: fapiClient([personalSession]) })); + await renderUserButton( + { combined: false, hidePersonal: true }, + signedIn({ client: fapiClient([personalSession]) }), + ); expect(within(trigger()).getByText('Alice Smith')).toBeInTheDocument(); }); it('renders the avatar alone when the label is off', async () => { - await renderUserButton({ mode: 'user', showTriggerLabel: false }); + await renderUserButton({ combined: false, showTriggerLabel: false }); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); expect(within(trigger()).queryByText('Alice Smith')).toBeNull(); @@ -1433,17 +1440,17 @@ describe('UserButton', () => { describe('UserButton custom trigger badges', () => { it.each([ - { mode: 'combined' as const, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' }, - { mode: 'user' as const, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' }, + { combined: true, seed: signedIn(), badge: 'organization:org_1', name: 'Acme' }, + { combined: false, seed: signedIn(), badge: 'user:user_1', name: 'Alice Smith' }, { - mode: 'combined' as const, + combined: true, seed: signedIn({ client: fapiClient([personalSession]) }), badge: 'user:user_1', name: 'Alice Smith', }, - ])('shows a badge for the displayed identity as $badge', async ({ mode, seed, badge, name }) => { + ])('shows a badge for the displayed identity as $badge', async ({ combined, seed, badge, name }) => { await renderUserButton( - { mode, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) }, + { combined, triggerBadge: ({ kind, id }) => Promise.resolve({ id: 'pro', label: `${kind}:${id}` }) }, seed, ); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx index 16f93c880c7..b940498f204 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.new-profiles.feature.test.tsx @@ -17,6 +17,7 @@ import { import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { PortalProvider } from '../../../primitives/portal'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; import { signedIn as withConnectedAccounts } from '../../user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures'; import type { UserButtonProps } from '../user-button'; import { UserButton } from '../user-button'; @@ -98,11 +99,15 @@ const terms = { label: 'Terms', path: 'terms', content:

Terms body

}; describe('UserButton with experimental.newProfiles', () => { const newProfiles = { newProfiles: true }; - async function renderWithNewProfiles(props: UserButtonProps = {}, seed: FakeFapiSeed = signedIn()) { + async function renderWithNewProfiles( + props: UserButtonProps = {}, + seed: FakeFapiSeed = signedIn(), + profile?: MosaicProfile, + ) { await page.viewport(1280, 800); onTestFinished(() => page.viewport(414, 896)); serveFapi(seed); - return renderWithClerk(tree(props), undefined, undefined, newProfiles); + return renderWithClerk(tree(props), undefined, undefined, newProfiles, profile); } const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); @@ -157,10 +162,10 @@ describe('UserButton with experimental.newProfiles', () => { expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); }); - it('renders custom pages in their order, and leaves out links', async () => { - await renderWithNewProfiles({ + it('renders custom pages in their order', async () => { + await renderWithNewProfiles({}, undefined, { userProfileProps: { - customPages: [terms, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }], + customPages: [terms], pageOrder: ['terms', 'account'], }, }); @@ -174,7 +179,9 @@ describe('UserButton with experimental.newProfiles', () => { }); it("lists the organization's own pages in order in the combined profile", async () => { - await renderWithNewProfiles({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + await renderWithNewProfiles({}, undefined, { + organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] }, + }); const user = await open(); await user.click(screen.getByRole('button', { name: 'Settings' })); @@ -184,7 +191,7 @@ describe('UserButton with experimental.newProfiles', () => { }); it('still asks which to manage when the organization profile routes to a page', async () => { - const { navigate } = await renderWithNewProfiles({ organizationProfileUrl: '/org' }); + const { navigate } = await renderWithNewProfiles({}, undefined, { organizationProfileUrl: '/org' }); const user = await open(); await openSettings(user, 'Manage organization'); @@ -192,8 +199,8 @@ describe('UserButton with experimental.newProfiles', () => { expect(navigate).toHaveBeenCalledWith('/org'); }); - it("leaves the organization's pages out in user mode", async () => { - await renderWithNewProfiles({ mode: 'user' }); + it("leaves the organization's pages out of a separate profile", async () => { + await renderWithNewProfiles({ combined: false }, undefined, { combined: false }); const user = await open(); await user.click(screen.getByRole('button', { name: 'Settings' })); diff --git a/packages/mosaic/src/features/user-button/user-button.tsx b/packages/mosaic/src/features/user-button/user-button.tsx index 5f6ab970961..a547c08fa09 100644 --- a/packages/mosaic/src/features/user-button/user-button.tsx +++ b/packages/mosaic/src/features/user-button/user-button.tsx @@ -4,35 +4,29 @@ import type { ReactElement } from 'react'; import type { SwitcherProps } from '../switcher/switcher'; import { Switcher } from '../switcher/switcher'; -import type { SwitcherMode } from '../switcher/switcher.types'; import type { SwitcherTriggerVariant } from '../switcher/switcher.view'; -/** - * Which switchers the surface carries. `combined` is both; `user` is an account switcher that never - * shows an organization, even when one is active. - */ -export type UserButtonMode = Exclude; - -/** Which switchers the surface carries. */ -export interface UserButtonModeProps { +/** What the menu lists, and how much its trigger carries. */ +export interface UserButtonMenuProps { /** - * Which switchers the popup carries: both, or accounts alone. + * Whether the menu lists organizations beside accounts. `false` lists accounts alone, and never shows + * an organization, even when one is active. The profile's shape is set by `MosaicProvider`'s `profile`. * - * @default 'combined' + * @default true */ - mode?: UserButtonMode; + combined?: boolean; /** - * How much the labelled trigger carries in `combined` mode: `full` badges the active organization - * with the account at a larger size, `condensed` shows the lead's avatar alone. `user` mode is - * always `condensed`. + * How much the labelled trigger carries when `combined`: `full` badges the active organization with + * the account at a larger size, `condensed` shows the lead's avatar alone. Otherwise it is always + * `condensed`. * * @default 'full' */ triggerVariant?: SwitcherTriggerVariant; } -/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profiles it opens. */ -export type UserButtonProps = SwitcherProps & UserButtonModeProps; +/** Everything `` takes: trigger content, and the app's own menu rows. */ +export type UserButtonProps = SwitcherProps & UserButtonMenuProps; /** * The signed-in user's avatar, and the menu behind it: switch organization, switch or add an account, @@ -50,9 +44,9 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * ``` * * @example - * `mode` narrows the menu to the account switcher. + * `combined={false}` narrows the menu to the account switcher. * ```tsx - * + * * ``` * * @example @@ -62,13 +56,12 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * ``` * * @example - * Passing a URL routes to a page of your own instead of opening Clerk's modal; that is the whole - * opt-in. `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` - * template, a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands. + * `afterSelectOrganizationUrl` is where switching organization lands, and takes a `:param` template, + * a plain path, or a function. `afterSwitchSessionUrl` is where switching account lands. Set + * `userProfileUrl` and `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to + * pages of your own. * ```tsx * @@ -82,18 +75,10 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * ``` * * @example - * `customPages` adds your own pages to either profile this button opens; `customMenuItems` adds your - * own rows beside Settings, each one either an `onClick` action or an `href` link. + * `customMenuItems` adds your own rows beside Settings, each one either an `onClick` action or an + * `href` link. The profile's own pages are set on `MosaicProvider`'s `profile`. * ```tsx * , content: }], - * pageOrder: ['account', 'usage', 'security'], - * }} - * organizationProfileProps={{ - * customPages: [{ path: 'audit', label: 'Audit log', icon: , content: }], - * pageOrder: ['general', 'members', 'audit'], - * }} * customMenuItems={[ * { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' }, * { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() }, @@ -102,8 +87,7 @@ export type UserButtonProps = SwitcherProps & UserButtonModeProps; * /> * ``` */ -export function UserButton({ mode, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement { - const combined = mode !== 'user'; +export function UserButton({ combined = true, triggerVariant = 'full', ...props }: UserButtonProps = {}): ReactElement { return ( { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); }); - it('lists only the account pages in user mode', async () => { + it('lists only the account pages when not combined', async () => { serveWithOrganization(); - await renderWithClerk(); + await renderWithClerk(); expect(tabNames()).toEqual(['Account', 'Security']); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx index a296493d559..1c2532650fc 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx @@ -6,9 +6,17 @@ import { beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'; import { page } from 'vitest/browser'; import { publishableKey, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { MosaicProvider } from '../../../mosaic-provider'; +import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile'; beforeAll(async () => { await import('../user-profile.modal-content'); @@ -150,6 +158,51 @@ describe('UserProfile modal return', () => { expect(window.history.state).not.toHaveProperty('__NA'); }); + describe("with the provider's profile", () => { + const memberships = [fapiMembership(fapiOrganization({ id: 'org_1', name: 'Acme' }))]; + + function serveInOrganization() { + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([ + fapiSession({ + id: 'sess_1', + user: fapiUser({ id: 'user_1', organization_memberships: memberships }), + last_active_organization_id: 'org_1', + }), + ]), + memberships, + }); + } + + async function tabsAfterReturning(profile: MosaicProfile) { + serveInOrganization(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + + await renderWithClerk(

Page

, undefined, undefined, undefined, profile); + + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByRole('tab', { name: 'Account' }); + return within(dialog) + .getAllByRole('tab') + .map(tab => tab.textContent); + } + + it('keeps the custom pages', async () => { + const tabs = await tabsAfterReturning({ + userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, + }); + + expect(tabs).toContain('Terms'); + }); + + it('stays account-only when the provider turns combined off', async () => { + const tabs = await tabsAfterReturning({ combined: false }); + + expect(tabs).not.toContain('General'); + }); + }); + it('ignores an unknown value', async () => { serve(); visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&tab=connections'); diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts index 01bb0db3647..d0fd10c14f4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -1,6 +1,7 @@ import React from 'react'; import { useModal } from '../../modals/use-modal'; +import { useMosaicProfile, userProfileModalOptions } from '../profile-pages/use-mosaic-profile'; import { userProfileModal } from './user-profile.modal'; import type { UserProfileOptions } from './user-profile.provider'; @@ -18,6 +19,7 @@ export function withUserProfileModalReturn(url: string, mode: UserProfileOptions export function UserProfileModalReturn(): null { const { open } = useModal(userProfileModal); + const profile = useMosaicProfile(); React.useEffect(() => { queueMicrotask(() => { @@ -27,9 +29,9 @@ export function UserProfileModalReturn(): null { } url.searchParams.delete(MODAL_PARAM); window.history.replaceState(withoutNextAppRouterMarker(window.history.state), '', url); - open({ page: 'account' }); + open({ ...userProfileModalOptions(profile), page: 'account' }); }); - }, [open]); + }, [open, profile]); return null; } diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts index 73ea32c6e4e..c1c8d1e51b0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts @@ -3,7 +3,7 @@ import type { UserProfileProps } from './user-profile'; export interface UserProfileModalOptions extends Pick< UserProfileProps, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' | 'organizationProfileProps' + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'combined' | 'organizationProfileProps' > { page?: UserProfileProps['defaultPage']; } diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index d352d11380c..754efaa863e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -8,9 +8,9 @@ import { organizationProfilePanels } from '../organization-profile/organization- import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; import { useProfilePagesController } from '../profile-pages/profile-pages.controller'; +import { useMosaicProfile } from '../profile-pages/use-mosaic-profile'; import { useUserProfilePages } from '../switcher/switcher.pages'; -import type { SwitcherMode } from '../switcher/switcher.types'; -import { userProfilePageIds } from './user-profile.layout'; +import { ORGANIZATION_PAGE_PREFIX, userProfilePageIds } from './user-profile.layout'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; import type { UserProfileViewProps } from './user-profile.view'; @@ -20,12 +20,6 @@ import { UserProfileProfilePanel } from './user-profile-profile-panel'; import { UserProfileSecurityPanel } from './user-profile-security-panel'; import { useUserProfileUserModel } from './user-profile-user.model'; -/** - * Which profiles the surface carries. `combined` adds the active organization's pages after the - * account's; `user` shows the account's alone, even when an organization is active. - */ -export type UserProfileMode = Exclude; - /** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */ export interface UserProfileProps extends Pick, Pick { @@ -47,14 +41,14 @@ export interface UserProfileProps */ apiKeysProps?: APIKeysPageProps; /** - * Which profiles the surface carries: the account's and the active organization's, or the - * account's alone. + * Whether the surface adds the active organization's pages after the account's. `false` shows the + * account's alone, even when an organization is active. * - * @default 'combined' + * @default `MosaicProvider`'s `profile.combined`, which is `true` unless set */ - mode?: UserProfileMode; + combined?: boolean; /** - * Options for the organization's pages in `combined` mode: your own pages and their order, the API + * Options for the organization's pages when `combined`: your own pages and their order, the API * keys page, and where leaving the organization lands. `pageOrder` names the organization's pages * without the `organization/` prefix, as in `['members', 'general']`. */ @@ -89,9 +83,9 @@ export interface UserProfileProps * ``` * * @example - * `mode='user'` leaves the active organization's pages out. + * `combined={false}` leaves the active organization's pages out. * ```tsx - * + * * ``` * * @example @@ -121,9 +115,36 @@ export interface UserProfileProps * ``` */ export function UserProfile(props: UserProfileProps) { + const { combined, userProfileProps, organizationProfileProps: providerOrganization } = useMosaicProfile(); + const { + combined: ownCombined = combined, + customPages = userProfileProps?.customPages, + pageOrder = userProfileProps?.pageOrder, + additionalOAuthScopes = userProfileProps?.additionalOAuthScopes, + apiKeysProps = userProfileProps?.apiKeysProps, + organizationProfileProps: { + customPages: organizationCustomPages = providerOrganization?.customPages, + pageOrder: organizationPageOrder = providerOrganization?.pageOrder, + apiKeysProps: organizationApiKeysProps = providerOrganization?.apiKeysProps, + afterLeaveOrganizationUrl = providerOrganization?.afterLeaveOrganizationUrl, + } = {}, + ...rest + } = props; + return ( ); @@ -131,7 +152,7 @@ export function UserProfile(props: UserProfileProps) { export function UserProfileSurface({ presentation, - mode = 'combined', + combined = true, organizationProfileProps, customPages, pageOrder, @@ -168,13 +189,13 @@ export function UserProfileSurface({ additionalOAuthScopes={additionalOAuthScopes} mode={presentation} > - {mode === 'user' ? ( - - ) : ( + {combined ? ( + ) : ( + )} ); @@ -205,9 +226,13 @@ function CombinedUserProfileView({ model.status === 'ready' ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } : undefined; + const pageIds = model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization }); const navigation = useProfilePagesController( initialPage, - model.status === 'loading' ? undefined : userProfilePageIds({ ...viewProps, organization }), + pageIds, + initialPage.startsWith(ORGANIZATION_PAGE_PREFIX) + ? pageIds?.find(id => id.startsWith(ORGANIZATION_PAGE_PREFIX)) + : undefined, ); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index dcd57d0fa44..9e3dc819612 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -35,22 +35,7 @@ export interface CustomProfilePage { label: string; /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */ path: string; - href?: never; icon?: ReactNode; /** Rendered as the page itself. */ content: ReactNode; } - -/** A row in the profile's navigation that leaves for somewhere else. */ -export interface CustomProfileLink { - /** Names the row in the profile's navigation. */ - label: string; - /** Identifies the row, for ordering. */ - path: string; - /** Where the row goes. */ - href: string; - icon?: ReactNode; - content?: never; -} - -export type CustomProfileItem = CustomProfilePage | CustomProfileLink; diff --git a/packages/mosaic/src/hooks/use-mosaic-experimental.ts b/packages/mosaic/src/hooks/use-mosaic-experimental.ts index 8acce7161c4..1073abde38d 100644 --- a/packages/mosaic/src/hooks/use-mosaic-experimental.ts +++ b/packages/mosaic/src/hooks/use-mosaic-experimental.ts @@ -3,8 +3,7 @@ import { createContext, useContext } from 'react'; export interface MosaicExperimental { /** * Opens Mosaic's own `UserProfile` and `OrganizationProfile` from `UserButton` and - * `OrganizationSwitcher`, instead of Clerk's profile modals. They skip `appearance` and the link - * entries of `customPages`. + * `OrganizationSwitcher`, instead of Clerk's profile modals. * * @default false */ diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index fb3e2beacba..a64f3111ea2 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -8,8 +8,9 @@ export type { OrganizationSwitcherProps } from './features/organization-switcher export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; export type { OrganizationProfileProps } from './features/organization-profile/organization-profile'; export { OrganizationProfile } from './features/organization-profile/organization-profile'; -export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; +export type { UserProfileProps } from './features/user-profile/user-profile'; export { UserProfile } from './features/user-profile/user-profile'; +export type { MosaicProfile } from './features/profile-pages/use-mosaic-profile'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 7f90a73d2c0..4c8a6748c14 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; +import type { MosaicProfile } from './features/profile-pages/use-mosaic-profile'; +import { MosaicProfileProvider } from './features/profile-pages/use-mosaic-profile'; import { userProfileModal } from './features/user-profile/user-profile.modal'; import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return'; import type { MosaicExperimental } from './hooks/use-mosaic-experimental'; @@ -26,6 +28,8 @@ export interface MosaicProviderProps { localization?: MosaicLocalization; /** Features still being tried out. Their names and behavior can change in any release. */ experimental?: MosaicExperimental; + /** The profile `UserButton`, `OrganizationSwitcher`, `UserProfile` and `OrganizationProfile` share: its shape, and the options it takes. */ + profile?: MosaicProfile; } // Exported Mosaic components annotate a React return type on purpose. The package sets @@ -37,6 +41,7 @@ export function MosaicProvider({ icons, localization, experimental, + profile = defaultProfile, }: MosaicProviderProps): React.ReactElement { const iconsValue = React.useMemo(() => icons ?? {}, [icons]); const localizationValue = React.useMemo(() => resolveLocalization(localization), [localization]); @@ -50,12 +55,14 @@ export function MosaicProvider({ - - - - {children} - - + + + + + {children} + + + @@ -64,4 +71,5 @@ export function MosaicProvider({ ); } +const defaultProfile: MosaicProfile = {}; const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal]; diff --git a/packages/mosaic/src/utils/permissions.ts b/packages/mosaic/src/utils/permissions.ts index 9836133848f..121b42224dd 100644 --- a/packages/mosaic/src/utils/permissions.ts +++ b/packages/mosaic/src/utils/permissions.ts @@ -5,4 +5,5 @@ export const ORGANIZATION_PERMISSIONS = { membershipsManage: 'org:sys_memberships:manage', profileManage: 'org:sys_profile:manage', profileDelete: 'org:sys_profile:delete', + ssoBypassManage: 'org:sys_entconns_sso_bypass:manage', } as const; diff --git a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx index 37670fef95f..b3321f42f96 100644 --- a/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/user-button/user-button/page.tsx @@ -30,7 +30,7 @@ export default function UserButtonLivePage() { ) : null}
- +
diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 70a536937e0..843650fd9fa 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -11,6 +11,7 @@ import { PlaygroundProvider } from './PlaygroundContext'; const docModules: Record> = { 'user-button': { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + 'profile-permutations': dynamic(() => import('../stories/profile-permutations.mdx')), }, 'user-profile': { 'user-profile': dynamic(() => import('../stories/user-profile.mdx')), @@ -52,6 +53,7 @@ const docModules: Record> = { 'organization-profile-api-keys-panel': dynamic(() => import('../stories/organization-profile-api-keys-panel.mdx')), }, misc: { + 'mosaic-provider': dynamic(() => import('../stories/mosaic-provider.mdx')), 'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')), 'create-organization': dynamic(() => import('../stories/create-organization.mdx')), 'organization-list': dynamic(() => import('../stories/organization-list.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 460afde6778..3fcb60718a5 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -162,6 +162,7 @@ import { } from '../stories/localization.stories'; import { Default as MenuComponentDefault, meta as menuComponentMeta } from '../stories/menu.component.stories'; import { meta as menuMeta } from '../stories/menu.stories'; +import { meta as mosaicProviderMeta } from '../stories/mosaic-provider.stories'; import { Default as OrganizationListDefault, Empty as OrganizationListEmpty, @@ -246,6 +247,7 @@ import { meta as profileComponentMeta, Transitions as ProfileTransitions, } from '../stories/profile.component.stories'; +import { meta as profilePermutationsMeta } from '../stories/profile-permutations.stories'; import { Default as ProviderButtonDefault, Many as ProviderButtonMany, @@ -1112,6 +1114,7 @@ const organizationProfileMembersPanelModule: StoryModule = { export const registry: StoryModule[] = [ // User Button userButtonModule, + { meta: profilePermutationsMeta }, // User Profile userProfileModule, // User Profile · Panels @@ -1144,6 +1147,7 @@ export const registry: StoryModule[] = [ organizationProfileProfileSectionModule, organizationProfileDangerSectionModule, // Misc + { meta: mosaicProviderMeta }, organizationSwitcherModule, createOrganizationModule, organizationListModule, diff --git a/packages/swingset/src/stories/mosaic-provider.mdx b/packages/swingset/src/stories/mosaic-provider.mdx new file mode 100644 index 00000000000..279948f7b4a --- /dev/null +++ b/packages/swingset/src/stories/mosaic-provider.mdx @@ -0,0 +1,87 @@ +# MosaicProvider + +`MosaicProvider` holds the settings shared by the Mosaic components below it: reading direction, icons, +localization, experimental features and the profile. It also renders the toasts and profile modals those +components open. + +## Usage + +Wrap the part of your app that uses Mosaic. Every prop is optional. + +```tsx +import { MosaicProvider, UserButton } from '@clerk/mosaic'; + + + +; +``` + +## Props + +| Prop | Type | Default | Description | +| -------------- | --------------------- | ------- | ----------------------------------------------------------------------------- | +| `children` | `ReactNode` | — | The components that use these settings. | +| `direction` | `'ltr' \| 'rtl'` | `'ltr'` | The direction arrow-key navigation follows. Set it to match the page's `dir`. | +| `icons` | `MosaicIconOverrides` | — | Glyph overrides by name. See [Icon](/components/icon). | +| `localization` | `MosaicLocalization` | — | Locale and strings. See [Localization](/localization/localization). | +| `experimental` | `MosaicExperimental` | — | Features still being tried out. Names and behavior can change in any release. | +| `profile` | `MosaicProfile` | — | The profile shared by the profile components, described below. | + +## Experimental + +| Option | Type | Default | Description | +| ------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `newProfiles` | `boolean` | `false` | `UserButton` and `OrganizationSwitcher` open Mosaic's own `UserProfile` and `OrganizationProfile` instead of Clerk's modals. | + +## Profile + +`profile` configures the profile that `UserButton`, `OrganizationSwitcher`, `UserProfile` and +`OrganizationProfile` share. `UserProfile` and `OrganizationProfile` use it for any prop they don't set +themselves. + +```tsx + + + + +``` + +`combined` on `MosaicProvider` decides what the profile shows, and `combined` on `UserButton` decides what +its menu lists. [Profile permutations](/user-button/profile-permutations) shows each pairing. + +`profile.combined` takes effect only with `experimental.newProfiles`. Without it, `UserButton` and +`OrganizationSwitcher` open Clerk's modals. + +| Option | Type | Default | Description | +| -------------------------- | -------------------------- | ------- | --------------------------------------------------------------------------------- | +| `combined` | `boolean` | `true` | Whether one profile carries the account's pages and the active organization's. | +| `userProfileUrl` | `string` | — | Your own user profile page. Setting it routes there instead of opening the modal. | +| `organizationProfileUrl` | `string` | — | Your own organization profile page. Setting it routes there instead of the modal. | +| `userProfileProps` | `ProfileUserProps` | — | Options for the user profile. | +| `organizationProfileProps` | `ProfileOrganizationProps` | — | Options for the organization profile, or its pages in a combined profile. | + +### `userProfileProps` + +| Option | Type | Default | Description | +| ----------------------- | ----------------------- | ------- | ------------------------------------------------------------ | +| `customPages` | `CustomProfilePage[]` | — | Your own pages inside the profile. | +| `pageOrder` | `string[]` | — | Navigation order, by built-in page id or custom page `path`. | +| `additionalOAuthScopes` | `AdditionalOAuthScopes` | — | Extra access to request from each OAuth provider. | +| `apiKeysProps` | `APIKeysPageProps` | — | Options for the API keys page. | + +### `organizationProfileProps` + +| Option | Type | Default | Description | +| --------------------------- | --------------------- | ------- | ------------------------------------------------------------ | +| `customPages` | `CustomProfilePage[]` | — | Your own pages inside the profile. | +| `pageOrder` | `string[]` | — | Navigation order, by built-in page id or custom page `path`. | +| `afterLeaveOrganizationUrl` | `string` | — | Where the user lands after leaving the organization. | +| `apiKeysProps` | `APIKeysPageProps` | — | Options for the API keys page. | diff --git a/packages/swingset/src/stories/mosaic-provider.stories.tsx b/packages/swingset/src/stories/mosaic-provider.stories.tsx new file mode 100644 index 00000000000..57be1422df8 --- /dev/null +++ b/packages/swingset/src/stories/mosaic-provider.stories.tsx @@ -0,0 +1,8 @@ +import type { StoryMeta } from '@/lib/types'; + +export const meta: StoryMeta = { + group: 'Misc', + title: 'MosaicProvider', + status: 'stable', + source: 'packages/mosaic/src/mosaic-provider.tsx', +}; diff --git a/packages/swingset/src/stories/profile-permutations.mdx b/packages/swingset/src/stories/profile-permutations.mdx new file mode 100644 index 00000000000..a94f17882ff --- /dev/null +++ b/packages/swingset/src/stories/profile-permutations.mdx @@ -0,0 +1,161 @@ +import * as Stories from './profile-permutations.stories'; + +# Profile permutations + +Every pairing of switchers and profile shape, running on fixtures. Each example shares one account and +organization across its triggers, so switching in one updates the other and the profile. + +`combined` defaults to `true` in two places: on `UserButton` it decides what the menu lists, and on +`MosaicProvider`'s `profile` it decides what the profile shows. `UserProfile` follows the provider unless +it sets `combined` itself. Profile URLs, custom pages, page order and where leaving an organization lands +go on the provider too: + +```tsx + + + + +``` + +| # | Switchers | Profile | Config | +| --- | ---------------------------------------------- | -------- | ---------------------- | +| 1 | `UserButton` combined | Combined | None | +| 2 | `UserButton` combined | Separate | Profile opt-out | +| 3 | `UserButton` user | Account | Menu + profile opt-out | +| 4 | `UserButton` user | Combined | Menu opt-out | +| 5 | `UserButton` user + `OrganizationSwitcher` | Separate | Menu + profile opt-out | +| 6 | `UserButton` user + `OrganizationSwitcher` | Combined | Menu opt-out | +| 7 | `OrganizationSwitcher` | Org | Profile opt-out | +| 8 | `OrganizationSwitcher` | Combined | None | +| 9 | `UserButton` combined + `OrganizationSwitcher` | Combined | None | + +Setting only one URL routes that target to your page, while the other still opens the modal with all of the +profile's pages. + +## 1. Combined button, combined profile + +Settings opens one profile. + +```tsx + +``` + + + +## 2. Combined button, separate profiles + +Settings offers the account and the organization as two targets. + +```tsx + + + +``` + + + +## 3. User button, account profile + +```tsx + + + +``` + + + +## 4. User button, combined profile + +No organization rows in the menu, but the profile carries the active organization's pages. + +```tsx + +``` + + + +## 5. User button and switcher, separate profiles + +```tsx + + + + +``` + + + +## 6. User button and switcher, combined profile + +Both triggers open the same profile. The button lands on the account's pages and the switcher on the +organization's. Switch to the personal account and the organization group leaves the profile. + +```tsx + + +``` + + + +## 7. Switcher, organization profile + +```tsx + + + +``` + + + +## 8. Switcher, combined profile + +```tsx + +``` + + + +## 9. Combined button and switcher + +Both menus list the organizations, and both open the combined profile. + +```tsx + + +``` + + diff --git a/packages/swingset/src/stories/profile-permutations.stories.tsx b/packages/swingset/src/stories/profile-permutations.stories.tsx new file mode 100644 index 00000000000..2e0075e5d31 --- /dev/null +++ b/packages/swingset/src/stories/profile-permutations.stories.tsx @@ -0,0 +1,220 @@ +import type { + OrganizationProfileModalContentProps, + OrganizationProfileModalOptions, +} from '@clerk/mosaic/features/organization-profile/organization-profile.modal.types'; +import { OrganizationProfileView } from '@clerk/mosaic/features/organization-profile/organization-profile.view'; +import type { SwitcherViewProps } from '@clerk/mosaic/features/switcher/switcher.view'; +import { SwitcherView } from '@clerk/mosaic/features/switcher/switcher.view'; +import type { + UserProfileModalContentProps, + UserProfileModalOptions, +} from '@clerk/mosaic/features/user-profile/user-profile.modal.types'; +import { UserProfileView } from '@clerk/mosaic/features/user-profile/user-profile.view'; +import { defineModalSurface } from '@clerk/mosaic/modals/define-modal-surface'; +import type { ModalSurfaces } from '@clerk/mosaic/modals/modal.types'; +import { ModalHost } from '@clerk/mosaic/modals/modal-host'; +import { useModal } from '@clerk/mosaic/modals/use-modal'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import { useOrganizationProfileFixture } from './fixtures/organization-profile'; +import { usePrototype } from './fixtures/switcher'; +import { useUserProfileFixture } from './fixtures/user-profile'; + +export const meta: StoryMeta = { + group: 'User Button', + title: 'ProfilePermutations', + label: 'Profile permutations', + status: 'wip', + source: 'packages/swingset/src/stories/profile-permutations.stories.tsx', +}; + +export function CombinedButtonCombinedProfile(_args: Record) { + return ( + + ); +} + +export function CombinedButtonSeparateProfiles(_args: Record) { + return ( + + ); +} + +export function UserButtonAccountProfile(_args: Record) { + return ( + + ); +} + +export function UserButtonCombinedProfile(_args: Record) { + return ( + + ); +} + +export function UserButtonAndSwitcherSeparateProfiles(_args: Record) { + return ( + + ); +} + +export function UserButtonAndSwitcherCombinedProfile(_args: Record) { + return ( + + ); +} + +export function SwitcherOrganizationProfile(_args: Record) { + return ( + + ); +} + +export function SwitcherCombinedProfile(_args: Record) { + return ( + + ); +} + +export function CombinedButtonAndSwitcher(_args: Record) { + return ( + + ); +} + +interface PermutationProps { + userButton?: 'combined' | 'user'; + organizationSwitcher?: boolean; + profile: 'combined' | 'separate'; +} + +function Permutation(props: PermutationProps) { + return ( + + + + ); +} + +function Triggers({ userButton, organizationSwitcher = false, profile }: PermutationProps) { + const prototype = usePrototype(); + const [openSurface, setOpenSurface] = useState<'userButton' | 'organizationSwitcher'>('userButton'); + const userProfile = useModal(fixtureUserProfileModal); + const organizationProfile = useModal(fixtureOrganizationProfileModal); + const combined = profile === 'combined' && Boolean(prototype.activeOrganization); + + const manageAccount = () => { + prototype.onManageAccount?.(); + userProfile.open({ combined }); + }; + const manageOrganization = () => { + prototype.onManageOrganization?.(); + if (profile === 'combined') { + userProfile.open({ combined, page: 'organization/general' }); + } else { + organizationProfile.open(); + } + }; + const surfaceProps = (surface: typeof openSurface): Omit => ({ + ...prototype, + open: prototype.open && openSurface === surface, + onOpenChange: open => { + setOpenSurface(surface); + prototype.onOpenChange?.(open); + }, + onManageAccount: manageAccount, + onManageOrganization: manageOrganization, + }); + + return ( +
+ {organizationSwitcher ? ( + + ) : null} + {userButton ? ( + + ) : null} +
+ ); +} + +const fixtureUserProfileModal = defineModalSurface({ + id: 'permutationUserProfile', + variant: 'profile', + load: () => Promise.resolve({ default: FixtureUserProfile }), +}); + +const fixtureOrganizationProfileModal = defineModalSurface({ + id: 'permutationOrganizationProfile', + variant: 'profile', + load: () => Promise.resolve({ default: FixtureOrganizationProfile }), +}); + +const surfaces: ModalSurfaces = [fixtureUserProfileModal, fixtureOrganizationProfileModal]; + +function FixtureUserProfile({ options }: UserProfileModalContentProps) { + const { pages } = useUserProfileFixture(); + const organization = useOrganizationProfileFixture(); + const [activePage, setActivePage] = useState(options?.page ?? 'account'); + + return ( + + ); +} + +function FixtureOrganizationProfile(_props: OrganizationProfileModalContentProps) { + const { activePage, setActivePage, pages } = useOrganizationProfileFixture(); + + return ( + + ); +} diff --git a/packages/swingset/src/stories/user-button.stories.tsx b/packages/swingset/src/stories/user-button.stories.tsx index b6267038785..da4761bf497 100644 --- a/packages/swingset/src/stories/user-button.stories.tsx +++ b/packages/swingset/src/stories/user-button.stories.tsx @@ -301,7 +301,7 @@ function FixtureUserProfile({ options }: UserProfileModalContentProps) { pages={pages} customPages={options?.customPages} pageOrder={options?.pageOrder} - organization={options?.mode === 'user' ? undefined : { pages: organization.pages }} + organization={options?.combined === false ? undefined : { pages: organization.pages }} onPageChange={setActivePage} /> ); @@ -364,7 +364,7 @@ function ProfileModalTriggers() {