diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md new file mode 100644 index 0000000000..68c3dba82e --- /dev/null +++ b/.changeset/proud-ways-lie.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduce Custom Pages in UserProfile diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 08bf8ac6e6..4b491a22ba 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -1,33 +1,18 @@ 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: '', - }, -]; +import { pageToRootNavbarRouteMap } from '../../utils'; export const UserProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { + const { pages } = useUserProfileContext(); return ( {props.children} @@ -35,17 +20,6 @@ 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'>) => { return ( ) => { + const { pages } = useUserProfileContext(); return ( - - - - - - - - - - + + {/* Custom Pages */} + {pages.contents?.map((customPage, index) => ( + + - - + ))} + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + - - + + + + + + + + + - - - - - - + + - - + {/**/} + + - - - - - - + + - + - - - - - - {/**/} - - - - {/**/} - - - + + ); }; 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 9f6ca54a56..ed6b04ce07 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 @@ -2,9 +2,10 @@ import { describe, it } from '@jest/globals'; import React from 'react'; import { bindCreateFixtures, render, screen } from '../../../../testUtils'; +import type { CustomPage } from '../../../utils'; 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/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 98ed7863ed..3909ba7b86 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 { createCustomPages } from '../utils'; const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); @@ -184,24 +187,31 @@ export const useSignInContext = (): SignInContextType => { }; }; +type PagesType = { + routes: NavbarRoute[]; + contents: CustomPageContent[]; + isAccountPageRoot: boolean; +}; + 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 = useMemo(() => createCustomPages(customPages || []), [customPages]); + return { ...ctx, + pages, componentName, queryParams, authQueryString: '', diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx index d80eeb1d17..763cbc06ef 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'; -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 0000000000..e04299d3c5 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +type ExternalElementMounterProps = { + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +export const ExternalElementMounter = ({ mount, unmount }: 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 0000000000..1eeb232ac3 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts @@ -0,0 +1,312 @@ +import type { CustomPage } from '../createCustomPages'; +import { createCustomPages } from '../createCustomPages'; + +describe('createCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents, isAccountPageRoot } = createCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(contents.length).toEqual(0); + expect(isAccountPageRoot).toEqual(true); + }); + + 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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(true); + }); + + 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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(false); + }); + + 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 } = createCustomPages(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 } = createCustomPages(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 } = createCustomPages(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 } = createCustomPages(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(() => createCustomPages(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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(true); + }); + + 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 } = createCustomPages(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 } = createCustomPages(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(() => createCustomPages(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 0000000000..07d419f0a3 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -0,0 +1,217 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; + +import { isValidUrl } from '../../utils'; +import type { NavbarRoute } from '../elements'; +import { TickShield, User } from '../icons'; +import { localizationKeys } from '../localization'; +import { ExternalElementMounter } from './ExternalElementMounter'; + +const CLERK_ACCOUNT_ROUTE: NavbarRoute = { + name: localizationKeys('userProfile.start.headerTitle__account'), + id: 'account', + icon: User, + path: 'account', +}; + +const CLERK_SECURITY_ROUTE: NavbarRoute = { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: 'security', + icon: TickShield, + path: 'account', +}; + +export const pageToRootNavbarRouteMap = { + profile: CLERK_ACCOUNT_ROUTE, + 'email-address': CLERK_ACCOUNT_ROUTE, + 'phone-number': CLERK_ACCOUNT_ROUTE, + 'connected-account': CLERK_ACCOUNT_ROUTE, + 'web3-wallet': CLERK_ACCOUNT_ROUTE, + username: CLERK_ACCOUNT_ROUTE, + 'multi-factor': CLERK_SECURITY_ROUTE, + password: CLERK_SECURITY_ROUTE, +}; + +export type CustomPageContent = { + url: string; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type UserProfileReorderItem = { + label: 'account' | 'security'; +}; + +type UserProfileCustomPage = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type UserProfileCustomLink = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; +}; + +export const createCustomPages = (customPages: CustomPage[]) => { + if (isDevelopmentEnvironment()) { + checkForDuplicateUsageOfReorderingItems(customPages); + } + + const validCustomPages = customPages.filter(cp => { + if (!isValidPageItem(cp)) { + if (isDevelopmentEnvironment()) { + console.error('Invalid custom page data: ', cp); + } + return false; + } + return true; + }); + + const { allRoutes, contents } = getRoutesAndContents(validCustomPages); + + assertExternalLinkAsRoot(allRoutes); + + const routes = setFirstPathToRoot(allRoutes); + + if (isDevelopmentEnvironment()) { + warnForDuplicatePaths(routes); + } + + return { routes, contents, isAccountPageRoot: routes[0].id === 'account' || routes[0].id === 'security' }; +}; + +const getRoutesAndContents = (customPages: CustomPage[]) => { + let clerkDefaultRoutes: NavbarRoute[] = [{ ...CLERK_ACCOUNT_ROUTE }, { ...CLERK_SECURITY_ROUTE }]; + const CLERK_ROUTES = { + account: CLERK_ACCOUNT_ROUTE, + security: CLERK_SECURITY_ROUTE, + }; + const contents: CustomPageContent[] = []; + + const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => { + if (isCustomLink(cp)) { + return { + name: cp.label, + id: `custom-page-${index}`, + icon: () => ( + + ), + 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: () => ( + + ), + path: pageURL, + }; + } + const reorderItem = CLERK_ROUTES[cp.label as 'account' | 'security']; + clerkDefaultRoutes = clerkDefaultRoutes.filter(({ id }) => id !== cp.label); + return { ...reorderItem }; + }); + + const allRoutes = [...clerkDefaultRoutes, ...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 setFirstPathToRoot = (routes: 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 checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[]) => { + const reorderItems = customPages.filter(cp => isAccountReorderItem(cp) || isSecurityReorderItem(cp)); + reorderItems.reduce((acc, cp) => { + if (acc.includes(cp.label)) { + console.error( + `The "${cp.label}" item is used more than once when reordering UserProfile pages. This may cause unexpected behavior.`, + ); + } + return [...acc, cp.label]; + }, [] as string[]); +}; + +const warnForDuplicatePaths = (routes: NavbarRoute[]) => { + const paths = routes + .filter(({ external, path }) => !external && path !== '/' && path !== 'account') + .map(({ path }) => path); + const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index); + duplicatePaths.forEach(p => { + console.error(`Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); + }); +}; + +const isValidPageItem = (cp: CustomPage): cp is CustomPage => { + return isCustomPage(cp) || isCustomLink(cp) || isAccountReorderItem(cp) || isSecurityReorderItem(cp); +}; + +const isCustomPage = (cp: CustomPage): cp is UserProfileCustomPage => { + return !!cp.url && !!cp.label && !!cp.mount && !!cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isCustomLink = (cp: CustomPage): cp is UserProfileCustomLink => { + return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isAccountReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { + return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'account'; +}; + +const isSecurityReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { + return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'security'; +}; + +const sanitizeCustomPageURL = (url: string): string => { + if (!url) { + throw new Error('URL is required for custom pages'); + } + if (isValidUrl(url)) { + throw new Error('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('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('The first route cannot be a component'); + } +}; diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index a86856b90e..6c88d4aa3e 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/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index ca9af9043d..6185f1605b 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,3 +1,4 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; import type { CreateOrganizationProps, OrganizationListProps, @@ -8,9 +9,12 @@ 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 { userProfileLinkRenderedError, userProfilePageRenderedError } from '../errors'; +import type { MountProps, UserProfileLinkProps, UserProfilePageProps, WithClerkProp } from '../types'; +import { useCustomPages } from '../utils/useCustomPages'; import { withClerk } from './withClerk'; // README: should be a class pure component in order for mount and unmount @@ -63,7 +67,12 @@ class Portal extends React.PureComponent { } render() { - return
; + return ( + <> +
+ {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + + ); } } @@ -89,28 +98,65 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); }, 'SignUp'); -export const UserProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { +export function UserProfilePage({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(userProfilePageRenderedError); + } + return
{children}
; +} + +export function UserProfileLink({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(userProfileLinkRenderedError); + } + return
{children}
; +} + +const _UserProfile = withClerk(({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useCustomPages(props.children); return ( ); }, 'UserProfile'); -export const UserButton = withClerk(({ clerk, ...props }: WithClerkProp) => { +type UserProfileExportType = typeof _UserProfile & { + Page: ({ children }: PropsWithChildren) => React.JSX.Element; + Link: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { + Page: UserProfilePage, + Link: UserProfileLink, +}); + +const _UserButton = withClerk(({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useCustomPages(props.children); + const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); return ( ); }, 'UserButton'); +type UserButtonExportType = typeof _UserButton & { + UserProfilePage: ({ children }: PropsWithChildren) => React.JSX.Element; + UserProfileLink: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const UserButton: UserButtonExportType = Object.assign(_UserButton, { + UserProfilePage: UserProfilePage, + UserProfileLink: UserProfileLink, +}); + export const OrganizationProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( component needs to be a direct child of `` or ``.'; +export const userProfileLinkRenderedError = + ' component needs to be a direct child of `` or ``.'; + +export const customPagesIngoredComponent = + ' can only accept and as its children. Any other provided component will be ignored.'; + +export const userProfilePageWrongProps = + 'Missing props. component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.'; + +export const userProfileLinkWrongProps = + 'Missing props. component requires the following props: url, label and labelIcon.'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 7d8109601c..a770cc7fe3 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -11,6 +11,7 @@ import type { SignUpRedirectOptions, UserResource, } from '@clerk/types'; +import type React from 'react'; declare global { interface Window { @@ -49,6 +50,7 @@ export interface MountProps { unmount: (node: HTMLDivElement) => void; updateProps: (props: any) => void; props?: any; + customPagesPortals?: any[]; } export interface HeadlessBrowserClerk extends Clerk { @@ -86,3 +88,15 @@ export type SignInWithMetamaskButtonProps = Pick { + const [node, setNode] = useState(null); + + const mount = (node: Element) => { + setNode(node); + }; + const unmount = () => { + setNode(null); + }; + + // If mount has been called, CustomElementPortal returns a portal that renders `component` + // into the passed node + + // Otherwise, CustomElementPortal returns nothing + const CustomElementPortal = () => <>{node ? createPortal(component, node) : null}; + + return { CustomElementPortal, mount, unmount }; +}; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx new file mode 100644 index 0000000000..a1ec050b14 --- /dev/null +++ b/packages/react/src/utils/useCustomPages.tsx @@ -0,0 +1,98 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { UserProfileLink, UserProfilePage } from '../components/uiComponents'; +import { customPagesIngoredComponent, userProfileLinkWrongProps, userProfilePageWrongProps } from '../errors'; +import { useCustomElementPortal } from './useCustomElementPortal'; + +const errorInDevMode = (message: string) => { + if (isDevelopmentEnvironment()) { + console.error(message); + } +}; + +const isPageComponent = (v: any): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfilePage; +}; + +const isLinkComponent = (v: any): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfileLink; +}; + +export const useCustomPages = (userProfileChildren: React.ReactNode | React.ReactNode[]) => { + const customPages: CustomPage[] = []; + const customPagesPortals: React.ComponentType[] = []; + React.Children.forEach(userProfileChildren, child => { + if (!isPageComponent(child) && !isLinkComponent(child)) { + errorInDevMode(customPagesIngoredComponent); + return; + } + + const { props } = child as ReactElement; + + const { children, label, url, labelIcon } = props; + + if (isPageComponent(child)) { + if (isReorderItem(props)) { + // This is a reordering item + customPages.push({ label }); + } else if (isCustomPage(props)) { + // this is a custom page + const { CustomElementPortal, mount, unmount } = useCustomElementPortal(children); + const { + CustomElementPortal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = useCustomElementPortal(labelIcon); + customPages.push({ + url, + label, + mountIcon, + unmountIcon, + mount, + unmount, + }); + customPagesPortals.push(CustomElementPortal); + customPagesPortals.push(labelPortal); + } else { + errorInDevMode(userProfilePageWrongProps); + return; + } + } + + if (isLinkComponent(child)) { + if (isExternalLink(props)) { + // This is an external link + const { + CustomElementPortal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = useCustomElementPortal(labelIcon); + customPages.push({ label, url, mountIcon, unmountIcon }); + customPagesPortals.push(labelPortal); + } else { + errorInDevMode(userProfileLinkWrongProps); + return; + } + } + }); + + return { customPages, customPagesPortals }; +}; + +const isReorderItem = (childProps: any): boolean => { + const { children, label, url, labelIcon } = childProps; + return !children && !url && !labelIcon && (label === 'account' || label === 'security'); +}; + +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/types/src/appearance.ts b/packages/types/src/appearance.ts index a06b59352d..5a783f83cb 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'; @@ -386,8 +384,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 a88be54579..dea9cf7f1b 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'; @@ -692,6 +693,16 @@ export type UserProfileProps = { * e.g. */ additionalOAuthScopes?: Partial>; + /* + * Provide addition custom route items and pages to be rendered inside the UserProfile. + * e.g. + * + * C
}> + *
Hello from custom page!
+ * + * + */ + customPages?: CustomPage[]; }; export type OrganizationProfileProps = { @@ -802,7 +813,7 @@ export type UserButtonProps = { * Specify options for the underlying component. * e.g. */ - userProfileProps?: Pick; + userProfileProps?: Pick; }; type PrimitiveKeys = { diff --git a/packages/types/src/customPages.ts b/packages/types/src/customPages.ts new file mode 100644 index 0000000000..e21b710d2b --- /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 a83ebbf2e0..de6d253f69 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';