diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md
new file mode 100644
index 00000000000..d099191bd35
--- /dev/null
+++ b/.changeset/proud-ways-lie.md
@@ -0,0 +1,39 @@
+---
+'@clerk/clerk-js': minor
+'@clerk/clerk-react': minor
+'@clerk/types': minor
+---
+
+Introduce customization in `UserProfile` and `OrganizationProfile`
+
+The `` component now allows the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `` component, and external links can be added using the `` component. The default routes, such as `Account` and `Security`, can be reordered.
+
+Example React API usage:
+
+```tsx
+
+ }>
+
+
+ } />
+
+
+
+```
+Custom pages and links should be provided as children using the `` and `` components when using the `UserButton` component.
+
+The `` component now supports the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `` component, and external links can be added using the `` component. The default routes, such as `Members` and `Settings`, can be reordered.
+
+Example React API usage:
+
+```tsx
+
+ }>
+
+
+ } />
+
+
+
+```
+Custom pages and links should be provided as children using the `` and `` components when using the `OrganizationSwitcher` component.
diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json
index 4703390f128..9ff28763793 100644
--- a/packages/clerk-js/bundlewatch.config.json
+++ b/packages/clerk-js/bundlewatch.config.json
@@ -2,7 +2,7 @@
"files": [
{ "path": "./dist/clerk.browser.js", "maxSize": "62kB" },
{ "path": "./dist/clerk.headless.js", "maxSize": "43kB" },
- { "path": "./dist/ui-common*.js", "maxSize": "75KB" },
+ { "path": "./dist/ui-common*.js", "maxSize": "76KB" },
{ "path": "./dist/vendors*.js", "maxSize": "70KB" },
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
{ "path": "./dist/impersonationfab*.js", "maxSize": "5KB" },
diff --git a/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx b/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx
new file mode 100644
index 00000000000..e134a08f89e
--- /dev/null
+++ b/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx
@@ -0,0 +1,28 @@
+import { Col, descriptors } from '../customizables';
+import { CardAlert, NavbarMenuButtonRow, useCardState, withCardStateProvider } from '../elements';
+import type { CustomPageContent } from '../utils';
+import { ExternalElementMounter } from '../utils';
+
+export const CustomPageContentContainer = withCardStateProvider(
+ ({ mount, unmount }: Omit) => {
+ const card = useCardState();
+ return (
+
+ {card.error}
+
+
+
+
+
+ );
+ },
+);
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
index 9306a562e48..7832af2bce0 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx
@@ -1,31 +1,14 @@
import React from 'react';
-import { useCoreOrganization } from '../../contexts';
-import type { NavbarRoute } from '../../elements';
+import { useCoreOrganization, useOrganizationProfileContext } from '../../contexts';
import { Breadcrumbs, NavBar, NavbarContextProvider, OrganizationPreview } from '../../elements';
-import { CogFilled, User } from '../../icons';
-import { localizationKeys } from '../../localization';
import type { PropsOfComponent } from '../../styledSystem';
-const organizationProfileRoutes: NavbarRoute[] = [
- {
- name: localizationKeys('organizationProfile.start.headerTitle__members'),
- id: 'members',
- icon: User,
- path: '/',
- },
- {
- name: localizationKeys('organizationProfile.start.headerTitle__settings'),
- id: 'settings',
- icon: CogFilled,
- path: 'organization-settings',
- },
-];
-
export const OrganizationProfileNavbar = (
props: React.PropsWithChildren, 'contentRef'>>,
) => {
const { organization } = useCoreOrganization();
+ const { pages } = useOrganizationProfileContext();
if (!organization) {
return null;
@@ -41,7 +24,7 @@ export const OrganizationProfileNavbar = (
sx={t => ({ margin: `0 0 ${t.space.$4} ${t.space.$2}` })}
/>
}
- routes={organizationProfileRoutes}
+ routes={pages.routes}
contentRef={props.contentRef}
/>
{props.children}
@@ -49,19 +32,12 @@ export const OrganizationProfileNavbar = (
);
};
-const pageToRootNavbarRouteMap = {
- 'invite-members': organizationProfileRoutes.find(r => r.id === 'members'),
- domain: organizationProfileRoutes.find(r => r.id === 'settings'),
- profile: organizationProfileRoutes.find(r => r.id === 'settings'),
- leave: organizationProfileRoutes.find(r => r.id === 'settings'),
- delete: organizationProfileRoutes.find(r => r.id === 'settings'),
-};
-
export const OrganizationProfileBreadcrumbs = (props: Pick, 'title'>) => {
+ const { pages } = useOrganizationProfileContext();
return (
);
};
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
index f84c667bcdc..ed0c9c4cecf 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx
@@ -1,4 +1,7 @@
-import { Gate } from '../../common/Gate';
+import { Gate } from '../../common';
+import { CustomPageContentContainer } from '../../common/CustomPageContentContainer';
+import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants';
+import { useOrganizationProfileContext } from '../../contexts';
import { ProfileCardContent } from '../../elements';
import { Route, Switch } from '../../router';
import type { PropsOfComponent } from '../../styledSystem';
@@ -13,100 +16,125 @@ import { VerifiedDomainPage } from './VerifiedDomainPage';
import { VerifyDomainPage } from './VerifyDomainPage';
export const OrganizationProfileRoutes = (props: PropsOfComponent) => {
+ const { pages } = useOrganizationProfileContext();
+ const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS;
+ const isSettingsPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS;
+
+ const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
+ const shouldFirstCustomItemBeOnRoot = !isSettingsPageRoot && !isMembersPageRoot && index === 0;
+ return (
+
+
+
+ );
+ });
+
return (
-
-
-
-
-
-
-
-
+
+ {customPageRoutesWithContents}
+
+
-
+
-
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
+
+
+
+
+
+
+
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
);
};
diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx
index 18cb3efe8d1..3b654a948e1 100644
--- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx
+++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx
@@ -1,3 +1,4 @@
+import type { CustomPage } from '@clerk/types';
import { describe, it } from '@jest/globals';
import React from 'react';
@@ -19,4 +20,37 @@ describe('OrganizationProfile', () => {
expect(getByText('Members')).toBeDefined();
expect(getByText('Settings')).toBeDefined();
});
+
+ it('includes custom nav items', async () => {
+ const { wrapper, props } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: ['Org1'] });
+ });
+
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'ExternalLink',
+ url: '/link',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+
+ props.setProps({ customPages });
+
+ const { getByText } = render(, { wrapper });
+ expect(getByText('Org1')).toBeDefined();
+ expect(getByText('Members')).toBeDefined();
+ expect(getByText('Settings')).toBeDefined();
+ expect(getByText('Custom1')).toBeDefined();
+ expect(getByText('ExternalLink')).toBeDefined();
+ });
});
diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx
index 08bf8ac6e67..0b05702a4e7 100644
--- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx
+++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx
@@ -1,33 +1,17 @@
import React from 'react';
-import type { NavbarRoute } from '../../elements';
+import { useUserProfileContext } from '../../contexts';
import { Breadcrumbs, NavBar, NavbarContextProvider } from '../../elements';
-import { TickShield, User } from '../../icons';
-import { localizationKeys } from '../../localization';
import type { PropsOfComponent } from '../../styledSystem';
-const userProfileRoutes: NavbarRoute[] = [
- {
- name: localizationKeys('userProfile.start.headerTitle__account'),
- id: 'account',
- icon: User,
- path: '/',
- },
- {
- name: localizationKeys('userProfile.start.headerTitle__security'),
- id: 'security',
- icon: TickShield,
- path: '',
- },
-];
-
export const UserProfileNavbar = (
props: React.PropsWithChildren, 'contentRef'>>,
) => {
+ const { pages } = useUserProfileContext();
return (
{props.children}
@@ -35,22 +19,12 @@ export const UserProfileNavbar = (
);
};
-const pageToRootNavbarRouteMap = {
- profile: userProfileRoutes.find(r => r.id === 'account'),
- 'email-address': userProfileRoutes.find(r => r.id === 'account'),
- 'phone-number': userProfileRoutes.find(r => r.id === 'account'),
- 'connected-account': userProfileRoutes.find(r => r.id === 'account'),
- 'web3-wallet': userProfileRoutes.find(r => r.id === 'account'),
- username: userProfileRoutes.find(r => r.id === 'account'),
- 'multi-factor': userProfileRoutes.find(r => r.id === 'security'),
- password: userProfileRoutes.find(r => r.id === 'security'),
-};
-
export const UserProfileBreadcrumbs = (props: Pick, 'title'>) => {
+ const { pages } = useUserProfileContext();
return (
);
};
diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx
index 0e5b2401bcf..a9b293e3a30 100644
--- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx
+++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx
@@ -1,3 +1,6 @@
+import { CustomPageContentContainer } from '../../common/CustomPageContentContainer';
+import { USER_PROFILE_NAVBAR_ROUTE_ID } from '../../constants';
+import { useUserProfileContext } from '../../contexts';
import { ProfileCardContent } from '../../elements';
import { Route, Switch } from '../../router';
import type { PropsOfComponent } from '../../styledSystem';
@@ -22,90 +25,142 @@ import { UsernamePage } from './UsernamePage';
import { Web3Page } from './Web3Page';
export const UserProfileRoutes = (props: PropsOfComponent) => {
+ const { pages } = useUserProfileContext();
+ const isAccountPageRoot =
+ pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT ||
+ pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY;
+
+ const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
+ const shouldFirstCustomItemBeOnRoot = !isAccountPageRoot && index === 0;
+ return (
+
+
+
+ );
+ });
+
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {customPageRoutesWithContents}
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
+ {/**/}
+
+
-
-
-
-
-
-
-
-
-
+
+
-
+
-
-
-
-
-
- {/**/}
-
-
-
- {/**/}
-
-
-
+
+
);
};
diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx
index 9f6ca54a566..f7ee22f59c1 100644
--- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx
+++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx
@@ -1,10 +1,11 @@
+import type { CustomPage } from '@clerk/types';
import { describe, it } from '@jest/globals';
import React from 'react';
import { bindCreateFixtures, render, screen } from '../../../../testUtils';
import { UserProfile } from '../UserProfile';
-const { createFixtures } = bindCreateFixtures('SignIn');
+const { createFixtures } = bindCreateFixtures('UserProfile');
describe('UserProfile', () => {
describe('Navigation', () => {
@@ -19,5 +20,39 @@ describe('UserProfile', () => {
const securityElements = screen.getAllByText(/Security/i);
expect(securityElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true);
});
+
+ it('includes custom nav items', async () => {
+ const { wrapper, props } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.dev'] });
+ });
+
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'ExternalLink',
+ url: '/link',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+
+ props.setProps({ customPages });
+ render(, { wrapper });
+ const accountElements = screen.getAllByText(/Account/i);
+ expect(accountElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true);
+ const securityElements = screen.getAllByText(/Security/i);
+ expect(securityElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true);
+ const customElements = screen.getAllByText(/Custom1/i);
+ expect(customElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true);
+ const externalElements = screen.getAllByText(/ExternalLink/i);
+ expect(externalElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true);
+ });
});
});
diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts
new file mode 100644
index 00000000000..17d1dea5121
--- /dev/null
+++ b/packages/clerk-js/src/ui/constants.ts
@@ -0,0 +1,9 @@
+export const USER_PROFILE_NAVBAR_ROUTE_ID = {
+ ACCOUNT: 'account',
+ SECURITY: 'security',
+};
+
+export const ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID = {
+ MEMBERS: 'members',
+ SETTINGS: 'settings',
+};
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index 07ef3640e9c..b7bbacb2775 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -5,6 +5,7 @@ import React, { useMemo } from 'react';
import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_INITIAL_VALUE_KEYS } from '../../core/constants';
import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils';
import { useCoreClerk, useEnvironment, useOptions } from '../contexts';
+import type { NavbarRoute } from '../elements';
import type { ParsedQs } from '../router';
import { useRouter } from '../router';
import type {
@@ -18,6 +19,8 @@ import type {
UserButtonCtx,
UserProfileCtx,
} from '../types';
+import type { CustomPageContent } from '../utils';
+import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../utils';
const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ });
@@ -184,24 +187,31 @@ export const useSignInContext = (): SignInContextType => {
};
};
+type PagesType = {
+ routes: NavbarRoute[];
+ contents: CustomPageContent[];
+ pageToRootNavbarRouteMap: Record;
+};
+
export type UserProfileContextType = UserProfileCtx & {
queryParams: ParsedQs;
authQueryString: string | null;
+ pages: PagesType;
};
-// UserProfile does not accept any props except for
-// `routing` and `path`
-// TODO: remove if not needed during the components v2 overhaul
export const useUserProfileContext = (): UserProfileContextType => {
- const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx;
+ const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx;
const { queryParams } = useRouter();
if (componentName !== 'UserProfile') {
throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.');
}
+ const pages = createUserProfileCustomPages(customPages || []);
+
return {
...ctx,
+ pages,
componentName,
queryParams,
authQueryString: '',
@@ -398,8 +408,13 @@ export const useOrganizationListContext = () => {
};
};
-export const useOrganizationProfileContext = () => {
- const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx;
+export type OrganizationProfileContextType = OrganizationProfileCtx & {
+ pages: PagesType;
+ navigateAfterLeaveOrganization: () => Promise;
+};
+
+export const useOrganizationProfileContext = (): OrganizationProfileContextType => {
+ const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx;
const { navigate } = useRouter();
const { displayConfig } = useEnvironment();
@@ -407,11 +422,14 @@ export const useOrganizationProfileContext = () => {
throw new Error('Clerk: useOrganizationProfileContext called outside OrganizationProfile.');
}
+ const pages = createOrganizationProfileCustomPages(customPages || []);
+
const navigateAfterLeaveOrganization = () =>
navigate(ctx.afterLeaveOrganizationUrl || displayConfig.afterLeaveOrganizationUrl);
return {
...ctx,
+ pages,
navigateAfterLeaveOrganization,
componentName,
};
diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx
index 4f348f2c4db..0148b538c25 100644
--- a/packages/clerk-js/src/ui/elements/Navbar.tsx
+++ b/packages/clerk-js/src/ui/elements/Navbar.tsx
@@ -1,5 +1,4 @@
import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react';
-import type { NavbarItemId } from '@clerk/types';
import React, { useEffect } from 'react';
import type { LocalizationKey } from '../customizables';
@@ -27,10 +26,11 @@ export const NavbarContextProvider = (props: React.PropsWithChildren `#cl-section-${id}`;
export const NavBar = (props: NavBarProps) => {
const { contentRef, routes, header } = props;
- const [activeId, setActiveId] = React.useState(routes[0]['id']);
+ const [activeId, setActiveId] = React.useState('');
const { close } = useNavbarContext();
const { navigate } = useRouter();
const { navigateToFlowStart } = useNavigateToFlowStart();
const { t } = useLocalizations();
const router = useRouter();
+ const handleNavigate = (route: NavbarRoute) => {
+ if (route?.external) {
+ return () => navigate(route.path);
+ } else {
+ return () => navigateAndScroll(route);
+ }
+ };
+
const navigateAndScroll = async (route: NavbarRoute) => {
if (contentRef.current) {
setActiveId(route.id);
@@ -74,7 +82,7 @@ export const NavBar = (props: NavBarProps) => {
for (const entry of entries) {
const id = entry.target?.id?.split('section-')[1];
if (entry.isIntersecting && id) {
- return setActiveId(id as NavbarItemId);
+ return setActiveId(id);
}
}
};
@@ -114,8 +122,9 @@ export const NavBar = (props: NavBarProps) => {
const matchesPath = router.matches(route.path);
if (isRoot || matchesPath) {
setActiveId(route.id);
+ return false;
}
- return false;
+ return true;
});
}, [router.currentPath]);
@@ -128,7 +137,7 @@ export const NavBar = (props: NavBarProps) => {
elementId={descriptors.navbarButton.setId(r.id as any)}
iconElementDescriptor={descriptors.navbarButtonIcon}
iconElementId={descriptors.navbarButtonIcon.setId(r.id) as any}
- onClick={() => navigateAndScroll(r)}
+ onClick={handleNavigate(r)}
icon={r.icon}
isActive={activeId === r.id}
>
diff --git a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx
new file mode 100644
index 00000000000..db29be45905
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx
@@ -0,0 +1,28 @@
+import { useEffect, useRef } from 'react';
+
+type ExternalElementMounterProps = {
+ mount: (el: HTMLDivElement) => void;
+ unmount: (el?: HTMLDivElement) => void;
+};
+
+export const ExternalElementMounter = ({ mount, unmount, ...rest }: ExternalElementMounterProps) => {
+ const nodeRef = useRef(null);
+
+ useEffect(() => {
+ let elRef: HTMLDivElement | undefined;
+ if (nodeRef.current) {
+ elRef = nodeRef.current;
+ mount(nodeRef.current);
+ }
+ return () => {
+ unmount(elRef);
+ };
+ }, [nodeRef.current]);
+
+ return (
+
+ );
+};
diff --git a/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts
new file mode 100644
index 00000000000..0ddff304799
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts
@@ -0,0 +1,616 @@
+import type { CustomPage } from '@clerk/types';
+
+import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../createCustomPages';
+
+describe('createCustomPages', () => {
+ describe('createUserProfileCustomPages', () => {
+ it('should return the default pages if no custom pages are passed', () => {
+ const { routes, contents } = createUserProfileCustomPages([]);
+ expect(routes.length).toEqual(2);
+ expect(routes[0].id).toEqual('account');
+ expect(routes[1].id).toEqual('security');
+ expect(contents.length).toEqual(0);
+ });
+
+ it('should return the custom pages after the default pages', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].id).toEqual('account');
+ expect(routes[1].id).toEqual('security');
+ expect(routes[2].name).toEqual('Custom1');
+ expect(routes[3].name).toEqual('Custom2');
+ expect(contents.length).toEqual(2);
+ expect(contents[0].url).toEqual('custom1');
+ expect(contents[1].url).toEqual('custom2');
+ });
+
+ it('should reorder the default pages when their label is used to target them', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'account' },
+ { label: 'security' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].name).toEqual('Custom1');
+ expect(routes[1].id).toEqual('account');
+ expect(routes[2].id).toEqual('security');
+ expect(routes[3].name).toEqual('Custom2');
+ expect(contents.length).toEqual(2);
+ expect(contents[0].url).toEqual('custom1');
+ expect(contents[1].url).toEqual('custom2');
+ });
+
+ it('ignores invalid entries', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'account' },
+ { label: 'security' },
+ { label: 'Aaaaaa' },
+ { label: 'account', mount: () => undefined },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].name).toEqual('Custom1');
+ expect(routes[1].id).toEqual('account');
+ expect(routes[2].id).toEqual('security');
+ expect(routes[3].name).toEqual('Custom2');
+ });
+
+ it('sets the path of the first page to be the root (/)', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'account' },
+ { label: 'security' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('account');
+ expect(routes[2].path).toEqual('account');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('sets the path of both account and security pages to root (/) if account is first', () => {
+ const customPages: CustomPage[] = [
+ { label: 'account' },
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'security' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('custom1');
+ expect(routes[2].path).toEqual('/');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('sets the path of both account and security pages to root (/) if security is first', () => {
+ const customPages: CustomPage[] = [
+ { label: 'security' },
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'account' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('custom1');
+ expect(routes[2].path).toEqual('/');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('throws if the first item in the navbar is an external link', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Link1',
+ url: '/link1',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'account' },
+ { label: 'security' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ expect(() => createUserProfileCustomPages(customPages)).toThrow();
+ });
+
+ it('adds an external link to the navbar routes', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link1',
+ url: '/link1',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].id).toEqual('account');
+ expect(routes[1].id).toEqual('security');
+ expect(routes[2].name).toEqual('Custom1');
+ expect(routes[3].name).toEqual('Link1');
+ expect(contents.length).toEqual(1);
+ expect(contents[0].url).toEqual('custom1');
+ });
+
+ it('sanitizes the path for external links', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Link1',
+ url: 'https://www.fullurl.com',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link2',
+ url: '/url-with-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link3',
+ url: 'url-without-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(5);
+ expect(routes[2].path).toEqual('https://www.fullurl.com');
+ expect(routes[3].path).toEqual('/url-with-slash');
+ expect(routes[4].path).toEqual('/url-without-slash');
+ });
+
+ it('sanitizes the path for custom pages', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Page1',
+ url: '/url-with-slash',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Page2',
+ url: 'url-without-slash',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createUserProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[2].path).toEqual('url-with-slash');
+ expect(routes[3].path).toEqual('url-without-slash');
+ });
+
+ it('throws when a custom page has an absolute URL', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Page1',
+ url: 'https://www.fullurl.com',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ expect(() => createUserProfileCustomPages(customPages)).toThrow();
+ });
+ });
+
+ describe('createOrganizationProfileCustomPages', () => {
+ it('should return the default pages if no custom pages are passed', () => {
+ const { routes, contents } = createOrganizationProfileCustomPages([]);
+ expect(routes.length).toEqual(2);
+ expect(routes[0].id).toEqual('members');
+ expect(routes[1].id).toEqual('settings');
+ expect(contents.length).toEqual(0);
+ });
+
+ it('should return the custom pages after the default pages', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].id).toEqual('members');
+ expect(routes[1].id).toEqual('settings');
+ expect(routes[2].name).toEqual('Custom1');
+ expect(routes[3].name).toEqual('Custom2');
+ expect(contents.length).toEqual(2);
+ expect(contents[0].url).toEqual('custom1');
+ expect(contents[1].url).toEqual('custom2');
+ });
+
+ it('should reorder the default pages when their label is used to target them', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'members' },
+ { label: 'settings' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].name).toEqual('Custom1');
+ expect(routes[1].id).toEqual('members');
+ expect(routes[2].id).toEqual('settings');
+ expect(routes[3].name).toEqual('Custom2');
+ expect(contents.length).toEqual(2);
+ expect(contents[0].url).toEqual('custom1');
+ expect(contents[1].url).toEqual('custom2');
+ });
+
+ it('ignores invalid entries', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'members' },
+ { label: 'settings' },
+ { label: 'Aaaaaa' },
+ { label: 'members', mount: () => undefined },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].name).toEqual('Custom1');
+ expect(routes[1].id).toEqual('members');
+ expect(routes[2].id).toEqual('settings');
+ expect(routes[3].name).toEqual('Custom2');
+ });
+
+ it('sets the path of the first page to be the root (/)', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'members' },
+ { label: 'settings' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('organization-members');
+ expect(routes[2].path).toEqual('organization-settings');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('sets the path of members pages to root (/) if it is first', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'settings' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('custom1');
+ expect(routes[2].path).toEqual('organization-settings');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('sets the path of settings pages to root (/) if it is first', () => {
+ const customPages: CustomPage[] = [
+ { label: 'settings' },
+ { label: 'members' },
+
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].path).toEqual('/');
+ expect(routes[1].path).toEqual('organization-members');
+ expect(routes[3].path).toEqual('custom2');
+ });
+
+ it('throws if the first item in the navbar is an external link', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Link1',
+ url: '/link1',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ { label: 'members' },
+ { label: 'settings' },
+ {
+ label: 'Custom2',
+ url: 'custom2',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ expect(() => createOrganizationProfileCustomPages(customPages)).toThrow();
+ });
+
+ it('adds an external link to the navbar routes', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Custom1',
+ url: 'custom1',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link1',
+ url: '/link1',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes, contents } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[0].id).toEqual('members');
+ expect(routes[1].id).toEqual('settings');
+ expect(routes[2].name).toEqual('Custom1');
+ expect(routes[3].name).toEqual('Link1');
+ expect(contents.length).toEqual(1);
+ expect(contents[0].url).toEqual('custom1');
+ });
+
+ it('sanitizes the path for external links', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Link1',
+ url: 'https://www.fullurl.com',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link2',
+ url: '/url-with-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Link3',
+ url: 'url-without-slash',
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(5);
+ expect(routes[2].path).toEqual('https://www.fullurl.com');
+ expect(routes[3].path).toEqual('/url-with-slash');
+ expect(routes[4].path).toEqual('/url-without-slash');
+ });
+
+ it('sanitizes the path for custom pages', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Page1',
+ url: '/url-with-slash',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ {
+ label: 'Page2',
+ url: 'url-without-slash',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ const { routes } = createOrganizationProfileCustomPages(customPages);
+ expect(routes.length).toEqual(4);
+ expect(routes[2].path).toEqual('url-with-slash');
+ expect(routes[3].path).toEqual('url-without-slash');
+ });
+
+ it('throws when a custom page has an absolute URL', () => {
+ const customPages: CustomPage[] = [
+ {
+ label: 'Page1',
+ url: 'https://www.fullurl.com',
+ mount: () => undefined,
+ unmount: () => undefined,
+ mountIcon: () => undefined,
+ unmountIcon: () => undefined,
+ },
+ ];
+ expect(() => createOrganizationProfileCustomPages(customPages)).toThrow();
+ });
+ });
+});
diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx
new file mode 100644
index 00000000000..cff378eaf3d
--- /dev/null
+++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx
@@ -0,0 +1,305 @@
+import { isDevelopmentEnvironment } from '@clerk/shared';
+import type { CustomPage } from '@clerk/types';
+
+import { isValidUrl } from '../../utils';
+import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID, USER_PROFILE_NAVBAR_ROUTE_ID } from '../constants';
+import type { NavbarRoute } from '../elements';
+import { CogFilled, TickShield, User } from '../icons';
+import { localizationKeys } from '../localization';
+import { ExternalElementMounter } from './ExternalElementMounter';
+
+export type CustomPageContent = {
+ url: string;
+ mount: (el: HTMLDivElement) => void;
+ unmount: (el?: HTMLDivElement) => void;
+};
+
+type ProfileReorderItem = {
+ label: 'account' | 'security' | 'members' | 'settings';
+};
+
+type ProfileCustomPage = {
+ label: string;
+ url: string;
+ mountIcon: (el: HTMLDivElement) => void;
+ unmountIcon: (el?: HTMLDivElement) => void;
+ mount: (el: HTMLDivElement) => void;
+ unmount: (el?: HTMLDivElement) => void;
+};
+
+type ProfileCustomLink = {
+ label: string;
+ url: string;
+ mountIcon: (el: HTMLDivElement) => void;
+ unmountIcon: (el?: HTMLDivElement) => void;
+};
+
+type GetDefaultRoutesReturnType = {
+ INITIAL_ROUTES: NavbarRoute[];
+ pageToRootNavbarRouteMap: Record;
+ validReorderItemLabels: string[];
+};
+
+type CreateCustomPagesParams = {
+ customPages: CustomPage[];
+ getDefaultRoutes: () => GetDefaultRoutesReturnType;
+ setFirstPathToRoot: (routes: NavbarRoute[]) => NavbarRoute[];
+ excludedPathsFromDuplicateWarning: string[];
+};
+
+export const createUserProfileCustomPages = (customPages: CustomPage[]) => {
+ return createCustomPages({
+ customPages,
+ getDefaultRoutes: getUserProfileDefaultRoutes,
+ setFirstPathToRoot: setFirstPathToUserProfileRoot,
+ excludedPathsFromDuplicateWarning: ['/', 'account'],
+ });
+};
+
+export const createOrganizationProfileCustomPages = (customPages: CustomPage[]) => {
+ return createCustomPages({
+ customPages,
+ getDefaultRoutes: getOrganizationProfileDefaultRoutes,
+ setFirstPathToRoot: setFirstPathToOrganizationProfileRoot,
+ excludedPathsFromDuplicateWarning: [],
+ });
+};
+
+const createCustomPages = ({
+ customPages,
+ getDefaultRoutes,
+ setFirstPathToRoot,
+ excludedPathsFromDuplicateWarning,
+}: CreateCustomPagesParams) => {
+ const { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels } = getDefaultRoutes();
+
+ if (isDevelopmentEnvironment()) {
+ checkForDuplicateUsageOfReorderingItems(customPages, validReorderItemLabels);
+ }
+
+ const validCustomPages = customPages.filter(cp => {
+ if (!isValidPageItem(cp, validReorderItemLabels)) {
+ if (isDevelopmentEnvironment()) {
+ console.error('Clerk: Invalid custom page data: ', cp);
+ }
+ return false;
+ }
+ return true;
+ });
+
+ const { allRoutes, contents } = getRoutesAndContents({
+ customPages: validCustomPages,
+ defaultRoutes: INITIAL_ROUTES,
+ });
+
+ assertExternalLinkAsRoot(allRoutes);
+
+ const routes = setFirstPathToRoot(allRoutes);
+
+ if (isDevelopmentEnvironment()) {
+ warnForDuplicatePaths(routes, excludedPathsFromDuplicateWarning);
+ }
+
+ return {
+ routes,
+ contents,
+ pageToRootNavbarRouteMap,
+ };
+};
+
+type GetRoutesAndContentsParams = {
+ customPages: CustomPage[];
+ defaultRoutes: NavbarRoute[];
+};
+
+const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndContentsParams) => {
+ let remainingDefaultRoutes: NavbarRoute[] = defaultRoutes.map(r => r);
+ const contents: CustomPageContent[] = [];
+
+ const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => {
+ if (isCustomLink(cp)) {
+ return {
+ name: cp.label,
+ id: `custom-page-${index}`,
+ icon: props => (
+
+ ),
+ path: sanitizeCustomLinkURL(cp.url),
+ external: true,
+ };
+ }
+ if (isCustomPage(cp)) {
+ const pageURL = sanitizeCustomPageURL(cp.url);
+ contents.push({ url: pageURL, mount: cp.mount, unmount: cp.unmount });
+ return {
+ name: cp.label,
+ id: `custom-page-${index}`,
+ icon: props => (
+
+ ),
+ path: pageURL,
+ };
+ }
+ const reorderItem = defaultRoutes.find(r => r.id === cp.label) as NavbarRoute;
+ remainingDefaultRoutes = remainingDefaultRoutes.filter(({ id }) => id !== cp.label);
+ return { ...reorderItem };
+ });
+
+ const allRoutes = [...remainingDefaultRoutes, ...routesWithoutDefaults];
+
+ return { allRoutes, contents };
+};
+
+// Set the path of the first route to '/' or if the first route is account or security, set the path of both account and security to '/'
+const setFirstPathToUserProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => {
+ if (routes[0].id === 'account' || routes[0].id === 'security') {
+ return routes.map(r => {
+ if (r.id === 'account' || r.id === 'security') {
+ return { ...r, path: '/' };
+ }
+ return r;
+ });
+ } else {
+ return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r));
+ }
+};
+
+const setFirstPathToOrganizationProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => {
+ return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r));
+};
+
+const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], validReorderItems: string[]) => {
+ const reorderItems = customPages.filter(cp => isReorderItem(cp, validReorderItems));
+ reorderItems.reduce((acc, cp) => {
+ if (acc.includes(cp.label)) {
+ console.error(
+ `Clerk: The "${cp.label}" item is used more than once when reordering pages. This may cause unexpected behavior.`,
+ );
+ }
+ return [...acc, cp.label];
+ }, [] as string[]);
+};
+
+//path !== '/' && path !== 'account'
+const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) => {
+ const paths = routes
+ .filter(({ external, path }) => !external && pathsToFilter.every(p => p !== path))
+ .map(({ path }) => path);
+ const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index);
+ duplicatePaths.forEach(p => {
+ console.error(`Clerk: Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`);
+ });
+};
+
+const isValidPageItem = (cp: CustomPage, validReorderItems: string[]): cp is CustomPage => {
+ return isCustomPage(cp) || isCustomLink(cp) || isReorderItem(cp, validReorderItems);
+};
+
+const isCustomPage = (cp: CustomPage): cp is ProfileCustomPage => {
+ return !!cp.url && !!cp.label && !!cp.mount && !!cp.unmount && !!cp.mountIcon && !!cp.unmountIcon;
+};
+
+const isCustomLink = (cp: CustomPage): cp is ProfileCustomLink => {
+ return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon;
+};
+
+const isReorderItem = (cp: CustomPage, validItems: string[]): cp is ProfileReorderItem => {
+ return (
+ !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && validItems.some(v => v === cp.label)
+ );
+};
+
+const sanitizeCustomPageURL = (url: string): string => {
+ if (!url) {
+ throw new Error('Clerk: URL is required for custom pages');
+ }
+ if (isValidUrl(url)) {
+ throw new Error('Clerk: Absolute URLs are not supported for custom pages');
+ }
+ return (url as string).charAt(0) === '/' && (url as string).length > 1 ? (url as string).substring(1) : url;
+};
+
+const sanitizeCustomLinkURL = (url: string): string => {
+ if (!url) {
+ throw new Error('Clerk: URL is required for custom links');
+ }
+ if (isValidUrl(url)) {
+ return url;
+ }
+ return (url as string).charAt(0) === '/' ? url : `/${url}`;
+};
+
+const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => {
+ if (routes[0].external) {
+ throw new Error('Clerk: The first route cannot be a custom external link component');
+ }
+};
+
+const getUserProfileDefaultRoutes = (): GetDefaultRoutesReturnType => {
+ const INITIAL_ROUTES: NavbarRoute[] = [
+ {
+ name: localizationKeys('userProfile.start.headerTitle__account'),
+ id: USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT,
+ icon: User,
+ path: 'account',
+ },
+ {
+ name: localizationKeys('userProfile.start.headerTitle__security'),
+ id: USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY,
+ icon: TickShield,
+ path: 'account',
+ },
+ ];
+
+ const pageToRootNavbarRouteMap: Record = {
+ profile: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ 'email-address': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ 'phone-number': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ 'connected-account': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ 'web3-wallet': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ username: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute,
+ 'multi-factor': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute,
+ password: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute,
+ };
+
+ const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id);
+
+ return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels };
+};
+
+const getOrganizationProfileDefaultRoutes = (): GetDefaultRoutesReturnType => {
+ const INITIAL_ROUTES: NavbarRoute[] = [
+ {
+ name: localizationKeys('organizationProfile.start.headerTitle__members'),
+ id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS,
+ icon: User,
+ path: 'organization-members',
+ },
+ {
+ name: localizationKeys('organizationProfile.start.headerTitle__settings'),
+ id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS,
+ icon: CogFilled,
+ path: 'organization-settings',
+ },
+ ];
+
+ const pageToRootNavbarRouteMap: Record = {
+ 'invite-members': INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS) as NavbarRoute,
+ domain: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute,
+ profile: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute,
+ leave: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute,
+ delete: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute,
+ };
+
+ const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id);
+
+ return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels };
+};
diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts
index a86856b90ed..6c88d4aa3ee 100644
--- a/packages/clerk-js/src/ui/utils/index.ts
+++ b/packages/clerk-js/src/ui/utils/index.ts
@@ -21,3 +21,5 @@ export * from './getRelativeToNowDateKey';
export * from './mergeRefs';
export * from './createSlug';
export * from './passwordUtils';
+export * from './createCustomPages';
+export * from './ExternalElementMounter';
diff --git a/packages/nextjs/src/app-beta/client/ui-components.tsx b/packages/nextjs/src/app-beta/client/ui-components.tsx
index f7c4a0d3894..e0ef758fe5f 100644
--- a/packages/nextjs/src/app-beta/client/ui-components.tsx
+++ b/packages/nextjs/src/app-beta/client/ui-components.tsx
@@ -1,11 +1,4 @@
'use client';
-import { deprecated } from '@clerk/shared/deprecated';
-
-deprecated(
- '@clerk/nextjs/app-beta',
- 'Use imports from `@clerk/nextjs` instead.\nFor more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware',
-);
-
import {
CreateOrganization as _CreateOrganization,
OrganizationProfile as _OrganizationProfile,
@@ -15,6 +8,12 @@ import {
UserButton as _UserButton,
UserProfile as _UserProfile,
} from '@clerk/clerk-react';
+import { deprecated } from '@clerk/shared/deprecated';
+
+deprecated(
+ '@clerk/nextjs/app-beta',
+ 'Use imports from `@clerk/nextjs` instead.\nFor more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware',
+);
/**
* @deprecated Use imports from `@clerk/nextjs` instead.
@@ -25,12 +24,12 @@ export const CreateOrganization = _CreateOrganization;
* @deprecated Use imports from `@clerk/nextjs` instead.
* For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware
*/
-export const OrganizationProfile = _OrganizationProfile;
+export const OrganizationProfile: typeof _OrganizationProfile = _OrganizationProfile;
/**
* @deprecated Use imports from `@clerk/nextjs` instead.
* For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware
*/
-export const OrganizationSwitcher = _OrganizationSwitcher;
+export const OrganizationSwitcher: typeof _OrganizationSwitcher = _OrganizationSwitcher;
/**
* @deprecated Use imports from `@clerk/nextjs` instead.
* For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware
@@ -45,9 +44,9 @@ export const SignUp = _SignUp;
* @deprecated Use imports from `@clerk/nextjs` instead.
* For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware
*/
-export const UserButton = _UserButton;
+export const UserButton: typeof _UserButton = _UserButton;
/**
* @deprecated Use imports from `@clerk/nextjs` instead.
* For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware
*/
-export const UserProfile = _UserProfile;
+export const UserProfile: typeof _UserProfile = _UserProfile;
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index ca9af9043dd..42edc8f1ff2 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -1,3 +1,4 @@
+import { logErrorInDevMode } from '@clerk/shared';
import type {
CreateOrganizationProps,
OrganizationListProps,
@@ -8,11 +9,54 @@ import type {
UserButtonProps,
UserProfileProps,
} from '@clerk/types';
-import React from 'react';
+import type { PropsWithChildren } from 'react';
+import React, { createElement } from 'react';
-import type { MountProps, WithClerkProp } from '../types';
+import {
+ organizationProfileLinkRenderedError,
+ organizationProfilePageRenderedError,
+ userProfileLinkRenderedError,
+ userProfilePageRenderedError,
+} from '../errors';
+import type {
+ MountProps,
+ OrganizationProfileLinkProps,
+ OrganizationProfilePageProps,
+ UserProfileLinkProps,
+ UserProfilePageProps,
+ WithClerkProp,
+} from '../types';
+import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils';
import { withClerk } from './withClerk';
+type UserProfileExportType = typeof _UserProfile & {
+ Page: typeof UserProfilePage;
+ Link: typeof UserProfileLink;
+};
+
+type UserButtonExportType = typeof _UserButton & {
+ UserProfilePage: typeof UserProfilePage;
+ UserProfileLink: typeof UserProfileLink;
+};
+
+type UserButtonPropsWithoutCustomPages = Omit & {
+ userProfileProps?: Pick;
+};
+
+type OrganizationProfileExportType = typeof _OrganizationProfile & {
+ Page: typeof OrganizationProfilePage;
+ Link: typeof OrganizationProfileLink;
+};
+
+type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & {
+ OrganizationProfilePage: typeof OrganizationProfilePage;
+ OrganizationProfileLink: typeof OrganizationProfileLink;
+};
+
+type OrganizationSwitcherPropsWithoutCustomPages = Omit & {
+ organizationProfileProps?: Pick;
+};
+
// README: should be a class pure component in order for mount and unmount
// lifecycle props to be invoked correctly. Replacing the class component with a
// functional component wrapped with a React.memo is not identical to the original
@@ -45,7 +89,10 @@ class Portal extends React.PureComponent {
private portalRef = React.createRef();
componentDidUpdate(prevProps: Readonly) {
- if (prevProps.props.appearance !== this.props.props.appearance) {
+ if (
+ prevProps.props.appearance !== this.props.props.appearance ||
+ prevProps.props?.customPages?.length !== this.props.props?.customPages?.length
+ ) {
this.props.updateProps({ node: this.portalRef.current, props: this.props.props });
}
}
@@ -63,7 +110,12 @@ class Portal extends React.PureComponent {
}
render() {
- return ;
+ return (
+ <>
+
+ {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))}
+ >
+ );
}
}
@@ -89,38 +141,89 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp
);
}, 'SignUp');
-export const UserProfile = withClerk(({ clerk, ...props }: WithClerkProp) => {
- return (
-
- );
-}, 'UserProfile');
+export function UserProfilePage({ children }: PropsWithChildren) {
+ logErrorInDevMode(userProfilePageRenderedError);
+ return <>{children}>;
+}
-export const UserButton = withClerk(({ clerk, ...props }: WithClerkProp) => {
- return (
-
- );
-}, 'UserButton');
+export function UserProfileLink({ children }: PropsWithChildren) {
+ logErrorInDevMode(userProfileLinkRenderedError);
+ return <>{children}>;
+}
-export const OrganizationProfile = withClerk(({ clerk, ...props }: WithClerkProp) => {
- return (
-
- );
-}, 'OrganizationProfile');
+const _UserProfile = withClerk(
+ ({ clerk, ...props }: WithClerkProp>>) => {
+ const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children);
+ return (
+
+ );
+ },
+ 'UserProfile',
+);
+
+export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, {
+ Page: UserProfilePage,
+ Link: UserProfileLink,
+});
+
+const _UserButton = withClerk(
+ ({ clerk, ...props }: WithClerkProp>) => {
+ const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children);
+ const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages });
+ return (
+
+ );
+ },
+ 'UserButton',
+);
+
+export const UserButton: UserButtonExportType = Object.assign(_UserButton, {
+ UserProfilePage,
+ UserProfileLink,
+});
+
+export function OrganizationProfilePage({ children }: PropsWithChildren) {
+ logErrorInDevMode(organizationProfilePageRenderedError);
+ return <>{children}>;
+}
+
+export function OrganizationProfileLink({ children }: PropsWithChildren) {
+ logErrorInDevMode(organizationProfileLinkRenderedError);
+ return <>{children}>;
+}
+
+const _OrganizationProfile = withClerk(
+ ({ clerk, ...props }: WithClerkProp>>) => {
+ const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children);
+ return (
+
+ );
+ },
+ 'OrganizationProfile',
+);
+
+export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, {
+ Page: OrganizationProfilePage,
+ Link: OrganizationProfileLink,
+});
export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => {
return (
@@ -133,16 +236,27 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp<
);
}, 'CreateOrganization');
-export const OrganizationSwitcher = withClerk(({ clerk, ...props }: WithClerkProp) => {
- return (
-
- );
-}, 'OrganizationSwitcher');
+const _OrganizationSwitcher = withClerk(
+ ({ clerk, ...props }: WithClerkProp>) => {
+ const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children);
+ const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages });
+ return (
+
+ );
+ },
+ 'OrganizationSwitcher',
+);
+
+export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, {
+ OrganizationProfilePage,
+ OrganizationProfileLink,
+});
export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => {
return (
diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts
index 4e567a97428..0e881c3de35 100644
--- a/packages/react/src/errors.ts
+++ b/packages/react/src/errors.ts
@@ -27,7 +27,26 @@ export const multipleChildrenInButtonComponent = (name: string) =>
`Clerk: You've passed multiple children components to <${name}/>. You can only pass a single child component or text.`;
export const invalidStateError =
- 'Invalid state. Feel free to submit a bug or reach out to support here: https://clerk.com/support';
+ 'Clerk: Invalid state. Feel free to submit a bug or reach out to support here: https://clerk.com/support';
export const unsupportedNonBrowserDomainOrProxyUrlFunction =
- 'Unsupported usage of domain or proxyUrl. The usage of domain or proxyUrl as function is not supported in non-browser environments.';
+ 'Clerk: Unsupported usage of domain or proxyUrl. The usage of domain or proxyUrl as function is not supported in non-browser environments.';
+
+export const userProfilePageRenderedError =
+ 'Clerk: component needs to be a direct child of `` or ``.';
+export const userProfileLinkRenderedError =
+ 'Clerk: component needs to be a direct child of `` or ``.';
+
+export const organizationProfilePageRenderedError =
+ 'Clerk: component needs to be a direct child of `` or ``.';
+export const organizationProfileLinkRenderedError =
+ 'Clerk: component needs to be a direct child of `` or ``.';
+
+export const customPagesIgnoredComponent = (componentName: string) =>
+ `Clerk: <${componentName} /> can only accept <${componentName}.Page /> and <${componentName}.Link /> as its children. Any other provided component will be ignored.`;
+
+export const customPageWrongProps = (componentName: string) =>
+ `Clerk: Missing props. <${componentName}.Page /> component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.`;
+
+export const customLinkWrongProps = (componentName: string) =>
+ `Clerk: Missing props. <${componentName}.Link /> component requires the following props: url, label and labelIcon.`;
diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts
index 8077d32d434..dd9bb60028b 100644
--- a/packages/react/src/types.ts
+++ b/packages/react/src/types.ts
@@ -12,6 +12,7 @@ import type {
SignUpRedirectOptions,
UserResource,
} from '@clerk/types';
+import type React from 'react';
declare global {
interface Window {
@@ -51,6 +52,7 @@ export interface MountProps {
unmount: (node: HTMLDivElement) => void;
updateProps: (props: any) => void;
props?: any;
+ customPagesPortals?: any[];
}
export interface HeadlessBrowserClerk extends Clerk {
@@ -88,3 +90,18 @@ export type SignInWithMetamaskButtonProps = Pick JSX.Element;
+ mount: (node: Element) => void;
+ unmount: () => void;
+ id: number;
+};
+
+// This function takes a component as prop, and returns functions that mount and unmount
+// the given component into a given node
+export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => {
+ const initialState = Array(elements.length).fill(null);
+ const [nodes, setNodes] = useState<(Element | null)[]>(initialState);
+
+ return elements.map((el, index) => ({
+ id: el.id,
+ mount: (node: Element) => setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))),
+ unmount: () => setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))),
+ portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index] as Element) : null}>,
+ }));
+};
diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx
new file mode 100644
index 00000000000..7037a4751c0
--- /dev/null
+++ b/packages/react/src/utils/useCustomPages.tsx
@@ -0,0 +1,169 @@
+import { logErrorInDevMode } from '@clerk/shared';
+import type { CustomPage } from '@clerk/types';
+import type { ReactElement } from 'react';
+import React from 'react';
+
+import {
+ OrganizationProfileLink,
+ OrganizationProfilePage,
+ UserProfileLink,
+ UserProfilePage,
+} from '../components/uiComponents';
+import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors';
+import type { UserProfilePageProps } from '../types';
+import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal';
+import { useCustomElementPortal } from './useCustomElementPortal';
+
+const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => {
+ return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component;
+};
+
+export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
+ const reorderItemsLabels = ['account', 'security'];
+ return useCustomPages({
+ children,
+ reorderItemsLabels,
+ LinkComponent: UserProfileLink,
+ PageComponent: UserProfilePage,
+ componentName: 'UserProfile',
+ });
+};
+
+export const useOrganizationProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => {
+ const reorderItemsLabels = ['members', 'settings'];
+ return useCustomPages({
+ children,
+ reorderItemsLabels,
+ LinkComponent: OrganizationProfileLink,
+ PageComponent: OrganizationProfilePage,
+ componentName: 'OrganizationProfile',
+ });
+};
+
+type UseCustomPagesParams = {
+ children: React.ReactNode | React.ReactNode[];
+ LinkComponent: any;
+ PageComponent: any;
+ reorderItemsLabels: string[];
+ componentName: string;
+};
+
+type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode };
+
+const useCustomPages = ({
+ children,
+ LinkComponent,
+ PageComponent,
+ reorderItemsLabels,
+ componentName,
+}: UseCustomPagesParams) => {
+ const validChildren: CustomPageWithIdType[] = [];
+
+ React.Children.forEach(children, child => {
+ if (!isThatComponent(child, PageComponent) && !isThatComponent(child, LinkComponent)) {
+ if (child) {
+ logErrorInDevMode(customPagesIgnoredComponent(componentName));
+ }
+ return;
+ }
+
+ const { props } = child as ReactElement;
+
+ const { children, label, url, labelIcon } = props;
+
+ if (isThatComponent(child, PageComponent)) {
+ if (isReorderItem(props, reorderItemsLabels)) {
+ // This is a reordering item
+ validChildren.push({ label });
+ } else if (isCustomPage(props)) {
+ // this is a custom page
+ validChildren.push({ label, labelIcon, children, url });
+ } else {
+ logErrorInDevMode(customPageWrongProps(componentName));
+ return;
+ }
+ }
+
+ if (isThatComponent(child, LinkComponent)) {
+ if (isExternalLink(props)) {
+ // This is an external link
+ validChildren.push({ label, labelIcon, url });
+ } else {
+ logErrorInDevMode(customLinkWrongProps(componentName));
+ return;
+ }
+ }
+ });
+
+ const customPageContents: UseCustomElementPortalParams[] = [];
+ const customPageLabelIcons: UseCustomElementPortalParams[] = [];
+ const customLinkLabelIcons: UseCustomElementPortalParams[] = [];
+
+ validChildren.forEach((cp, index) => {
+ if (isCustomPage(cp)) {
+ customPageContents.push({ component: cp.children, id: index });
+ customPageLabelIcons.push({ component: cp.labelIcon, id: index });
+ return;
+ }
+ if (isExternalLink(cp)) {
+ customLinkLabelIcons.push({ component: cp.labelIcon, id: index });
+ }
+ });
+
+ const customPageContentsPortals = useCustomElementPortal(customPageContents);
+ const customPageLabelIconsPortals = useCustomElementPortal(customPageLabelIcons);
+ const customLinkLabelIconsPortals = useCustomElementPortal(customLinkLabelIcons);
+
+ const customPages: CustomPage[] = [];
+ const customPagesPortals: React.ComponentType[] = [];
+
+ validChildren.forEach((cp, index) => {
+ if (isReorderItem(cp, reorderItemsLabels)) {
+ customPages.push({ label: cp.label });
+ return;
+ }
+ if (isCustomPage(cp)) {
+ const {
+ portal: contentPortal,
+ mount,
+ unmount,
+ } = customPageContentsPortals.find(p => p.id === index) as UseCustomElementPortalReturn;
+ const {
+ portal: labelPortal,
+ mount: mountIcon,
+ unmount: unmountIcon,
+ } = customPageLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn;
+ customPages.push({ label: cp.label, url: cp.url, mount, unmount, mountIcon, unmountIcon });
+ customPagesPortals.push(contentPortal);
+ customPagesPortals.push(labelPortal);
+ return;
+ }
+ if (isExternalLink(cp)) {
+ const {
+ portal: labelPortal,
+ mount: mountIcon,
+ unmount: unmountIcon,
+ } = customLinkLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn;
+ customPages.push({ label: cp.label, url: cp.url, mountIcon, unmountIcon });
+ customPagesPortals.push(labelPortal);
+ return;
+ }
+ });
+
+ return { customPages, customPagesPortals };
+};
+
+const isReorderItem = (childProps: any, validItems: string[]): boolean => {
+ const { children, label, url, labelIcon } = childProps;
+ return !children && !url && !labelIcon && validItems.some(v => v === label);
+};
+
+const isCustomPage = (childProps: any): boolean => {
+ const { children, label, url, labelIcon } = childProps;
+ return !!children && !!url && !!labelIcon && !!label;
+};
+
+const isExternalLink = (childProps: any): boolean => {
+ const { children, label, url, labelIcon } = childProps;
+ return !children && !!url && !!labelIcon && !!label;
+};
diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts
index 7e6dcec81b1..8838c7ed4b4 100644
--- a/packages/shared/src/utils/index.ts
+++ b/packages/shared/src/utils/index.ts
@@ -3,3 +3,4 @@ export { isStaging } from './instance';
export { noop } from './noop';
export * from './runtimeEnvironment';
export * from './runWithExponentialBackOff';
+export { logErrorInDevMode } from './logErrorInDevMode';
diff --git a/packages/shared/src/utils/logErrorInDevMode.ts b/packages/shared/src/utils/logErrorInDevMode.ts
new file mode 100644
index 00000000000..ea299ba832f
--- /dev/null
+++ b/packages/shared/src/utils/logErrorInDevMode.ts
@@ -0,0 +1,7 @@
+import { isDevelopmentEnvironment } from './runtimeEnvironment';
+
+export const logErrorInDevMode = (message: string) => {
+ if (isDevelopmentEnvironment()) {
+ console.error(message);
+ }
+};
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 657cd1cb4f7..de454419291 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -94,8 +94,6 @@ export type ProfileSectionId =
| 'organizationDomains';
export type ProfilePageId = 'account' | 'security' | 'organizationSettings' | 'organizationMembers';
-export type NavbarItemId = 'account' | 'security' | 'members' | 'settings';
-
export type UserPreviewId = 'userButton' | 'personalWorkspace';
export type OrganizationPreviewId = 'organizationSwitcher' | 'organizationList';
@@ -391,8 +389,8 @@ export type ElementsConfig = {
navbar: WithOptions;
navbarButtons: WithOptions;
- navbarButton: WithOptions;
- navbarButtonIcon: WithOptions;
+ navbarButton: WithOptions;
+ navbarButtonIcon: WithOptions;
navbarMobileMenuRow: WithOptions;
navbarMobileMenuButton: WithOptions;
navbarMobileMenuButtonIcon: WithOptions;
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 004b0aea7c8..044d1b936a4 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -10,6 +10,7 @@ import type {
UserProfileTheme,
} from './appearance';
import type { ClientResource } from './client';
+import type { CustomPage } from './customPages';
import type { DisplayThemeJSON } from './json';
import type { LocalizationResource } from './localization';
import type { OAuthProvider, OAuthScope } from './oauth';
@@ -721,6 +722,10 @@ export type UserProfileProps = {
* e.g.
*/
additionalOAuthScopes?: Partial>;
+ /*
+ * Provide custom pages and links to be rendered inside the UserProfile.
+ */
+ customPages?: CustomPage[];
};
export type OrganizationProfileProps = {
@@ -743,6 +748,10 @@ export type OrganizationProfileProps = {
* prop of ClerkProvided (if one is provided)
*/
appearance?: OrganizationProfileTheme;
+ /*
+ * Provide custom pages and links to be rendered inside the OrganizationProfile.
+ */
+ customPages?: CustomPage[];
};
export type CreateOrganizationProps = {
@@ -831,7 +840,7 @@ export type UserButtonProps = {
* Specify options for the underlying component.
* e.g.
*/
- userProfileProps?: Pick;
+ userProfileProps?: Pick;
};
type PrimitiveKeys = {
@@ -921,7 +930,7 @@ export type OrganizationSwitcherProps = {
* Specify options for the underlying component.
* e.g.
*/
- organizationProfileProps?: Pick;
+ organizationProfileProps?: Pick;
};
export type OrganizationListProps = {
diff --git a/packages/types/src/customPages.ts b/packages/types/src/customPages.ts
new file mode 100644
index 00000000000..e21b710d2bd
--- /dev/null
+++ b/packages/types/src/customPages.ts
@@ -0,0 +1,8 @@
+export type CustomPage = {
+ label: string;
+ url?: string;
+ mountIcon?: (el: HTMLDivElement) => void;
+ unmountIcon?: (el?: HTMLDivElement) => void;
+ mount?: (el: HTMLDivElement) => void;
+ unmount?: (el?: HTMLDivElement) => void;
+};
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index a83ebbf2e02..de6d253f698 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -49,3 +49,4 @@ export * from './utils';
export * from './verification';
export * from './web3';
export * from './web3Wallet';
+export * from './customPages';