diff --git a/.changeset/mosaic-user-profile-combined.md b/.changeset/mosaic-user-profile-combined.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-combined.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/components/profile/index.ts b/packages/mosaic/src/components/profile/index.ts index 39af298aaf2..9f9d3b6f073 100644 --- a/packages/mosaic/src/components/profile/index.ts +++ b/packages/mosaic/src/components/profile/index.ts @@ -2,6 +2,7 @@ export { Profile } from './profile'; export type { ProfileContentProps, ProfileElevation, + ProfileNavGroupProps, ProfileNavItemProps, ProfileNavProps, ProfileContentPanelProps, diff --git a/packages/mosaic/src/components/profile/profile.styles.ts b/packages/mosaic/src/components/profile/profile.styles.ts index a7c1219e9cc..4ef4dcc1eb7 100644 --- a/packages/mosaic/src/components/profile/profile.styles.ts +++ b/packages/mosaic/src/components/profile/profile.styles.ts @@ -189,6 +189,22 @@ export const styles = stylex.create({ minWidth: 0, }, + navGroup: { + gap: NAV_GAP, + display: 'flex', + flexDirection: 'column', + marginBlockStart: { default: null, ':not(:first-child)': space['4'] }, + }, + + navGroupLabel: { + paddingBlock: space['1'], + paddingInline: space['2.5'], + color: colorVars['--cl-color-foreground-secondary'], + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-medium'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + navItem: { borderColor: 'transparent', borderRadius: radiusVars['--cl-radius-md'], diff --git a/packages/mosaic/src/components/profile/profile.test.tsx b/packages/mosaic/src/components/profile/profile.test.tsx index c3d82a62bf8..0e36bedafef 100644 --- a/packages/mosaic/src/components/profile/profile.test.tsx +++ b/packages/mosaic/src/components/profile/profile.test.tsx @@ -1,11 +1,12 @@ import * as stylex from '@stylexjs/stylex'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, isInaccessible, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UserCircle } from '../../icons/glyphs/user-circle'; import { MosaicProvider } from '../../mosaic-provider'; +import { axe } from '../../primitives/test-utils/axe'; import { Badge } from '../badge'; import { Card } from '../card'; import { Dialog } from '../dialog'; @@ -65,6 +66,30 @@ function renderSurface(props: Partial = {}) { ); } +function GroupedSurface(rootProps: Partial) { + return ( + + Settings + + + Account + Security + + + General + Security + + + + Account page + + + ); +} + function atomsOf(style: stylex.StyleXStyles): string[] { return stylex .props(style) @@ -289,6 +314,42 @@ describe('Profile', () => { }); // The sentinel reports 1px column, 2px select, 3px sheet. + describe('navigation groups', () => { + it('labels each group and describes its tabs by that label', () => { + render(); + + const label = screen.getByText('Acme Inc'); + expect(label).toHaveClass('cl-profile-nav-group-label'); + expect(label.closest('.cl-profile-nav-group')).toContainElement( + screen.getByRole('tab', { name: 'General', description: 'Acme Inc' }), + ); + expect(screen.getByRole('tab', { name: 'Security', description: 'Personal' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Security', description: 'Acme Inc' })).toBeInTheDocument(); + }); + + it('keeps the group labels out of the tablist for assistive tech', () => { + render(); + + expect(isInaccessible(screen.getByText('Personal'))).toBe(true); + expect(isInaccessible(screen.getByText('Acme Inc'))).toBe(true); + }); + + it('moves between groups with the arrow keys', async () => { + const user = userEvent.setup(); + render(); + + screen.getByRole('tab', { name: 'Security', description: 'Personal' }).focus(); + await user.keyboard('{ArrowDown}'); + + expect(screen.getByRole('tab', { name: 'General' })).toHaveFocus(); + }); + + it('has no accessibility violations', async () => { + const { container } = render(); + expect((await axe(container)).violations).toEqual([]); + }); + }); + describe('compact', () => { let observe: ((width: number) => void) | null = null; const original = globalThis.ResizeObserver; @@ -367,6 +428,25 @@ describe('Profile', () => { await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument()); }); + it('groups the listbox options under each group label', async () => { + const user = userEvent.setup(); + render( + + + , + ); + act(() => observe?.(2)); + + await user.click(screen.getByRole('combobox', { name: 'Account' })); + + expect(screen.getByRole('group', { name: 'Acme Inc' })).toContainElement( + screen.getByRole('option', { name: 'General' }), + ); + expect(screen.getByRole('group', { name: 'Personal' })).toContainElement( + screen.getByRole('option', { name: 'Account' }), + ); + }); + it('opens the listbox from the keyboard, picks with the arrows, and keeps focus on the title', async () => { const user = userEvent.setup(); function Controlled() { diff --git a/packages/mosaic/src/components/profile/profile.tsx b/packages/mosaic/src/components/profile/profile.tsx index cae4c889539..0f12cbf3db1 100644 --- a/packages/mosaic/src/components/profile/profile.tsx +++ b/packages/mosaic/src/components/profile/profile.tsx @@ -232,6 +232,8 @@ type NavItemMode = 'tab' | 'option'; // `Profile.Nav` children render again as the select's options. const NavItemModeContext = React.createContext('tab'); +const NavGroupLabelContext = React.createContext(undefined); + function BackButton() { const m = useMessages('profile'); return ( @@ -267,7 +269,7 @@ function NavBranding() { } /** - * Children are `Profile.NavItem`s only. Wide, they render as a tablist; compact, as the options of + * Children are `Profile.NavItem`s, or `Profile.NavGroup`s of them. Wide, they render as a tablist; compact, as the options of * the page title's select, or a tablist in a sheet on a phone. */ const Nav = React.forwardRef(function ProfileNav( @@ -340,6 +342,87 @@ const Nav = React.forwardRef(function ProfileNav( ); }); +export interface ProfileNavGroupProps extends MosaicComponentProps<'div'> { + /** Names the group above its items. */ + label: React.ReactNode; +} + +/** + * A labelled run of `Profile.NavItem`s. As tabs, the label describes each item; as options, it names + * a listbox group. + */ +const NavGroup = React.forwardRef(function ProfileNavGroup( + { label, children, xstyle, ...rest }, + ref, +) { + const mode = React.useContext(NavItemModeContext); + const groupProps = mergeStyleProps( + themeProps('profile-nav-group'), + stylex.props(reset.base, styles.navGroup, xstyle), + rest, + ); + const labelProps = mergeStyleProps( + themeProps('profile-nav-group-label'), + stylex.props(reset.base, styles.navGroupLabel), + ); + if (mode === 'option') { + return ( + + {label} + {children} + + ); + } + return ( + + {children} + + ); +}); + +interface TabsNavGroupProps extends Omit { + labelProps: React.ComponentProps<'div'>; +} + +const TabsNavGroup = React.forwardRef(function TabsNavGroup( + { label, labelProps, children, render, ...rest }, + ref, +) { + const labelId = React.useId(); + return ( + + {useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...rest, + children: ( + <> +
+ {label} +
+ {children} + + ), + }, + })} +
+ ); +}); + export interface ProfileNavItemProps extends MosaicComponentProps<'button'> { value: string; icon?: React.ReactNode; @@ -355,6 +438,7 @@ const NavItem = React.forwardRef(functio ) { const { compact, closeNav } = useProfileContext('Profile.NavItem'); const mode = React.useContext(NavItemModeContext); + const groupLabelId = React.useContext(NavGroupLabelContext); const styleProps = mergeStyleProps( themeProps('profile-nav-item'), stylex.props(reset.base, styles.navItem, focusOutline.visible, mode === 'option' && styles.navItemOption, xstyle), @@ -405,6 +489,7 @@ const NavItem = React.forwardRef(functio ref={ref} value={value} disabled={disabled} + aria-describedby={groupLabelId} render={render} onClick={event => { onClick?.(event); @@ -596,4 +681,4 @@ const ContentPanel = React.forwardRef( * * ``` */ -export const Profile = { Root, Title, Nav, NavItem, Content, ContentPanel }; +export const Profile = { Root, Title, Nav, NavGroup, NavItem, Content, ContentPanel }; diff --git a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx index ff62d3bb637..289f19ff8ce 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.layout.test.tsx @@ -1,10 +1,22 @@ import { describe, expect, it } from 'vitest'; -import { getAvailableOrganizationProfilePages } from '../organization-profile.layout'; -import type { OrganizationProfilePages } from '../organization-profile.types'; +import { getAvailableOrganizationProfilePages, resolveOrganizationProfilePages } from '../organization-profile.layout'; +import type { CustomOrganizationProfilePage, OrganizationProfilePages } from '../organization-profile.types'; const general: OrganizationProfilePages['general'] =

General

; +describe('resolveOrganizationProfilePages', () => { + it('keeps the first custom page for a repeated path', () => { + const audit: CustomOrganizationProfilePage = { label: 'Audit', path: 'audit', content: null }; + const otherAudit: CustomOrganizationProfilePage = { label: 'Other audit', path: 'audit', content: null }; + + expect(resolveOrganizationProfilePages(['general'], [audit, otherAudit])).toEqual([ + { id: 'general' }, + { id: 'audit', custom: audit }, + ]); + }); +}); + describe('getAvailableOrganizationProfilePages', () => { it('lists api keys given content', () => { expect(getAvailableOrganizationProfilePages({ general, apiKeys:

API keys

})).toEqual(['general', 'apiKeys']); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts index 096c7acfd19..e2d14f244bd 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts @@ -5,6 +5,7 @@ import { Key } from '../../icons/glyphs/key'; import { SecurityLockSquare } from '../../icons/glyphs/security-lock-square'; import { Users } from '../../icons/glyphs/users'; import { applyOrder } from '../../utils/apply-order'; +import { withUniquePaths } from '../profile-pages/profile-pages.layout'; import type { CustomOrganizationProfilePage, OrganizationProfilePageId, @@ -43,14 +44,15 @@ export function resolveOrganizationProfilePages( customPages: readonly CustomOrganizationProfilePage[] = [], order?: readonly string[], ): OrganizationProfileNavEntry[] { - const customById = new Map(customPages.map(page => [page.path, page])); + const uniqueCustomPages = withUniquePaths(customPages); + const customById = new Map(uniqueCustomPages.map(page => [page.path, page])); const builtInIds = new Set(builtIn); const entries: OrganizationProfileNavEntry[] = [ ...builtIn.map((id): OrganizationProfileNavEntry => { const custom = customById.get(id); return custom ? { id, custom } : { id }; }), - ...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })), + ...uniqueCustomPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })), ]; return applyOrder(order, entries, entry => entry.id); } diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 966da9f76e4..a3b5349aaae 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -7,6 +7,7 @@ import type { APIKeysPageProps } from '../api-keys/api-keys-table'; import { useOrganizationProfileModel } from './organization-profile.model'; import type { OrganizationProfileOptions } from './organization-profile.provider'; import { OrganizationProfileProvider } from './organization-profile.provider'; +import type { OrganizationProfilePageId, OrganizationProfilePages } from './organization-profile.types'; import type { OrganizationProfileViewProps } from './organization-profile.view'; import { OrganizationProfileView } from './organization-profile.view'; import { OrganizationProfileApiKeysPanel } from './organization-profile-api-keys-panel'; @@ -83,7 +84,7 @@ export function OrganizationProfile({ defaultPage, afterLeaveOrganizationUrl, fallback, - apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {}, + apiKeysProps, }: OrganizationProfileProps) { const model = useOrganizationProfileModel(); const [activePage, setActivePage] = useState( @@ -105,15 +106,24 @@ export function OrganizationProfile({ onPageChange={setActivePage} customPages={customPages} pageOrder={pageOrder} - pages={{ - general: , - members: model.pages.includes('members') ? : undefined, - apiKeys: - model.pages.includes('apiKeys') && !hideAPIKeys ? ( - - ) : undefined, - }} + pages={organizationProfilePanels(model.pages, apiKeysProps)} /> ); } + +/** + * The organization's built-in panels, for the pages the instance has turned on. `apiKeysProps.hide` + * leaves the API keys panel out; the rest of `apiKeysProps` goes to it. + */ +export function organizationProfilePanels( + available: readonly OrganizationProfilePageId[], + { hide: hideAPIKeys = false, ...apiKeysProps }: APIKeysPageProps = {}, +): OrganizationProfilePages { + return { + general: , + members: available.includes('members') ? : undefined, + apiKeys: + available.includes('apiKeys') && !hideAPIKeys ? : undefined, + }; +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx index 1d4e47e7140..9f55147fe40 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.view.tsx @@ -2,9 +2,9 @@ import { logger } from '@clerk/shared/logger'; import React from 'react'; import { Icon } from '../../components/icon'; -import type { ProfileRootProps } from '../../components/profile'; -import { Profile } from '../../components/profile'; import { useMessages } from '../../localization'; +import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view'; +import { ProfilePagesView } from '../profile-pages/profile-pages.view'; import { getAvailableOrganizationProfilePages, ORGANIZATION_PROFILE_PAGE_ICONS, @@ -16,71 +16,66 @@ import type { OrganizationProfilePages, } from './organization-profile.types'; -export interface OrganizationProfileViewProps extends Omit { - label?: string; - activePage: OrganizationProfilePageId | (string & {}); +/** What the organization's navigation is built from. */ +export interface OrganizationProfilePageListOptions { + /** The built-in pages. One left undefined is left out of the navigation. */ pages: OrganizationProfilePages; + /** Pages of the consumer's own, added to the navigation after the built-ins. */ customPages?: readonly CustomOrganizationProfilePage[]; + /** + * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids + * left out keep their default place behind the ones named. + */ pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[]; +} + +export interface OrganizationProfileViewProps + extends + Omit, + OrganizationProfilePageListOptions { + label?: string; + activePage: OrganizationProfilePageId | (string & {}); onPageChange: (page: OrganizationProfilePageId | (string & {})) => void; } export const OrganizationProfileView = React.forwardRef( - function OrganizationProfileView({ activePage, pages, customPages, pageOrder, onPageChange, label, ...rest }, ref) { + function OrganizationProfileView({ pages, customPages, pageOrder, label, ...rest }, ref) { const m = useMessages('organizationProfile'); - const entries = resolveOrganizationProfilePages( - getAvailableOrganizationProfilePages(pages), - customPages, - pageOrder, - ); - const firstPage = entries[0]; - if (!firstPage) { + const list = organizationProfilePageList({ pages, customPages, pageOrder }, m.pages); + if (list.length === 0) { logger.warnOnce('[Clerk] OrganizationProfile has no pages to show.'); } - const resolvedEntry = entries.find(entry => entry.id === activePage) ?? firstPage; - const resolvedPage = resolvedEntry?.id ?? 'general'; return ( - - {label ?? m.label} - - {entries.map(entry => ( - - ) - } - > - {entry.custom ? entry.custom.label : m.pages[entry.id]} - - ))} - - - {entries.map(entry => ( - - {entry.custom ? entry.custom.content : pages[entry.id]} - - ))} - - + /> ); }, ); + +/** The organization's navigation, in order: the built-ins it was given content for, then the consumer's pages. */ +export function organizationProfilePageList( + { pages, customPages, pageOrder }: OrganizationProfilePageListOptions, + labels: Record, +): ProfilePage[] { + return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( + entry => + entry.custom + ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } + : { + id: entry.id, + label: labels[entry.id], + icon: ( + + ), + content: pages[entry.id], + }, + ); +} diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts b/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts new file mode 100644 index 00000000000..96e8f6b5809 --- /dev/null +++ b/packages/mosaic/src/features/profile-pages/profile-pages.layout.ts @@ -0,0 +1,15 @@ +import { logger } from '@clerk/shared/logger'; + +export function withUniquePaths(pages: readonly Page[]): Page[] { + const seen = new Set(); + return pages.filter(page => { + if (seen.has(page.path)) { + logger.warnOnce( + `[Clerk] Duplicate path "${page.path}" found in custom pages. This may cause unexpected behavior.`, + ); + return false; + } + seen.add(page.path); + return true; + }); +} diff --git a/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx new file mode 100644 index 00000000000..710176849c3 --- /dev/null +++ b/packages/mosaic/src/features/profile-pages/profile-pages.view.tsx @@ -0,0 +1,89 @@ +import type { ReactNode } from 'react'; +import React from 'react'; + +import type { ProfileRootProps } from '../../components/profile'; +import { Profile } from '../../components/profile'; + +/** One destination in a profile's navigation, with the page it opens. */ +export interface ProfilePage { + id: string; + label: string; + icon: ReactNode; + content: ReactNode; +} + +/** A run of pages in the navigation. A labelled group is set apart under its label. */ +export interface ProfilePageGroup { + label?: string; + pages: readonly ProfilePage[]; +} + +export interface ProfilePagesViewProps extends Omit { + /** Names the surface, and the dialog it opens in. */ + label: string; + /** The navigation, in order. A group without a label lists its pages without a heading. */ + groups: readonly ProfilePageGroup[]; + /** The open page's id. One the navigation does not list opens the first page instead. */ + activePage: string; + /** Called with a page's id when the user picks it from the navigation. */ + onPageChange: (page: string) => void; +} + +/** A `Profile` built from groups of pages: their navigation, and the active page beside it. */ +export const ProfilePagesView = React.forwardRef(function ProfilePagesView( + { label, groups, activePage, onPageChange, ...rest }, + ref, +) { + const pages = groups.flatMap(group => group.pages); + const activeEntry = pages.find(page => page.id === activePage) ?? pages[0]; + + return ( + + {label} + + {groups.map((group, index) => + group.label === undefined ? ( + + ) : ( + + + + ), + )} + + + {pages.map(page => ( + + {page.content} + + ))} + + + ); +}); + +function NavItems({ pages }: { pages: readonly ProfilePage[] }) { + return pages.map(page => ( + + {page.label} + + )); +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx index bec8fb3ac97..d73f1de4307 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx @@ -1,11 +1,18 @@ -import { screen, within } from '@testing-library/react'; +import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeAll, describe, expect, it } from 'vitest'; import { page } from 'vitest/browser'; import { openDialog } from '../../../__tests__/feature/dialog'; import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; -import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEnvironment, + fapiMembership, + fapiOrganization, + fapiSession, + fapiUser, +} from '../../../__tests__/feature/fapi'; import { openMenu } from '../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfile } from '../user-profile'; @@ -132,3 +139,146 @@ describe('UserProfile', () => { expect(screen.getByRole('menuitem', { name: 'Reconnect' })).toBeEnabled(); }); }); + +describe('UserProfile with an active organization', () => { + const acme = fapiOrganization({ id: 'org_1', name: 'Acme' }); + + function serveWithOrganization({ + permissions = ['org:sys_memberships:read'], + userAPIKeysEnabled = false, + orgAPIKeysEnabled = false, + activeOrganizationId = acme.id, + }: { + permissions?: string[]; + userAPIKeysEnabled?: boolean; + orgAPIKeysEnabled?: boolean; + activeOrganizationId?: string | null; + } = {}) { + const memberships = [fapiMembership(acme, { permissions })]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + return serveFapi({ + environment: fapiEnvironment({ + api_keys_settings: { user_api_keys_enabled: userAPIKeysEnabled, orgs_api_keys_enabled: orgAPIKeysEnabled }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: activeOrganizationId })]), + memberships, + }); + } + + function tabsDescribedBy(description: string) { + return screen + .getAllByRole('tab') + .filter(tab => tab.getAttribute('aria-describedby') !== null) + .filter(tab => document.getElementById(tab.getAttribute('aria-describedby') ?? '')?.textContent === description) + .map(tab => tab.textContent); + } + + it('lists the organization pages under Organization after the account pages', async () => { + serveWithOrganization(); + await renderWithClerk(); + expect(tabsDescribedBy('Account')).toEqual(['Account', 'Security']); + expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members']); + }); + + it('lists only the organization pages the user may see', async () => { + serveWithOrganization({ permissions: [] }); + await renderWithClerk(); + expect(tabsDescribedBy('Organization')).toEqual(['General']); + }); + + it.each([ + { hide: true, tabs: ['General'] }, + { hide: false, tabs: ['General', 'API Keys'] }, + ])('lists the organization API keys only when they are not hidden: $hide', async ({ hide, tabs }) => { + serveWithOrganization({ orgAPIKeysEnabled: true, permissions: ['org:sys_api_keys:read'] }); + await renderWithClerk(); + expect(tabsDescribedBy('Organization')).toEqual(tabs); + }); + + it('sends the user to afterLeaveOrganizationUrl after leaving the organization', async () => { + const fapi = serveWithOrganization(); + const { navigate } = await renderWithClerk( + , + ); + const user = userEvent.setup(); + const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), { + role: 'alertdialog', + }); + + await user.type(within(dialog).getByRole('textbox'), 'Acme'); + await user.click(within(dialog).getByRole('button', { name: 'Leave organization' })); + + await waitFor(() => expect(fapi.memberships).toHaveLength(0)); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); + }); + + it('lists only the account pages in user mode', async () => { + serveWithOrganization(); + await renderWithClerk(); + expect(tabNames()).toEqual(['Account', 'Security']); + }); + + it('lists only the account pages without an active organization', async () => { + serveWithOrganization({ activeOrganizationId: null }); + await renderWithClerk(); + expect(tabNames()).toEqual(['Account', 'Security']); + }); + + it('opens on an organization page by its prefixed id', async () => { + serveWithOrganization(); + await renderWithClerk(); + expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('heading', { level: 3, name: 'Members' })).toBeVisible(); + }); + + it('opens the organization page that shares an account page name', async () => { + serveWithOrganization(); + await renderWithClerk( + Organization security

}], + }} + />, + ); + const user = userEvent.setup(); + + await user.click(screen.getByRole('tab', { name: 'Security', description: 'Organization' })); + expect(screen.getByText('Organization security')).toBeVisible(); + }); + + it('skips an account page whose path is an organization page id', async () => { + serveWithOrganization(); + await renderWithClerk( + Imposter content

}]} + />, + ); + + expect(tabNames()).toEqual(['Account', 'Security', 'General', 'Members']); + expect(screen.getByRole('tab', { name: 'General', description: 'Organization' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.queryByText('Imposter content')).not.toBeInTheDocument(); + }); + + it('passes the organization options to its pages', async () => { + serveWithOrganization(); + await renderWithClerk( + Audit content

}], + pageOrder: ['audit'], + }} + />, + ); + expect(tabsDescribedBy('Organization')).toEqual(['Audit log', 'General', 'Members']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts index 070f1c16343..0e3b207b2ed 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.layout.test.ts @@ -25,6 +25,17 @@ describe('getAvailableUserProfilePages', () => { }); describe('resolveUserProfilePages', () => { + it('keeps the first custom page for a repeated path', () => { + const otherTerms: CustomProfilePage = { label: 'Other terms', path: 'terms', content: null }; + const otherAccount: CustomProfilePage = { label: 'Other account', path: 'account', content: null }; + const account: CustomProfilePage = { label: 'My account', path: 'account', content: null }; + + expect(resolveUserProfilePages(['account'], [account, terms, otherTerms, otherAccount])).toEqual([ + { id: 'account', custom: account }, + { id: 'terms', custom: terms }, + ]); + }); + it('lists the built-ins, then the custom pages by path', () => { expect(resolveUserProfilePages(['account', 'security'], [terms, help])).toEqual([ { id: 'account' }, diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 01c3f22125e..333f6968b00 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -194,4 +194,66 @@ describe('UserProfileView', () => { expect(warn).toHaveBeenCalledWith('[Clerk] UserProfile has no pages to show.'); }); + describe('with an organization', () => { + const organization: NonNullable = { + pages: { general:

General content

, apiKeys:

Organization API keys content

}, + }; + + function tabs() { + return screen.getAllByRole('tab').map(tab => tab.textContent); + } + + it('groups the organization pages under Organization, after the account pages', () => { + renderView({ organization }); + + expect(tabs()).toEqual(['Account', 'Security', 'Billing', 'API Keys', 'General', 'API Keys']); + expect(screen.getByRole('tab', { name: 'Security' })).toHaveAccessibleDescription('Account'); + expect(screen.getByRole('tab', { name: 'General' })).toHaveAccessibleDescription('Organization'); + }); + + it('gives the organization pages their own ids', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderView({ organization, onPageChange }); + + await user.click(screen.getByRole('tab', { name: 'General' })); + + expect(onPageChange).toHaveBeenCalledWith('organization/general'); + }); + + it('opens the organization page a shared id names, not the account one', () => { + renderView({ organization, activePage: 'organization/apiKeys' }); + + expect(screen.getByRole('tab', { name: 'API Keys', description: 'Organization' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByText('Organization API keys content')).toBeVisible(); + expect(screen.queryByText('API keys content')).not.toBeInTheDocument(); + }); + + it('adds and orders custom pages within the organization group', async () => { + const onPageChange = vi.fn(); + const user = userEvent.setup(); + renderView({ + onPageChange, + organization: { + ...organization, + customPages: [{ label: 'Audit log', path: 'audit', content:

Audit content

}], + pageOrder: ['audit'], + }, + }); + + expect(tabs()).toEqual(['Account', 'Security', 'Billing', 'API Keys', 'Audit log', 'General', 'API Keys']); + await user.click(screen.getByRole('tab', { name: 'Audit log' })); + expect(onPageChange).toHaveBeenCalledWith('organization/audit'); + }); + + it('falls back to the first page once the organization is gone', () => { + renderView({ activePage: 'organization/general' }); + + expect(screen.getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(document.querySelector('.cl-profile-nav-group')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile.layout.ts b/packages/mosaic/src/features/user-profile/user-profile.layout.ts index e56d92b6e68..2410672bf9e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts @@ -4,11 +4,15 @@ import { CreditCard } from '../../icons/glyphs/credit-card'; import { ShieldCheck } from '../../icons/glyphs/shield-check'; import { UserCircle } from '../../icons/glyphs/user-circle'; import { applyOrder } from '../../utils/apply-order'; +import { withUniquePaths } from '../profile-pages/profile-pages.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; /** The built-in pages in the order the profile lists them before a consumer reorders anything. */ export const USER_PROFILE_PAGE_IDS: readonly UserProfilePageId[] = ['account', 'security', 'billing', 'apiKeys']; +/** Sets an organization page's id apart from an account page's when the profile shows both. */ +export const ORGANIZATION_PAGE_PREFIX = 'organization/'; + export const USER_PROFILE_PAGE_ICONS: Record = { account: UserCircle, security: ShieldCheck, @@ -39,14 +43,15 @@ export function resolveUserProfilePages( customPages: readonly CustomProfilePage[] = [], order?: readonly string[], ): UserProfileNavEntry[] { - const customById = new Map(customPages.map(page => [page.path, page])); + const uniqueCustomPages = withUniquePaths(customPages); + const customById = new Map(uniqueCustomPages.map(page => [page.path, page])); const builtInIds = new Set(builtIn); const entries: UserProfileNavEntry[] = [ ...builtIn.map((id): UserProfileNavEntry => { const custom = customById.get(id); return custom ? { id, custom } : { id }; }), - ...customPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })), + ...uniqueCustomPages.filter(page => !builtInIds.has(page.path)).map(page => ({ id: page.path, custom: page })), ]; return applyOrder(order, entries, entry => entry.id); } diff --git a/packages/mosaic/src/features/user-profile/user-profile.messages.ts b/packages/mosaic/src/features/user-profile/user-profile.messages.ts index 7f95082be45..fd1b9b141bf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.messages.ts @@ -1,6 +1,10 @@ export const userProfileMessages = { /** Names the surface: its navigation landmark, and the dialog it opens in. */ label: 'User profile', + /** Labels the account's pages when the organization's are listed beside them. */ + accountGroup: 'Account', + /** Labels the organization's pages, listed after the account's. */ + organizationGroup: 'Organization', pages: { account: 'Account', security: 'Security', diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts index b9b9e03e90a..73ea32c6e4e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.types.ts @@ -3,7 +3,7 @@ import type { UserProfileProps } from './user-profile'; export interface UserProfileModalOptions extends Pick< UserProfileProps, - 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' | 'organizationProfileProps' > { page?: UserProfileProps['defaultPage']; } diff --git a/packages/mosaic/src/features/user-profile/user-profile.tsx b/packages/mosaic/src/features/user-profile/user-profile.tsx index 2d5347c1a34..1591a2ace70 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -4,7 +4,12 @@ import type { ReactNode } from 'react'; import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; +import type { OrganizationProfileProps } from '../organization-profile/organization-profile'; +import { organizationProfilePanels } from '../organization-profile/organization-profile'; +import { useOrganizationProfileModel } from '../organization-profile/organization-profile.model'; +import { OrganizationProfileProvider } from '../organization-profile/organization-profile.provider'; import { useUserProfilePages } from '../switcher/switcher.pages'; +import type { SwitcherMode } from '../switcher/switcher.types'; import type { UserProfileOptions } from './user-profile.provider'; import { UserProfileProvider } from './user-profile.provider'; import type { UserProfileViewProps } from './user-profile.view'; @@ -14,6 +19,12 @@ import { UserProfileProfilePanel } from './user-profile-profile-panel'; import { UserProfileSecurityPanel } from './user-profile-security-panel'; import { useUserProfileUserModel } from './user-profile-user.model'; +/** + * Which profiles the surface carries. `combined` adds the active organization's pages after the + * account's; `user` shows the account's alone, even when an organization is active. + */ +export type UserProfileMode = Exclude; + /** Everything `` takes: your own pages and their order, the page it opens on, and the OAuth scopes it asks for. */ export interface UserProfileProps extends Pick, Pick { @@ -34,11 +45,26 @@ export interface UserProfileProps * keys a page lists, and `showDescription` adds an optional description when creating a key. */ apiKeysProps?: APIKeysPageProps; + /** + * Which profiles the surface carries: the account's and the active organization's, or the + * account's alone. + * + * @default 'combined' + */ + mode?: UserProfileMode; + /** + * Options for the organization's pages in `combined` mode: your own pages and their order, the API + * keys page, and where leaving the organization lands. + */ + organizationProfileProps?: Pick< + OrganizationProfileProps, + 'customPages' | 'pageOrder' | 'apiKeysProps' | 'afterLeaveOrganizationUrl' + >; } /** * The signed-in user's profile: Account, Security, and API keys when the instance enables them, - * with your own pages alongside. It reads the user from Clerk, so it takes no data. It renders + * with your own pages alongside, and the active organization's pages after them. It reads the user from Clerk, so it takes no data. It renders * `fallback` until Clerk answers, and nothing at all when nobody is signed in. * * @example @@ -54,12 +80,29 @@ export interface UserProfileProps * ``` * * @example - * `defaultPage` picks the page the profile opens on without moving it in the navigation. + * `defaultPage` picks the page the profile opens on without moving it in the navigation. An + * organization page's id is prefixed with `organization/`. * ```tsx * * ``` * * @example + * `mode='user'` leaves the active organization's pages out. + * ```tsx + * + * ``` + * + * @example + * `organizationProfileProps` adds your own pages to the organization's group. + * ```tsx + * , content: }], + * }} + * /> + * ``` + * + * @example * `customPages` adds your own pages; `pageOrder` places them among the built-in ones. * ```tsx * , + security: , + apiKeys: + builtInPages.includes('apiKeys') && !hideAPIKeys ? : undefined, + }, + }; + return ( + {mode === 'user' ? ( + + ) : ( + + )} + + ); +} + +function CombinedUserProfileView({ + organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {}, + ...viewProps +}: UserProfileViewProps & Pick) { + const model = useOrganizationProfileModel(); + + return ( + , - security: , - apiKeys: - builtInPages.includes('apiKeys') && !hideAPIKeys ? ( - - ) : undefined, - }} + {...viewProps} + organization={ + model.status === 'ready' + ? { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder } + : undefined + } /> - + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile.view.tsx b/packages/mosaic/src/features/user-profile/user-profile.view.tsx index 320a7d8472a..27994a99272 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.view.tsx @@ -2,17 +2,31 @@ import { logger } from '@clerk/shared/logger'; import React from 'react'; import { Icon } from '../../components/icon'; -import type { ProfileRootProps } from '../../components/profile'; -import { Profile } from '../../components/profile'; import { useMessages } from '../../localization'; -import { getAvailableUserProfilePages, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS } from './user-profile.layout'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; +import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.view'; +import { organizationProfilePageList } from '../organization-profile/organization-profile.view'; +import type { ProfilePage, ProfilePageGroup, ProfilePagesViewProps } from '../profile-pages/profile-pages.view'; +import { ProfilePagesView } from '../profile-pages/profile-pages.view'; +import { + getAvailableUserProfilePages, + ORGANIZATION_PAGE_PREFIX, + resolveUserProfilePages, + USER_PROFILE_PAGE_ICONS, +} from './user-profile.layout'; import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types'; -export interface UserProfileViewProps extends Omit { +export interface UserProfileViewProps extends Omit< + ProfilePagesViewProps, + 'label' | 'groups' | 'activePage' | 'onPageChange' +> { /** Names the surface, and the dialog it opens in. Defaults to the `userProfile.label` message. */ label?: string; - /** The open page: a built-in page's id, or a custom page's `path`. */ - activePage: UserProfilePageId | (string & {}); + /** + * The open page: a built-in page's id, or a custom page's `path`. An organization page's id is + * prefixed with `organization/`. + */ + activePage: UserProfilePageId | `organization/${OrganizationProfilePageId}` | (string & {}); pages: UserProfilePages; /** Pages of the consumer's own, added to the navigation after the built-ins. */ customPages?: readonly CustomProfilePage[]; @@ -21,68 +35,77 @@ export interface UserProfileViewProps extends Omit void; + /** Adds the organization's pages, in a group of their own. */ + organization?: OrganizationProfilePageListOptions; + onPageChange: (page: UserProfileViewProps['activePage']) => void; } /** * The user profile as a `Profile`: the built-in pages the instance has content for, the - * consumer's own pages after them, in the order asked for. An `activePage` the navigation does - * not list falls back to the first one, so a page turned off by the environment cannot leave the - * surface blank. + * consumer's own pages after them, in the order asked for, and the organization's pages in a group + * after those when there is one. An `activePage` the navigation does not list falls back to the + * first one, so a page turned off by the environment cannot leave the surface blank. */ export const UserProfileView = React.forwardRef(function UserProfileView( - { activePage, pages, customPages, pageOrder, onPageChange, label, ...rest }, + { pages, customPages, pageOrder, organization, label, ...rest }, ref, ) { const m = useMessages('userProfile'); - const entries = resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder); - const firstPage = entries[0]; - if (!firstPage) { + const organizationLabels = useMessages('organizationProfile').pages; + const accountCustomPages = customPages?.filter(page => { + if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) { + return true; + } + logger.warnOnce( + `[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`, + ); + return false; + }); + const accountPages = userProfilePageList({ pages, customPages: accountCustomPages, pageOrder }, m.pages); + const groups: ProfilePageGroup[] = organization + ? [ + { label: m.accountGroup, pages: accountPages }, + { + label: m.organizationGroup, + pages: organizationProfilePageList(organization, organizationLabels).map(page => ({ + ...page, + id: `${ORGANIZATION_PAGE_PREFIX}${page.id}`, + })), + }, + ] + : [{ pages: accountPages }]; + if (accountPages.length === 0) { logger.warnOnce('[Clerk] UserProfile has no pages to show.'); } - const resolvedEntry = entries.find(entry => entry.id === activePage) ?? firstPage; - const resolvedPage = resolvedEntry?.id ?? 'account'; return ( - - {label ?? m.label} - - {entries.map(entry => ( - - ) - } - > - {entry.custom ? entry.custom.label : m.pages[entry.id]} - - ))} - - - {entries.map(entry => ( - - {entry.custom ? entry.custom.content : pages[entry.id]} - - ))} - - + /> ); }); + +/** The account's navigation, in order: the built-ins it was given content for, then the consumer's pages. */ +function userProfilePageList( + { pages, customPages, pageOrder }: Pick, + labels: Record, +): ProfilePage[] { + return resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder).map(entry => + entry.custom + ? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content } + : { + id: entry.id, + label: labels[entry.id], + icon: ( + + ), + content: pages[entry.id], + }, + ); +} diff --git a/packages/mosaic/src/primitives/select/README.md b/packages/mosaic/src/primitives/select/README.md index 3b394a3e46a..f4617d5c905 100644 --- a/packages/mosaic/src/primitives/select/README.md +++ b/packages/mosaic/src/primitives/select/README.md @@ -104,6 +104,8 @@ By default, the selected option visually aligns with the trigger. Disable this f | `Select.Positioner` | `
` | Floating positioned container | | `Select.Popup` | `
` | Visual wrapper for the option list | | `Select.Option` | ` + +
); }