diff --git a/.changeset/mosaic-user-button-profile-props.md b/.changeset/mosaic-user-button-profile-props.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-profile-props.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx index e9b14ca5463..0987261dfb1 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx @@ -87,8 +87,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { displayConfig: { afterSwitchSessionUrl: '/after-switch' }, authConfig: { singleSessionMode }, organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection }, - commerceSettings: { billing: { user: { enabled: false } } }, - apiKeysSettings: { user_api_keys_enabled: false }, + commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } }, + apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false }, + userSettings: { enterpriseSSO: { self_serve_sso: false } }, }, }), }; diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx index 7f4ee1ea062..6f6df6cf2ab 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.model.test.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-li import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView'; -import type { UserButtonModelOptions } from '../user-button.model'; +import type { UserButtonModalProps, UserButtonModelOptions } from '../user-button.model'; import { useUserButtonModel } from '../user-button.model'; interface FakeUser { @@ -188,8 +188,8 @@ afterEach(() => { vi.clearAllMocks(); }); -function Harness({ customPages, ...options }: UserButtonModelOptions & { customPages?: CustomPage[] } = {}) { - const c = useUserButtonModel(options, customPages); +function Harness({ modals, ...options }: UserButtonModelOptions & { modals?: UserButtonModalProps } = {}) { + const c = useUserButtonModel(options, modals); if (c.status !== 'ready') { return {c.status}; } @@ -640,6 +640,25 @@ describe('useUserButtonModel', () => { expect(decorateUrl).not.toHaveBeenCalled(); }); + it('prefers the signInUrl prop over the instance sign-in URL for add-account and task routing', async () => { + render(); + + fireEvent.click(screen.getByText('add-account')); + expect(navigate).toHaveBeenCalledWith('/join'); + + fireEvent.click(screen.getByText('switch')); + const navigateOnSetActive = setActive.mock.calls[0][0].navigate; + await act(async () => { + await navigateOnSetActive({ + session: { currentTask: { key: 'choose-organization' } }, + decorateUrl: (url: string) => url, + }); + }); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/join')); + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization')); + expect(navigate).not.toHaveBeenCalledWith(expect.stringContaining('/sign-in')); + }); + it('prefers the afterSwitchSessionUrl prop over the instance URL', async () => { render(); fireEvent.click(screen.getByText('switch')); @@ -723,13 +742,62 @@ describe('useUserButtonModel', () => { unmountIcon: vi.fn(), }, ]; - render(); + render(); fireEvent.click(screen.getByText('manage-account')); expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages }); }); + it('hands the user profile modal its OAuth scopes, API keys options, and appearance', () => { + const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] }; + const apiKeysProps = { showDescription: true, hide: false }; + const appearance = { variables: { colorPrimary: 'red' } }; + render(); + + fireEvent.click(screen.getByText('manage-account')); + + expect(openUserProfile).toHaveBeenCalledWith({ getContainer, additionalOAuthScopes, apiKeysProps, appearance }); + }); + + it('hands the organization profile modal its custom pages, appearance, and where leaving lands', () => { + const customPages: CustomPage[] = [{ label: 'members' }]; + const appearance = { variables: { colorPrimary: 'red' } }; + render( + , + ); + + fireEvent.click(screen.getByText('manage-org')); + + expect(openOrganizationProfile).toHaveBeenCalledWith({ + getContainer, + customPages, + appearance, + afterLeaveOrganizationUrl: '/left', + }); + }); + + // clerk-js fills the `:param` template itself once the organization exists. + it('hands the create-organization modal where creating lands and whether to skip inviting', () => { + render( + , + ); + + fireEvent.click(screen.getByText('create-org')); + + expect(openCreateOrganization).toHaveBeenCalledWith({ + getContainer, + afterCreateOrganizationUrl: '/orgs/:slug', + skipInvitationScreen: true, + }); + }); + // A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass // alongside it. The two are resolved apart, so routing one profile leaves the other a modal. it('navigates to a profile URL when one is given, and only for that profile', () => { diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx index a00addf3d9f..6bd63a5a497 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.pages.test.tsx @@ -1,10 +1,30 @@ +import type * as SharedReact from '@clerk/shared/react'; import type { CustomPage } from '@clerk/shared/types'; -import { act, render, screen, within } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { act, render, renderHook, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CustomProfileItem } from '../../user-profile/user-profile.types'; import type { CustomPagesOptions } from '../user-button.pages'; -import { useCustomPages } from '../user-button.pages'; +import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from '../user-button.pages'; + +let selfServeSSOEnabled: boolean; +let environment: { + commerceSettings: { billing: { user: { enabled: boolean }; organization: { enabled: boolean } } }; + apiKeysSettings: { user_api_keys_enabled: boolean; orgs_api_keys_enabled: boolean }; + userSettings: { enterpriseSSO: { self_serve_sso: boolean } }; +}; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useClerk: () => ({ organization: { selfServeSSOEnabled } }), + }; +}); + +vi.mock('../../hooks/useMosaicEnvironment', () => ({ + useMosaicEnvironment: () => environment, +})); // The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to // `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the @@ -45,6 +65,54 @@ const docs: CustomProfileItem = { beforeEach(() => { emitted = undefined; + selfServeSSOEnabled = false; + environment = { + commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } }, + apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false }, + userSettings: { enterpriseSSO: { self_serve_sso: false } }, + }; +}); + +describe('useUserProfilePages', () => { + it('lists the pages every instance has', () => { + expect(renderHook(() => useUserProfilePages()).result.current).toEqual(['account', 'security']); + }); + + it('adds billing and API keys once the instance turns them on', () => { + environment.commerceSettings.billing.user.enabled = true; + environment.apiKeysSettings.user_api_keys_enabled = true; + expect(renderHook(() => useUserProfilePages()).result.current).toEqual([ + 'account', + 'security', + 'billing', + 'apiKeys', + ]); + }); +}); + +describe('useOrganizationProfilePages', () => { + it('lists the pages every instance has', () => { + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members']); + }); + + it('adds billing and API keys once the instance turns them on', () => { + environment.commerceSettings.billing.organization.enabled = true; + environment.apiKeysSettings.orgs_api_keys_enabled = true; + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual([ + 'general', + 'members', + 'billing', + 'apiKeys', + ]); + }); + + it('adds security only when the instance and the active organization both allow self-serve SSO', () => { + environment.userSettings.enterpriseSSO.self_serve_sso = true; + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members']); + + selfServeSSOEnabled = true; + expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members', 'security']); + }); }); describe('useCustomPages', () => { diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx index 58af0503188..f0975997677 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx @@ -6,26 +6,22 @@ import type { UserButtonController } from '../user-button.controller'; let controller: UserButtonController; -vi.mock('../user-button.model', () => ({ - useUserButtonModel: () => ({ status: 'loading' }), +const { useUserButtonModel, useCustomPages } = vi.hoisted(() => ({ + useUserButtonModel: vi.fn(() => ({ status: 'loading' })), + useCustomPages: vi.fn(), })); +vi.mock('../user-button.model', () => ({ useUserButtonModel })); + vi.mock('../user-button.controller', () => ({ useUserButtonController: () => controller, })); -// The custom pages outlive the popup, so the wrapper renders their portals in every state. +// The two bridges are told apart by the built-in page list each was given. vi.mock('../user-button.pages', () => ({ - useUserProfilePages: () => [], - useCustomPages: () => ({ - customPages: undefined, - portals: [ - , - ], - }), + useUserProfilePages: () => ['account'], + useOrganizationProfilePages: () => ['general'], + useCustomPages, })); // The wrapper's own job is which of the three controller states renders what, so the surface is @@ -53,6 +49,16 @@ function ready(): UserButtonController { describe('UserButton', () => { beforeEach(() => { controller = { status: 'loading' }; + useUserButtonModel.mockClear(); + useCustomPages.mockImplementation(({ builtInPages }: { builtInPages: readonly string[] }) => ({ + customPages: [{ label: `${builtInPages[0]}-page` }], + portals: [ + , + ], + })); }); it('stands the fallback in while Clerk is still answering', () => { @@ -83,17 +89,61 @@ describe('UserButton', () => { expect(screen.queryByTestId('view')).not.toBeInTheDocument(); }); - // The profile can be open in clerk-js's own root while the button itself has nothing to render. - it('keeps the custom page portals mounted in every state', () => { + it('keeps the custom page portals of both profiles mounted in every state', () => { const { rerender } = render(); - expect(screen.getByTestId('portal')).toBeInTheDocument(); + expect(screen.getByTestId('account-portal')).toBeInTheDocument(); + expect(screen.getByTestId('general-portal')).toBeInTheDocument(); controller = { status: 'hidden' }; rerender(); - expect(screen.getByTestId('portal')).toBeInTheDocument(); + expect(screen.getByTestId('account-portal')).toBeInTheDocument(); + expect(screen.getByTestId('general-portal')).toBeInTheDocument(); controller = ready(); rerender(); - expect(screen.getByTestId('portal')).toBeInTheDocument(); + expect(screen.getByTestId('account-portal')).toBeInTheDocument(); + expect(screen.getByTestId('general-portal')).toBeInTheDocument(); + }); + + it('hands the model each profile modal its props, and keeps the routing options apart', () => { + const appearance = { variables: { colorPrimary: 'red' } }; + const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] }; + const apiKeysProps = { showDescription: true }; + render( + , + ); + + expect(useUserButtonModel).toHaveBeenCalledWith( + { afterLeaveOrganizationUrl: '/left' }, + { + userProfile: { customPages: [{ label: 'account-page' }], additionalOAuthScopes, apiKeysProps, appearance }, + organizationProfile: { customPages: [{ label: 'general-page' }], appearance }, + }, + ); + }); + + it('bridges each profile its own custom pages, ordered against its own built-in pages', () => { + const page = { label: 'Usage', path: 'usage', content:

Usage

}; + render( + , + ); + + expect(useCustomPages).toHaveBeenCalledWith({ + items: [page], + order: ['usage', 'account'], + builtInPages: ['account'], + }); + expect(useCustomPages).toHaveBeenCalledWith({ + items: [page], + order: ['members', 'usage'], + builtInPages: ['general'], + }); }); }); diff --git a/packages/ui/src/mosaic/user-button/user-button.model.tsx b/packages/ui/src/mosaic/user-button/user-button.model.tsx index 20404039508..18973176d74 100644 --- a/packages/ui/src/mosaic/user-button/user-button.model.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.model.tsx @@ -1,7 +1,14 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react'; -import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types'; +import type { + OrganizationProfileModalProps, + OrganizationResource, + OrganizationSwitcherProps, + UserButtonProps as ClerkUserButtonProps, + UserProfileModalProps, + UserResource, +} from '@clerk/shared/types'; import { populateParamFromObject } from '../../contexts/utils'; import { useOrganizationListInView } from '../../hooks/useOrganizationListInView'; @@ -39,9 +46,6 @@ export type UserButtonModel = organizationsEnabled: boolean; }); -// Mirrors ``: a URL, a `:token` template resolved against the entity, or a builder. -type AfterSelectUrl = ((entity: T) => string) | string; - /** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */ type UserProfileMode = | { userProfileUrl: string; userProfileMode?: 'navigation' } @@ -57,20 +61,28 @@ type CreateOrganizationMode = export type UserButtonModelOptions = UserProfileMode & OrganizationProfileMode & - CreateOrganizationMode & { - afterSelectOrganizationUrl?: AfterSelectUrl; - /** Where selecting the personal workspace lands. Resolved against the user, not an organization. */ - afterSelectPersonalUrl?: AfterSelectUrl; - /** Where switching account lands. The instance URL is used when this is omitted. */ - afterSwitchSessionUrl?: string; - /** - * Leaves the personal workspace out. An instance that forces organization selection withholds it - * either way, so this cannot opt back in. - */ - hidePersonal?: boolean; - }; + CreateOrganizationMode & + Pick & + Pick< + OrganizationSwitcherProps, + | 'afterSelectOrganizationUrl' + | 'afterSelectPersonalUrl' + | 'afterCreateOrganizationUrl' + | 'afterLeaveOrganizationUrl' + | 'skipInvitationScreen' + | 'hidePersonal' + >; -function resolveAfterSelectUrl(config: AfterSelectUrl | undefined, entity: T): string | undefined { +/** Props forwarded to the profile modals this button opens. */ +export interface UserButtonModalProps { + userProfile?: Pick; + organizationProfile?: Pick; +} + +function resolveAfterUrl( + config: ((entity: T) => string) | string | undefined, + entity: T, +): string | undefined { if (typeof config === 'function') { return config(entity); } @@ -124,14 +136,10 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession { } /** - * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's - * DOM-callback form. The wrapper owns that conversion because it is the layer that can render - * the portals behind it, so they arrive here ready to forward and stay out of the public options. + * @param modals - Props forwarded to the profile modals. Custom pages arrive already bridged, since only + * the wrapper can render the portals behind them. */ -export function useUserButtonModel( - options?: UserButtonModelOptions, - userProfileCustomPages?: CustomPage[], -): UserButtonModel { +export function useUserButtonModel(options?: UserButtonModelOptions, modals?: UserButtonModalProps): UserButtonModel { 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. @@ -143,6 +151,7 @@ export function useUserButtonModel( // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = usePortalRoot(); const environment = useMosaicEnvironment(); + 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. @@ -153,7 +162,7 @@ export function useUserButtonModel( const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }), + openModal: () => clerk.openUserProfile({ getContainer, ...modals?.userProfile }), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -161,7 +170,12 @@ export function useUserButtonModel( const manageOrganization = openOrNavigate({ url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, - openModal: () => clerk.openOrganizationProfile({ getContainer }), + openModal: () => + clerk.openOrganizationProfile({ + getContainer, + ...modals?.organizationProfile, + afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, + }), buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); @@ -169,7 +183,12 @@ export function useUserButtonModel( const createOrganization = openOrNavigate({ url: options?.createOrganizationUrl, mode: options?.createOrganizationMode, - openModal: () => clerk.openCreateOrganization({ getContainer }), + openModal: () => + clerk.openCreateOrganization({ + getContainer, + afterCreateOrganizationUrl: options?.afterCreateOrganizationUrl, + skipInvitationScreen: options?.skipInvitationScreen, + }), buildUrl: () => clerk.buildCreateOrganizationUrl(), navigate: router.navigate, }); @@ -231,10 +250,10 @@ export function useUserButtonModel( const afterSelectUrl = (organizationId: string | null): string | undefined => { if (!organizationId) { - return resolveAfterSelectUrl(options?.afterSelectPersonalUrl, user); + return resolveAfterUrl(options?.afterSelectPersonalUrl, user); } const selected = membershipData.find(m => m.organization.id === organizationId)?.organization; - return selected ? resolveAfterSelectUrl(options?.afterSelectOrganizationUrl, selected) : undefined; + return selected ? resolveAfterUrl(options?.afterSelectOrganizationUrl, selected) : undefined; }; return { @@ -266,7 +285,7 @@ export function useUserButtonModel( navigate: async ({ session, decorateUrl }) => { const task = session.currentTask; if (task) { - await router.navigate(buildTaskUrl(task, { base: clerk.buildSignInUrl() })); + await router.navigate(buildTaskUrl(task, { base: signInUrl() })); return; } const afterSwitchSessionUrl = options?.afterSwitchSessionUrl || displayConfig.afterSwitchSessionUrl; @@ -292,7 +311,7 @@ export function useUserButtonModel( onInviteMembers: canInviteMembers ? () => clerk.openInviteMembers({ getContainer }) : undefined, // Covers both restricted instances and users at their creation limit. onCreateOrganization: user.createOrganizationEnabled ? createOrganization : undefined, - onAddAccount: singleSessionMode ? undefined : () => void router.navigate(clerk.buildSignInUrl()), + onAddAccount: singleSessionMode ? undefined : () => void router.navigate(signInUrl()), onAcceptSuggestion: async suggestionId => { const suggestion = suggestionData.find(s => s.id === suggestionId); try { diff --git a/packages/ui/src/mosaic/user-button/user-button.pages.tsx b/packages/ui/src/mosaic/user-button/user-button.pages.tsx index 644554b45c3..e55d0a888d9 100644 --- a/packages/ui/src/mosaic/user-button/user-button.pages.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.pages.tsx @@ -1,4 +1,7 @@ import { + disabledOrganizationAPIKeysFeature, + disabledOrganizationBillingFeature, + disabledSelfServeSSOFeature, disabledUserAPIKeysFeature, disabledUserBillingFeature, } from '@clerk/shared/internal/clerk-js/componentGuards'; @@ -13,6 +16,9 @@ import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; import { applyOrder } from '../utils/apply-order'; +/** A built-in OrganizationProfile page, by the id its navigation uses. */ +export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security'; + /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned * off. @@ -33,6 +39,24 @@ export function useUserProfilePages(): UserProfilePageId[] { return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]); } +/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */ +export function useOrganizationProfilePages(): OrganizationProfilePageId[] { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + const pages: OrganizationProfilePageId[] = ['general', 'members']; + if (!disabledOrganizationBillingFeature(clerk, environment)) { + pages.push('billing'); + } + if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { + pages.push('apiKeys'); + } + if (!disabledSelfServeSSOFeature(clerk, environment)) { + pages.push('security'); + } + return pages; +} + export interface CustomPagesOptions { /** Pages and links of the consumer's own. */ items: CustomProfileItem[] | undefined; diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx index 9e878438bf2..df832fd1b93 100644 --- a/packages/ui/src/mosaic/user-button/user-button.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.tsx @@ -1,36 +1,59 @@ 'use client'; +import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types'; import { useUserButtonController } from './user-button.controller'; import type { UserButtonModelOptions } from './user-button.model'; import { useUserButtonModel } from './user-button.model'; -import { useCustomPages, useUserProfilePages } from './user-button.pages'; +import type { OrganizationProfilePageId } from './user-button.pages'; +import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './user-button.pages'; import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types'; import type { UserButtonTriggerProps } from './user-button.view'; import { UserButtonView } from './user-button.view'; -/** Configures the UserProfile this button opens. */ -export interface UserButtonUserProfileProps { - /** Pages and links of your own, added to the profile's navigation. */ +/** What a profile opened by `` takes beyond the profile component's own props. */ +export interface UserButtonProfilePages { + /** + * Provide custom pages and links to be rendered inside the profile. + */ customPages?: CustomProfileItem[]; /** - * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's - * `path`. Anything left out follows the pages named here. The first page is the one the profile - * opens on, so it cannot be a link. + * 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?: (UserProfilePageId | (string & {}))[]; + pageOrder?: (PageId | (string & {}))[]; } -/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profile it opens. */ -// TODO: Possibly missing, verify these before GA: -// defaultOpen, signInUrl, userProfileProps.additionalOAuthScopes, userProfileProps.apiKeysProps, userProfileProps.appearance, customMenuItems open/startPath, afterCreateOrganizationUrl, skipInvitationScreen, afterLeaveOrganizationUrl, organizationProfileProps +/** Options for the underlying `` component. */ +export interface UserButtonUserProfileProps + extends + UserButtonProfilePages, + Pick {} + +/** Options for the underlying `` component. */ +export interface UserButtonOrganizationProfileProps + extends UserButtonProfilePages, Pick {} + +/** Everything `` takes: profile routing, trigger content, the app's own menu rows, and the profiles it opens. */ export type UserButtonProps = UserButtonModelOptions & UserButtonTriggerProps & UserButtonMenuProps & UserButtonModeProps & { + /** + * Specify options for the underlying component. + * e.g., + */ userProfileProps?: UserButtonUserProfileProps; + /** + * Specify options for the underlying component. + * e.g., + */ + organizationProfileProps?: UserButtonOrganizationProfileProps; /** * Fallback while loading. * @@ -85,7 +108,7 @@ export type UserButtonProps = UserButtonModelOptions & * ``` * * @example - * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your + * `customPages` adds your own pages to either profile this button opens; `customMenuItems` adds your * own rows to the foot of the menu, each one either an `onClick` action or an `href` link. * ```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() }, @@ -108,21 +135,41 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null { mode, modePriority, userProfileProps, + organizationProfileProps, customMenuItems, menuItemOrder, fallback, ...options } = props; - // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered - // from here. They have to outlive the popover that opened it, and the button's own data with it, - // which is why they hang off the wrapper rather than anything the popover renders. - const builtInPages = useUserProfilePages(); - const { customPages, portals } = useCustomPages({ + // The portals must outlive the popover, so custom pages are bridged here rather than inside it. + const userProfile = useCustomPages({ items: userProfileProps?.customPages, order: userProfileProps?.pageOrder, - builtInPages, + builtInPages: useUserProfilePages(), + }); + const organizationProfile = useCustomPages({ + items: organizationProfileProps?.customPages, + order: organizationProfileProps?.pageOrder, + builtInPages: useOrganizationProfilePages(), + }); + const portals = ( + <> + {userProfile.portals} + {organizationProfile.portals} + + ); + const model = useUserButtonModel(options, { + userProfile: { + customPages: userProfile.customPages, + additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, + apiKeysProps: userProfileProps?.apiKeysProps, + appearance: userProfileProps?.appearance, + }, + organizationProfile: { + customPages: organizationProfile.customPages, + appearance: organizationProfileProps?.appearance, + }, }); - const model = useUserButtonModel(options, customPages); const controller = useUserButtonController(model, { mode, modePriority, customMenuItems, menuItemOrder }); if (controller.status === 'loading') {