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 ;
}
@@ -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') {