diff --git a/.changeset/mosaic-profile-modal-oauth-return.md b/.changeset/mosaic-profile-modal-oauth-return.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-modal-oauth-return.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-profile-modal-return-options.md b/.changeset/mosaic-profile-modal-return-options.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-profile-modal-return-options.md @@ -0,0 +1,2 @@ +--- +--- 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/.changeset/mosaic-user-profile-export.md b/.changeset/mosaic-user-profile-export.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-export.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/thin-trains-lose.md b/.changeset/thin-trains-lose.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/thin-trains-lose.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx new file mode 100644 index 00000000000..3efd034696b --- /dev/null +++ b/integration/templates/next-app-router-mosaic/src/app/organization-profile/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic'; + +export default function Page() { + return ( + + custom-page-content

}]} + /> +
+ ); +} diff --git a/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx new file mode 100644 index 00000000000..a23e6aeb5d6 --- /dev/null +++ b/integration/templates/next-app-router-mosaic/src/app/user-profile/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { MosaicProvider, UserProfile } from '@clerk/mosaic'; + +export default function Page() { + return ( + + custom-page-content

}]} /> +
+ ); +} diff --git a/integration/tests/mosaic/organization-profile.test.ts b/integration/tests/mosaic/organization-profile.test.ts new file mode 100644 index 00000000000..a7d0cda9388 --- /dev/null +++ b/integration/tests/mosaic/organization-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed'); + } + }); + + const path = '/organization-profile'; + const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + await expect(tab(page, 'General')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'General')).toHaveCount(0); + }); + + test('opens on the general page with the active organization', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('switches to the members page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Members').click(); + + await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); diff --git a/integration/tests/mosaic/organization-switcher.test.ts b/integration/tests/mosaic/organization-switcher.test.ts index 0b091106da6..c19fa9f8e9c 100644 --- a/integration/tests/mosaic/organization-switcher.test.ts +++ b/integration/tests/mosaic/organization-switcher.test.ts @@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })( }); test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); await signIn({ page, context }); await trigger(page).click(); await popup(page).getByRole('button', { name: 'Settings' }).click(); - const profile = page.locator('.cl-organizationProfile-root'); - await profile.getByText('Custom page').click(); + await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }, diff --git a/integration/tests/mosaic/user-button.test.ts b/integration/tests/mosaic/user-button.test.ts index c4d5e6ed310..2f0f51065ef 100644 --- a/integration/tests/mosaic/user-button.test.ts +++ b/integration/tests/mosaic/user-button.test.ts @@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User async function openProfileSettings(page: Page) { await popup(page).getByRole('button', { name: 'Settings' }).click(); const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' }); - await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor(); + const profile = page.getByRole('tab', { name: 'Account', exact: true }); + await profileSettings.or(profile).first().waitFor(); if (await profileSettings.isVisible()) { await profileSettings.click(); } @@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User await expect(page.getByText('custom-link-target')).toBeVisible(); }); + test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }); + + await trigger(page).click(); + await openProfileSettings(page); + + await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.cl-userProfile-root')).toHaveCount(0); + await expect(popup(page)).toBeHidden(); + }); + test('renders a custom page inside the UserProfile modal', async ({ page, context }) => { - const u = await signIn({ page, context }, '/custom'); + await page.setViewportSize({ width: 1280, height: 800 }); + await signIn({ page, context }, '/custom'); await trigger(page).click(); await openProfileSettings(page); - await u.po.userProfile.waitForUserProfileModal(); - await page.locator('.cl-userProfile-root').getByText('Custom page').click(); + await page.getByRole('tab', { name: 'Custom page', exact: true }).click(); await expect(page.getByText('custom-page-content')).toBeVisible(); }); }); diff --git a/integration/tests/mosaic/user-profile.test.ts b/integration/tests/mosaic/user-profile.test.ts new file mode 100644 index 00000000000..3eab59203f2 --- /dev/null +++ b/integration/tests/mosaic/user-profile.test.ts @@ -0,0 +1,82 @@ +import type { Organization } from '@clerk/backend'; +import type { BrowserContext, Page } from '@playwright/test'; +import { expect, test } from '@playwright/test'; + +import type { FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeUser: FakeUser; + let organization: Organization; + const cleanups: Array<() => Promise> = []; + + test.beforeAll(async () => { + const u = await createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser(test); + cleanups.push(() => fakeUser.deleteIfExists()); + const user = await u.services.users.createBapiUser(fakeUser); + organization = await u.services.clerk.organizations.createOrganization({ + name: `Mosaic ${Date.now()}`, + createdBy: user.id, + }); + cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id)); + }); + + test.afterAll(async () => { + const results = await Promise.allSettled(cleanups.map(cleanup => cleanup())); + await app.teardown(); + const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : [])); + if (failures.length > 0) { + throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed'); + } + }); + + const path = '/user-profile'; + const tab = (page: Page, name: string) => page.getByRole('tab', { name, exact: true }); + + async function signIn({ page, context }: { page: Page; context: BrowserContext }) { + const u = await createTestUtils({ app, page, context }); + await page.setViewportSize({ width: 1280, height: 800 }); + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.page.goToRelative(path); + await u.po.expect.toBeSignedIn(); + await expect(tab(page, 'Account')).toBeVisible(); + return u; + } + + test('renders nothing when signed out', async ({ page, context }) => { + const u = await createTestUtils({ app, page, context }); + await u.page.goToRelative(path); + await u.page.waitForClerkJsLoaded(); + + await expect(tab(page, 'Account')).toHaveCount(0); + }); + + test('opens on the account page with the signed-in user', async ({ page, context }) => { + await signIn({ page, context }); + + await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(fakeUser.email)).toBeVisible(); + }); + + test("lists the active organization's pages after the account's", async ({ page, context }) => { + await signIn({ page, context }); + + await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id); + await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id); + + await tab(page, 'General').click(); + await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByText(organization.name).first()).toBeVisible(); + }); + + test('renders a custom page', async ({ page, context }) => { + await signIn({ page, context }); + + await tab(page, 'Custom page').click(); + await expect(page.getByText('custom-page-content')).toBeVisible(); + }); +}); 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.feature.test.tsx b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx index afc43fae780..f52f2cadd02 100644 --- a/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx +++ b/packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx @@ -1,4 +1,4 @@ -import { screen, waitFor, within } from '@testing-library/react'; +import { act, 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'; @@ -177,4 +177,26 @@ describe('OrganizationProfile', () => { await waitFor(() => expect(fapi.memberships).toHaveLength(0)); await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations')); }); + + it('keeps the shown page after the open one leaves the navigation', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex', slug: 'globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: [] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(tabNames()).toEqual(['General'])); + await act(() => clerk.setActive({ organization: acme.id })); + await waitFor(() => expect(tabNames()).toEqual(['General', 'Members'])); + + expect(profileNav().getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + }); }); 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.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts new file mode 100644 index 00000000000..4f1542df339 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.controller.ts @@ -0,0 +1,30 @@ +import { useState } from 'react'; + +import { organizationProfilePageIds } from './organization-profile.layout'; +import type { OrganizationProfilePageListOptions, OrganizationProfileViewProps } from './organization-profile.view'; + +type OrganizationProfileControllerOptions = Pick & { + pages?: OrganizationProfilePageListOptions['pages']; + defaultPage?: OrganizationProfileViewProps['activePage']; +}; + +export function useOrganizationProfileController({ + pages, + customPages, + pageOrder, + defaultPage, +}: OrganizationProfileControllerOptions) { + const [activePage, setActivePage] = useState( + defaultPage ?? pageOrder?.[0] ?? 'general', + ); + + if (pages) { + const pageIds = organizationProfilePageIds({ pages, customPages, pageOrder }); + const firstPage = pageIds[0]; + if (firstPage !== undefined && !pageIds.includes(activePage)) { + setActivePage(firstPage); + } + } + + return { activePage, onPageChange: setActivePage }; +} 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..53a103ef079 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.layout.ts @@ -5,11 +5,13 @@ 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, OrganizationProfilePages, } from './organization-profile.types'; +import type { OrganizationProfilePageListOptions } from './organization-profile.view'; export const ORGANIZATION_PROFILE_PAGE_IDS: readonly OrganizationProfilePageId[] = [ 'general', @@ -43,14 +45,25 @@ 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); } + +export function organizationProfilePageIds({ + pages, + customPages, + pageOrder, +}: OrganizationProfilePageListOptions): string[] { + return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map( + entry => entry.id, + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx new file mode 100644 index 00000000000..b7ff553c1d7 --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal-content.tsx @@ -0,0 +1,27 @@ +import { useOrganization } from '@clerk/shared/react'; +import React from 'react'; + +import { OrganizationProfile } from './organization-profile'; +import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types'; + +export default function OrganizationProfileModalContent({ + options, + close, +}: OrganizationProfileModalContentProps): React.ReactElement { + const { isLoaded, organization } = useOrganization(); + const inactive = isLoaded && !organization; + + React.useEffect(() => { + if (inactive) { + close(); + } + }, [inactive, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts new file mode 100644 index 00000000000..c9b211967fc --- /dev/null +++ b/packages/mosaic/src/features/organization-profile/organization-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { OrganizationProfileModalOptions } from './organization-profile.modal.types'; + +export const organizationProfileModal = defineModalSurface({ + id: 'organizationProfile', + variant: 'profile', + load: () => import('./organization-profile.modal-content'), +}); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.tsx b/packages/mosaic/src/features/organization-profile/organization-profile.tsx index 966da9f76e4..cdffb75ce62 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile.tsx @@ -1,12 +1,13 @@ 'use client'; import type { ReactNode } from 'react'; -import { useState } from 'react'; import type { APIKeysPageProps } from '../api-keys/api-keys-table'; +import { useOrganizationProfileController } from './organization-profile.controller'; 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,17 +84,16 @@ export function OrganizationProfile({ defaultPage, afterLeaveOrganizationUrl, fallback, - apiKeysProps: { hide: hideAPIKeys = false, ...apiKeysProps } = {}, + apiKeysProps, }: OrganizationProfileProps) { const model = useOrganizationProfileModel(); - const [activePage, setActivePage] = useState( - defaultPage ?? pageOrder?.[0] ?? 'general', - ); + const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined; + const navigation = useOrganizationProfileController({ pages, customPages, pageOrder, defaultPage }); if (model.status === 'loading') { return fallback ?? null; } - if (model.status === 'hidden') { + if (!pages) { return null; } @@ -101,19 +101,27 @@ export function OrganizationProfile({ , - members: model.pages.includes('members') ? : undefined, - apiKeys: - model.pages.includes('apiKeys') && !hideAPIKeys ? ( - - ) : undefined, - }} + pages={pages} /> ); } + +/** + * 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/organization-switcher/__tests__/organization-switcher.feature.test.tsx b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx index 278fd3d049e..aea996da81e 100644 --- a/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx +++ b/packages/mosaic/src/features/organization-switcher/__tests__/organization-switcher.feature.test.tsx @@ -1,7 +1,9 @@ import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; +import { openDialog } from '../../../__tests__/feature/dialog'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, @@ -45,6 +47,11 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi const bobSession = fapiSession({ id: 'sess_2', user: bob }); const personalSession = fapiSession({ id: 'sess_1', user: alice }); +async function wideViewport() { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +} + function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed { return { client: fapiClient([aliceSession, bobSession]), @@ -128,8 +135,8 @@ describe('OrganizationSwitcher', () => { }); it('heads the surface with the active organization, and manages it from the rows beneath', async () => { - const { clerk } = await renderSwitcher({}, noOffers()); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + await wideViewport(); + await renderSwitcher({}, noOffers()); const user = await openWithList(); const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup(); @@ -144,7 +151,8 @@ describe('OrganizationSwitcher', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(openOrganizationProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull(); }); it('falls back to the account, with nothing to manage, where no organization is active', async () => { @@ -215,16 +223,19 @@ describe('OrganizationSwitcher', () => { await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other')); }); - it('tells the organization profile where to land after leaving', async () => { - const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => { + await wideViewport(); + const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers()); const user = await openWithList(); await user.click(screen.getByRole('button', { name: 'Settings' })); + 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' })); - expect(openOrganizationProfile).toHaveBeenCalledWith( - expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }), - ); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left')); }); it('tells create-organization where to land and whether to skip inviting members', async () => { 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/switcher/__tests__/switcher.pages.test.tsx b/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx deleted file mode 100644 index 8db622b685e..00000000000 --- a/packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import type { CustomPage } from '@clerk/shared/types'; -import { act, render, screen, within } from '@testing-library/react'; -import { beforeEach, describe, expect, it } from 'vitest'; - -import type { CustomProfileItem } from '../../user-profile/user-profile.types'; -import type { CustomPagesOptions } from '../switcher.pages'; -import { useCustomPages } from '../switcher.pages'; - -// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to -// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the -// tests exercise the same handshake the real modal performs. -function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement { - const el = document.createElement('div'); - document.body.appendChild(el); - act(() => callback?.(el)); - return el; -} - -function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) { - act(() => callback?.(el)); - el.remove(); -} - -let emitted: CustomPage[] | undefined; - -function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) { - const { customPages, portals } = useCustomPages({ items, order, builtInPages }); - emitted = customPages; - return
{portals}
; -} - -const terms: CustomProfileItem = { - label: 'Terms', - path: 'terms', - icon: terms icon, - content:

Terms body

, -}; - -const docs: CustomProfileItem = { - label: 'Docs', - path: 'docs', - href: 'https://clerk.com/docs', - icon: docs icon, -}; - -beforeEach(() => { - emitted = undefined; -}); - -describe('useCustomPages', () => { - it('sends nothing when there are no custom pages', () => { - render(); - - expect(emitted).toBeUndefined(); - expect(screen.getByTestId('host')).toBeEmptyDOMElement(); - }); - - it('sends a page as its path and a link as its href', () => { - render(); - - expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']); - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); - }); - - // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what - // make an item a page. A link carrying them would be routed to instead of followed. - it('sends content callbacks for a page and none for a link', () => { - render(); - - const [page, link] = emitted ?? []; - expect(page.mount).toBeTypeOf('function'); - expect(page.unmount).toBeTypeOf('function'); - expect(link.mount).toBeUndefined(); - expect(link.unmount).toBeUndefined(); - }); - - // Without them clerk-js drops the page as invalid, so `icon` could not be optional. - it('sends the icon callbacks even for an item with no icon', () => { - render(Terms body

}]} />); - - const [page] = emitted ?? []; - expect(page.mountIcon).toBeTypeOf('function'); - expect(page.unmountIcon).toBeTypeOf('function'); - - const el = mountInto(page.mountIcon); - expect(el).toBeEmptyDOMElement(); - }); - - it('renders an icon into its own element, apart from the content', () => { - render(); - - const content = mountInto(emitted?.[0].mount); - const icon = mountInto(emitted?.[0].mountIcon); - - expect(within(icon).getByText('terms icon')).toBeInTheDocument(); - expect(within(content).queryByText('terms icon')).toBeNull(); - }); - - it('keeps each page in the element that asked for it', () => { - const help: CustomProfileItem = { label: 'Help', path: 'help', content:

Help body

}; - render(); - - const first = mountInto(emitted?.[0].mount); - const second = mountInto(emitted?.[1].mount); - - expect(within(first).getByText('Terms body')).toBeInTheDocument(); - expect(within(second).getByText('Help body')).toBeInTheDocument(); - }); - - it('stops rendering content once clerk-js gives the element back', () => { - render(); - - const el = mountInto(emitted?.[0].mount); - expect(within(el).getByText('Terms body')).toBeInTheDocument(); - - unmountFrom(emitted?.[0].unmount, el); - - expect(screen.queryByText('Terms body')).toBeNull(); - }); - - // The profile is opened once with the callbacks from that render, and never handed a later set. - // They have to keep working against the current content, or a page re-rendered while the profile - // is open goes stale. - it('renders updated content through the callbacks the profile was opened with', () => { - const { rerender } = render(); - const el = mountInto(emitted?.[0].mount); - - rerender(Revised terms

}]} />); - - expect(within(el).getByText('Revised terms')).toBeInTheDocument(); - }); - - describe('order', () => { - it('leaves the built-in pages alone when no order is given', () => { - render(); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']); - }); - - // Anything more than the id and clerk-js reads it as a custom page. - it('sends a built-in page as its id alone', () => { - render(); - - expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]); - }); - - // Unsent built-ins jump to the front, so one left out of the order would not stay put. - it('sends the pages left out of the order after the ones in it', () => { - render( - , - ); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']); - }); - - it('drops an id that belongs to no page', () => { - render( - , - ); - - expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']); - }); - - it('sends a page once even when the order names it twice', () => { - render(); - - expect(emitted?.map(page => page.label)).toEqual(['security', 'account']); - }); - - it('renders a reordered page into the element clerk-js hands back', () => { - render( - , - ); - - const el = mountInto(emitted?.[1].mount); - - expect(within(el).getByText('Terms body')).toBeInTheDocument(); - }); - }); -}); diff --git a/packages/mosaic/src/features/switcher/switcher.model.tsx b/packages/mosaic/src/features/switcher/switcher.model.tsx index 413cc0bf287..c2d0aec6f42 100644 --- a/packages/mosaic/src/features/switcher/switcher.model.tsx +++ b/packages/mosaic/src/features/switcher/switcher.model.tsx @@ -2,19 +2,23 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks'; import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user'; import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react'; import type { - OrganizationProfileModalProps, OrganizationResource, OrganizationSwitcherProps, UserButtonProps as ClerkUserButtonProps, - UserProfileModalProps, UserResource, } from '@clerk/shared/types'; import { populateParamFromObject } from '@clerk/shared/url'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../hooks/use-mosaic-router'; +import { useModal } from '../../modals/use-modal'; +import { useModalOpener } from '../../modals/use-modal-opener'; import { useGetPortalContainer } from '../../primitives/portal'; import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages'; +import { organizationProfileModal } from '../organization-profile/organization-profile.modal'; +import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types'; +import { userProfileModal } from '../user-profile/user-profile.modal'; +import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types'; import type { SwitcherBrandingProps, SwitcherCallbacks, @@ -24,6 +28,7 @@ import type { SwitcherSession, SwitcherSuggestion, } from './switcher.types'; +import type { SwitcherSurface } from './switcher-surface'; import { useOrganizationListInView } from './use-organization-list-in-view'; // Promise-returning so the controller can drive busy state. Navigation callbacks stay fire-and-forget. @@ -109,8 +114,12 @@ export type OrganizationSwitcherModelOptions = OrganizationProfileMode & /** Props forwarded to the profile modals this button opens. */ export interface SwitcherModalProps { - userProfile?: Pick; - organizationProfile?: Pick; + opener: SwitcherSurface; + userProfile?: Pick< + UserProfileModalOptions, + 'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode' + >; + organizationProfile?: Pick; } function resolveAfterUrl( @@ -173,10 +182,11 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession { } /** - * @param modals - Props forwarded to the profile modals. Custom pages arrive already bridged, since only - * the wrapper can render the portals behind them. + * @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the + * organization's pages, so managing the organization opens it on them. `opener` names this switcher, + * so a profile reopened after a redirect gets this switcher's props back. */ -export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel { +export function useSwitcherModel(options: SwitcherModelOptions | undefined, modals: SwitcherModalProps): SwitcherModel { const { isLoaded: isUserLoaded, user } = useUser(); const { isLoaded: isSessionLoaded, session } = useSession(); // The active org names the trigger. That is not a request to turn Organizations on. @@ -185,6 +195,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch }); const clerk = useClerk(); const router = useMosaicRouter(); + const organizationProfile = useModal(organizationProfileModal); // The modal must portal into the app's own dialog root, or it renders behind the surface that opened it. const getContainer = useGetPortalContainer(); const environment = useMosaicEnvironment(); @@ -196,10 +207,18 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch enabled: Boolean(environment?.organizationSettings.enabled), }); + const organizationProfileProps = { + ...modals.organizationProfile, + afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, + }; + + const userProfileProps = { ...modals.userProfile, organizationProfileProps }; + const userProfile = useModalOpener(userProfileModal, modals.opener, userProfileProps); + const manageAccount = openOrNavigate({ url: options?.userProfileUrl, mode: options?.userProfileMode, - openModal: () => clerk.openUserProfile({ getContainer, ...modals?.userProfile }), + openModal: () => userProfile.open(userProfileProps, getContainer?.()), buildUrl: () => clerk.buildUserProfileUrl(), navigate: router.navigate, }); @@ -208,11 +227,9 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch url: options?.organizationProfileUrl, mode: options?.organizationProfileMode, openModal: () => - clerk.openOrganizationProfile({ - getContainer, - ...modals?.organizationProfile, - afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl, - }), + modals.userProfile?.mode === 'combined' + ? userProfile.open({ ...userProfileProps, page: 'organization/general' }, getContainer?.()) + : organizationProfile.open(organizationProfileProps, getContainer?.()), buildUrl: () => clerk.buildOrganizationProfileUrl(), navigate: router.navigate, }); diff --git a/packages/mosaic/src/features/switcher/switcher.pages.tsx b/packages/mosaic/src/features/switcher/switcher.pages.tsx index 301e64b0d0c..4a7b6cc63e8 100644 --- a/packages/mosaic/src/features/switcher/switcher.pages.tsx +++ b/packages/mosaic/src/features/switcher/switcher.pages.tsx @@ -1,24 +1,12 @@ import { - disabledOrganizationAPIKeysFeature, - disabledOrganizationBillingFeature, - disabledSelfServeDirectorySyncFeature, - disabledSelfServeSSOFeature, disabledUserAPIKeysFeature, disabledUserBillingFeature, } from '@clerk/shared/internal/clerk-js/componentGuards'; import { useClerk } from '@clerk/shared/react'; -import type { CustomPage } from '@clerk/shared/types'; -import type { ReactNode } from 'react'; -import { useCallback, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; -import { applyOrder } from '../../utils/apply-order'; import { USER_PROFILE_PAGE_IDS } from '../user-profile/user-profile.layout'; -import type { CustomProfileItem, CustomProfileLink, UserProfilePageId } from '../user-profile/user-profile.types'; - -/** A built-in OrganizationProfile page, by the id its navigation uses. */ -export type OrganizationProfilePageId = 'general' | 'members' | 'billing' | 'apiKeys' | 'security'; +import type { UserProfilePageId } from '../user-profile/user-profile.types'; /** * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned @@ -39,117 +27,3 @@ export function useUserProfilePages(): UserProfilePageId[] { }; return USER_PROFILE_PAGE_IDS.filter(id => !disabled[id]); } - -/** The OrganizationProfile's built-in pages, mirrored from clerk-js for the same reason as `useUserProfilePages`. */ -export function useOrganizationProfilePages(): OrganizationProfilePageId[] { - const clerk = useClerk(); - const environment = useMosaicEnvironment(); - - const pages: OrganizationProfilePageId[] = ['general', 'members']; - if (!disabledOrganizationBillingFeature(clerk, environment)) { - pages.push('billing'); - } - if (!disabledOrganizationAPIKeysFeature(clerk, environment)) { - pages.push('apiKeys'); - } - if (!disabledSelfServeSSOFeature(clerk, environment) || !disabledSelfServeDirectorySyncFeature(clerk, environment)) { - pages.push('security'); - } - return pages; -} - -export interface CustomPagesOptions { - /** Pages and links of the consumer's own. */ - items: CustomProfileItem[] | undefined; - /** The order the profile's navigation should run in, by id. */ - order: readonly string[] | undefined; - /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */ - builtInPages: readonly string[]; -} - -export interface CustomPagesBridge { - /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */ - customPages: CustomPage[] | undefined; - /** Render these for as long as the profile can be open, or its pages come up blank. */ - portals: ReactNode[]; -} - -const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined; - -function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode { - const container = containers.get(id); - return container ? createPortal(node, container, id) : null; -} - -/** - * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes. - * - * The profile opens in clerk-js's own React root, which cannot render a node from the host app's - * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the - * page belongs and hands it over, and the host tree portals the content into it from here. The - * portals therefore have to stay mounted in the host tree the whole time the profile is open, which - * is why they come back out rather than being rendered here. - * - * This is the shape of the bridge only for as long as the profile renders outside the host tree. A - * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the - * props a consumer writes. - */ -export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge { - const [containers, setContainers] = useState>(new Map()); - - // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep - // working: the portal re-reads its container from state on every render of the host tree. - const bind = useCallback( - (id: string) => ({ - mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)), - unmount: () => - setContainers(prev => { - const next = new Map(prev); - next.delete(id); - return next; - }), - }), - [], - ); - - const byId = new Map((items ?? []).map(item => [item.path, item])); - // clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a - // built-in left out of the order has to be sent anyway to keep it behind the pages that were named. - // Without an order there is nothing to hold in place, so only the custom pages go out. - const ids = order?.length ? applyOrder(order, [...builtInPages, ...byId.keys()], id => id) : [...byId.keys()]; - - if (!ids.length) { - return { customPages: undefined, portals: [] }; - } - - const customPages = ids.map(id => { - const item = byId.get(id); - // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and - // it reads as a custom page instead. - if (!item) { - return { label: id }; - } - - // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an - // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put - // through them; without them, leaving `icon` off would silently cost you the page. - const icon = bind(`icon:${id}`); - const content = isLink(item) ? undefined : bind(`content:${id}`); - - return { - label: item.label, - // A page is routed to by its path; a link is followed to wherever it points. - url: isLink(item) ? item.href : item.path, - mountIcon: icon.mount, - unmountIcon: icon.unmount, - ...(content && { mount: content.mount, unmount: content.unmount }), - }; - }); - - const portals = (items ?? []).flatMap(item => [ - portalInto(containers, `icon:${item.path}`, item.icon), - ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]), - ]); - - return { customPages, portals }; -} diff --git a/packages/mosaic/src/features/switcher/switcher.tsx b/packages/mosaic/src/features/switcher/switcher.tsx index aac782c9ad1..eac73e8e2f1 100644 --- a/packages/mosaic/src/features/switcher/switcher.tsx +++ b/packages/mosaic/src/features/switcher/switcher.tsx @@ -1,28 +1,28 @@ 'use client'; -import type { OrganizationProfileProps, UserProfileProps } from '@clerk/shared/types'; +import type { UserProfileProps } from '@clerk/shared/types'; import type { ReactElement, ReactNode } from 'react'; -import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types'; +import type { OrganizationProfilePageId } from '../organization-profile/organization-profile.types'; +import type { CustomProfilePage, UserProfilePageId } from '../user-profile/user-profile.types'; import { useSwitcherController } from './switcher.controller'; import type { SwitcherModelOptions } from './switcher.model'; import { useSwitcherModel } from './switcher.model'; -import type { OrganizationProfilePageId } from './switcher.pages'; -import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from './switcher.pages'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherTriggerVariant } from './switcher.view'; import { SwitcherView } from './switcher.view'; +import { surfaceForMode } from './switcher-surface'; /** What a profile opened from the switcher takes beyond the profile component's own props. */ export interface SwitcherProfilePages { /** - * Provide custom pages and links to be rendered inside the profile. + * Provide custom pages to be rendered inside the profile. */ - customPages?: CustomProfileItem[]; + customPages?: CustomProfilePage[]; /** * Controls the order of the profile's navigation. Accepts the ids of built-in pages and the * `path` of custom pages. Pages not listed are placed after the listed ones. The first entry is - * the page the profile opens on, so it cannot be a link. + * the page the profile opens on. * * @default undefined */ @@ -31,13 +31,10 @@ export interface SwitcherProfilePages { /** Options for the underlying `` component. */ export interface SwitcherUserProfileProps - extends - SwitcherProfilePages, - Pick {} + extends SwitcherProfilePages, Pick {} /** Options for the underlying `` component. */ -export interface SwitcherOrganizationProfileProps - extends SwitcherProfilePages, Pick {} +export type SwitcherOrganizationProfileProps = SwitcherProfilePages; export type SwitcherProps = SwitcherModelOptions & SwitcherTriggerProps & @@ -49,7 +46,7 @@ export type SwitcherProps = SwitcherModelOptions & userProfileProps?: SwitcherUserProfileProps; /** * Specify options for the underlying component. - * e.g., organizationProfileProps={{appearance: {...}}} + * e.g., organizationProfileProps={{pageOrder: ['members', 'general']}} */ organizationProfileProps?: SwitcherOrganizationProfileProps; /** @@ -76,64 +73,30 @@ export function Switcher( fallback, ...options } = props; - // The portals must outlive the popover, so custom pages are bridged here rather than inside it. - const userProfile = useCustomPages({ - items: userProfileProps?.customPages, - order: userProfileProps?.pageOrder, - builtInPages: useUserProfilePages(), - }); - const organizationProfile = useCustomPages({ - items: organizationProfileProps?.customPages, - order: organizationProfileProps?.pageOrder, - builtInPages: useOrganizationProfilePages(), - }); - const portals = ( - <> - {userProfile.portals} - {organizationProfile.portals} - - ); const model = useSwitcherModel(options, { - userProfile: { - customPages: userProfile.customPages, - additionalOAuthScopes: userProfileProps?.additionalOAuthScopes, - apiKeysProps: userProfileProps?.apiKeysProps, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: userProfileProps?.appearance, - }, - organizationProfile: { - customPages: organizationProfile.customPages, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- `@clerk/shared` types `appearance` as an unresolved type until a framework SDK augments `ClerkAppearanceRegistry`; it passes through untouched. - appearance: organizationProfileProps?.appearance, - }, + opener: surfaceForMode(mode), + userProfile: { ...userProfileProps, mode: mode === 'combined' ? 'combined' : 'user' }, + organizationProfile: organizationProfileProps, }); const controller = useSwitcherController(model, { mode, customMenuItems, menuItemOrder }); if (controller.status === 'loading') { - return ( - <> - {fallback} - {portals} - - ); + return <>{fallback}; } // Signed out is an answer, so the placeholder goes too rather than promising a button. if (controller.status === 'hidden') { - return <>{portals}; + return null; } const { status: _status, ...viewController } = controller; return ( - <> - - {portals} - + ); } diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 4ebb5a240dd..2259d4dbba7 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1,7 +1,8 @@ -import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; +import type { UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, onTestFinished, vi } from 'vitest'; +import { page } from 'vitest/browser'; import { deferred } from '../../../__tests__/async'; import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; @@ -111,6 +112,13 @@ const reading = (...names: string[]) => .filter(node => !node.closest('.cl-user-button-header')) .map(node => node.textContent); +async function wideViewport() { + await page.viewport(1280, 800); + onTestFinished(() => page.viewport(414, 896)); +} + +const profileTabs = () => screen.getAllByRole('tab').map(tab => tab.textContent); + const current = () => Array.from(requiredPopup().querySelectorAll('[aria-current="true"]')).map(node => node.textContent); @@ -465,28 +473,30 @@ describe('UserButton', () => { describe('handing off', () => { it('opens the UserProfile modal and closes when managing the account', async () => { + await wideViewport(); const { clerk, navigate } = await renderUserButton(); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const openUserProfile = vi.spyOn(clerk, 'openUserProfile'); const user = await open(); await openSettings(user, 'Manage profile'); - expect(openUserProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(openUserProfile).not.toHaveBeenCalled(); expect(navigate).not.toHaveBeenCalled(); await waitFor(() => expect(popup()).toBeNull()); }); - it('hands the opened profile the portal root the app provides', async () => { + it('opens the profile into the portal root the app provides', async () => { + await wideViewport(); const container = document.body.appendChild(document.createElement('div')); onTestFinished(() => container.remove()); serveFapi(signedIn()); - const { clerk } = await renderWithClerk({tree()}); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + await renderWithClerk({tree()}); const user = await open(); await openSettings(user, 'Manage profile'); - expect(openUserProfile.mock.calls[0]?.[0]?.getContainer?.()).toBe(container); + expect(within(container).getByRole('tab', { name: 'Account' })).toBeInTheDocument(); }); it('runs a custom menu item and closes', async () => { @@ -500,116 +510,55 @@ describe('UserButton', () => { await waitFor(() => expect(popup()).toBeNull()); }); - it('renders a custom page into the element the opened profile hands back', async () => { - const { clerk } = await renderUserButton({ - userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}] }, - }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + it('renders a custom page inside the opened profile', async () => { + await wideViewport(); + await renderUserButton({ userProfileProps: { customPages: [terms] } }); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); - - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages).toHaveLength(1); - expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' }); + await user.click(await screen.findByRole('tab', { name: 'Terms' })); - const el = document.createElement('div'); - document.body.appendChild(el); - act(() => { - customPages[0]?.mount?.(el); - }); - - expect(within(el).getByText('Terms body')).toBeInTheDocument(); + expect(screen.getByText('Terms body')).toBeVisible(); }); - it('opens the profile with its pages in the order it was given', async () => { - const { clerk } = await renderUserButton({ - userProfileProps: { - customPages: [{ label: 'Terms', path: 'terms', content:

Terms body

}], - pageOrder: ['account', 'terms'], - }, - }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + it('opens the profile on the first page of the order it was given', async () => { + await wideViewport(); + await renderUserButton({ userProfileProps: { customPages: [terms], pageOrder: ['terms', 'account'] } }); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']); + expect(await screen.findByRole('tab', { name: 'Terms' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Terms', 'Account', 'Security', 'General', 'Members']); }); it.each([ - { features: {}, pages: ['Terms', 'account', 'security'] }, - { features: { billing: true, apiKeys: true }, pages: ['Terms', 'account', 'security', 'billing', 'apiKeys'] }, - ])('orders the UserProfile pages the instance turns on: $features', async ({ features, pages }) => { - const { clerk } = await renderUserButton( + { features: {}, tabs: ['Terms', 'Account', 'Security', 'General', 'Members'] }, + { features: { apiKeys: true }, tabs: ['Terms', 'Account', 'Security', 'API Keys', 'General', 'Members'] }, + ])('orders the UserProfile pages the instance turns on: $features', async ({ features, tabs }) => { + await wideViewport(); + await renderUserButton( { userProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, signedIn({ environment: instanceWith(features) }), ); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); const user = await open(); await openSettings(user, 'Manage profile'); - await waitFor(() => expect(popup()).toBeNull()); + await screen.findByRole('tab', { name: 'Terms' }); - const customPages = openUserProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); + expect(profileTabs()).toEqual(tabs); }); - it.each([ - { features: {}, organization: {}, pages: ['Terms', 'general', 'members'] }, - { - features: { billing: true, apiKeys: true }, - organization: {}, - pages: ['Terms', 'general', 'members', 'billing', 'apiKeys'], - }, - { features: { sso: true }, organization: {}, pages: ['Terms', 'general', 'members'] }, - { - features: { sso: true }, - organization: { self_serve_sso_enabled: true }, - pages: ['Terms', 'general', 'members', 'security'], - }, - { - features: { sso: true, directorySync: true }, - organization: {}, - pages: ['Terms', 'general', 'members'], - }, - { - features: { sso: true, directorySync: true }, - organization: { self_serve_directory_sync_enabled: true }, - pages: ['Terms', 'general', 'members', 'security'], - }, - ])( - 'orders the OrganizationProfile pages the instance and organization turn on: $features $organization', - async ({ features, organization, pages }) => { - const selfServe = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 3, ...organization }); - const memberships = [fapiMembership(selfServe, { permissions: ['org:sys_memberships:manage'] })]; - const { clerk } = await renderUserButton( - { organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }, - signedIn({ - environment: instanceWith(features), - client: fapiClient([ - fapiSession({ - id: 'sess_1', - user: { ...alice, organization_memberships: memberships }, - last_active_organization_id: 'org_1', - }), - ]), - memberships, - }), - ); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); - const user = await open(); + it("opens the combined profile on the organization's General page, with its own pages in order", async () => { + await wideViewport(); + await renderUserButton({ organizationProfileProps: { customPages: [terms], pageOrder: ['terms'] } }); + const user = await open(); - await openSettings(user, 'Manage organization'); - await waitFor(() => expect(popup()).toBeNull()); + await openSettings(user, 'Manage organization'); - const customPages = openOrganizationProfile.mock.calls[0]?.[0]?.customPages ?? []; - expect(customPages.map((page: CustomPage) => page.label)).toEqual(pages); - }, - ); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security', 'Terms', 'General', 'Members']); + }); it('opens the InviteMembers modal and closes', async () => { const { clerk, navigate } = await renderUserButton(); @@ -676,25 +625,23 @@ describe('UserButton', () => { }); it('navigates to manage the account when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ userProfileUrl: '/account' }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + const { navigate } = await renderUserButton({ userProfileUrl: '/account' }); const user = await open(); await openSettings(user, 'Manage profile'); expect(navigate).toHaveBeenCalledWith('/account'); - expect(openUserProfile).not.toHaveBeenCalled(); + expect(screen.queryByRole('tab')).toBeNull(); }); it('navigates to manage the organization when a URL routes it', async () => { - const { clerk, navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + const { navigate } = await renderUserButton({ organizationProfileUrl: '/org' }); const user = await open(); await openSettings(user, 'Manage organization'); expect(navigate).toHaveBeenCalledWith('/org'); - expect(openOrganizationProfile).not.toHaveBeenCalled(); + expect(screen.queryByRole('tab')).toBeNull(); }); it('sends add-account to the sign-in page', async () => { @@ -932,8 +879,8 @@ describe('UserButton', () => { }); it('manages the account from its settings row, and signs out at the foot', async () => { - const { clerk } = await renderUserButton({ mode: 'user' }); - const openUserProfile = vi.spyOn(clerk, 'openUserProfile').mockImplementation(() => {}); + await wideViewport(); + await renderUserButton({ mode: 'user' }); const user = await open(); expect( @@ -944,7 +891,8 @@ describe('UserButton', () => { await user.click(screen.getByRole('button', { name: 'Settings' })); expect(screen.queryByRole('menu')).toBeNull(); - expect(openUserProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(profileTabs()).toEqual(['Account', 'Security']); }); it('lists every account in the flyout, then the way to add one, then signing out of them all', async () => { @@ -972,8 +920,8 @@ describe('UserButton', () => { }); it('heads the surface with the active organization, badged with the account, managing both', async () => { - const { clerk } = await renderUserButton(); - const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {}); + await wideViewport(); + await renderUserButton(); expect(trigger()).toHaveAccessibleName('Open account menu for Alice Smith'); const user = await open(); @@ -995,7 +943,7 @@ describe('UserButton', () => { 'Sign out', ]); await openSettings(user, 'Manage organization'); - expect(openOrganizationProfile).toHaveBeenCalled(); + expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true'); }); it('keeps the identifier beneath the organization even when it is all that names the account', async () => { 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..cf80f1acbd9 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 { act, 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,194 @@ 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']); + }); + + it('keeps the shown page after the open organization page leaves the navigation', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: [] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General'])); + await act(() => clerk.setActive({ organization: acme.id })); + await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members'])); + + expect(screen.getByRole('tab', { name: 'Account', description: 'Account' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('stays on the organization page when the next organization lists it too', async () => { + const globex = fapiOrganization({ id: 'org_2', name: 'Globex' }); + const memberships = [ + fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }), + fapiMembership(globex, { permissions: ['org:sys_memberships:read'] }), + ]; + const user = fapiUser({ id: 'user_1', organization_memberships: memberships }); + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]), + memberships, + }); + const { clerk } = await renderWithClerk(); + + await act(() => clerk.setActive({ organization: globex.id })); + await waitFor(() => expect(clerk.organization?.id).toBe(globex.id)); + + expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); +}); 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.modal-return.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx new file mode 100644 index 00000000000..5ef969a063e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.modal-return.feature.test.tsx @@ -0,0 +1,95 @@ +import { configure, screen, within } from '@testing-library/react'; +import { beforeAll, describe, expect, it, onTestFinished } from 'vitest'; +import { page } from 'vitest/browser'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { OrganizationSwitcher } from '../../organization-switcher/organization-switcher'; +import { UserButton } from '../../user-button/user-button'; + +beforeAll(() => page.viewport(1280, 800)); + +function serve() { + serveFapi({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), + }); +} + +function visit(search: string) { + const original = window.location.href; + window.history.replaceState(null, '', `${window.location.pathname}${search}#accounts`); + onTestFinished(() => window.history.replaceState(null, '', original)); +} + +function strictMode() { + configure({ reactStrictMode: true }); + onTestFinished(() => configure({ reactStrictMode: false })); +} + +describe('UserProfile modal return', () => { + it.each([ + { name: 'renders normally', strict: false }, + { name: 'renders in StrictMode', strict: true }, + ])('reopens the profile on the account page and clears the param when it $name', async ({ strict }) => { + if (strict) { + strictMode(); + } + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount&__clerk_mosaic_opener=user-button&tab=connections'); + + await renderWithClerk(

Page

); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + expect(window.location.search).toBe('?tab=connections'); + expect(window.location.hash).toBe('#accounts'); + }); + + it('reopens the profile with the pages its opener registered', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount'); + + await renderWithClerk( + Terms body

}] }} + />, + ); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByRole('tab', { name: 'Terms' })).toBeVisible(); + expect(within(dialog).getByRole('tab', { name: 'Account' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('reopens the profile with the pages of the opener named in the return', async () => { + serve(); + visit('?__clerk_mosaic_modal=user-profile%2Faccount&__clerk_mosaic_opener=user-button'); + + await renderWithClerk( + <> + Terms body

}] }} + /> + + , + ); + + const dialog = await screen.findByRole('dialog'); + expect(await within(dialog).findByRole('tab', { name: 'Terms' })).toBeVisible(); + }); + + it('ignores an unknown value', async () => { + serve(); + visit('?__clerk_mosaic_modal=organization-profile%2Fgeneral&__clerk_mosaic_opener=user-button&tab=connections'); + + await renderWithClerk(

Page

); + + expect(await screen.findByText('Page')).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(window.location.search).toBe( + '?__clerk_mosaic_modal=organization-profile%2Fgeneral&__clerk_mosaic_opener=user-button&tab=connections', + ); + expect(window.location.hash).toBe('#accounts'); + }); +}); 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-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx index 2595e112eaf..1b6f9c167b7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.connect.feature.test.tsx @@ -20,6 +20,7 @@ import { fapiVerification, } from '../../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { ModalOpenedByProvider } from '../../../../modals/use-modal-opener'; import { MosaicProvider } from '../../../../mosaic-provider'; import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section'; @@ -214,7 +215,7 @@ describe('connected accounts', () => { expect(await screen.findByText('The connection could not start. Please try again.')).toBeInTheDocument(); }); - it('preserves modal state when connecting', async () => { + it('returns to the profile modal after connecting', async () => { serveFapi(signedIn([])); await renderWithClerk( @@ -226,12 +227,48 @@ describe('connected accounts', () => { await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); await waitFor(() => expect(request.requests).toHaveLength(1)); const body = new URLSearchParams(await request.requests[0]?.text()); - const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); - expect(encoded).toBeTruthy(); - expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ - componentName: 'UserProfile', - socialProvider: 'github', - }); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account'); + expect(params.has('__clerk_mosaic_opener')).toBe(false); + expect(params.has('__clerk_modal_state')).toBe(false); + request.fail(); + }); + + it('names the opener of the profile modal in the return', async () => { + serveFapi(signedIn([])); + await renderWithClerk( + + + + + , + ); + const request = holdRequests('post', '/v1/me/external_accounts'); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const body = new URLSearchParams(await request.requests[0]?.text()); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account'); + expect(params.get('__clerk_mosaic_opener')).toBe('user-button'); + request.fail(); + }); + + it('returns to the page without modal state when connecting from a mounted profile', async () => { + serveFapi(signedIn([])); + await renderWithClerk( + + + , + ); + const request = holdRequests('post', '/v1/me/external_accounts'); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' })); + await waitFor(() => expect(request.requests).toHaveLength(1)); + const body = new URLSearchParams(await request.requests[0]?.text()); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.has('__clerk_mosaic_modal')).toBe(false); + expect(params.has('__clerk_modal_state')).toBe(false); request.fail(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx index 2daba3a4ef1..be98c19e55a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.reconnect.feature.test.tsx @@ -99,7 +99,7 @@ describe('connected accounts', () => { expect(open).not.toHaveBeenCalled(); }); - it('preserves modal state when reconnecting', async () => { + it('returns to the profile modal after reconnecting', async () => { serveFapi(signedIn([disconnectedGoogle])); await renderWithClerk( @@ -112,9 +112,9 @@ describe('connected accounts', () => { await startReconnect(user, 'Google'); await waitFor(() => expect(request.requests).toHaveLength(1)); const body = new URLSearchParams(await request.requests[0]?.text()); - const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); - expect(encoded).toBeTruthy(); - expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' }); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe('user-profile/account'); + expect(params.has('__clerk_modal_state')).toBe(false); request.fail(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index e0cc0b7249d..38a6898eb44 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -1,6 +1,5 @@ import { iconImageUrl } from '@clerk/shared/constants'; import { ClerkRuntimeError } from '@clerk/shared/error'; -import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { OAUTH_PROVIDERS } from '@clerk/shared/oauth'; import { useClerk, useUser } from '@clerk/shared/react'; import type { @@ -20,6 +19,7 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; +import { useUserProfileModalReturn } from '../user-profile.modal-return'; import { useUserProfileOptions } from '../user-profile.provider'; import { isMonochromeProvider } from '../user-profile-provider.utils'; import type { @@ -227,7 +227,8 @@ function unavailable() { } export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAccountsModel { - const { additionalOAuthScopes, mode } = useUserProfileOptions(); + const { additionalOAuthScopes } = useUserProfileOptions(); + const withModalState = useUserProfileModalReturn(); const clerk = useClerk(); const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); @@ -283,8 +284,6 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco }; const getRedirectUrl = async () => (transport ? String(await transport.getRedirectUrl()) : window.location.href); - const withModalState = (url: string, socialProvider?: string) => - mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile', socialProvider }) : url; const completeVerification = async (response: ExternalAccountResource): Promise => { const url = response.verification?.externalVerificationRedirectURL; @@ -319,7 +318,7 @@ export function useUserProfileConnectedAccountsModel(): UserProfileConnectedAcco const current = requireCurrentUser(); const response = await createAccount(current, { strategy: provider.strategy, - redirectUrl: withModalState(redirectUrl, provider.provider), + redirectUrl: withModalState(redirectUrl), additionalScopes: additionalOAuthScopes ? additionalOAuthScopes[provider.provider] : [], }); return completeVerification(response); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx index d28b064404a..73605b623e7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -22,6 +22,7 @@ import { } from '../../../../__tests__/feature/fapi'; import { openMenu } from '../../../../__tests__/feature/menu'; import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileProvider } from '../../user-profile.provider'; import { UserProfileEmailSection } from '../user-profile-email-section'; const VERIFIED = fapiVerification('email_code', { status: 'verified' }); @@ -207,6 +208,32 @@ describe('the user profile email section', () => { await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso'))); }); + it.each([ + { mode: 'modal' as const, returnParam: 'user-profile/account' }, + { mode: 'mounted' as const, returnParam: null }, + ])('marks the identity provider return for a $mode profile', async ({ mode, returnParam }) => { + const sso = fapiEmailAddress({ id: 'idn_sso', email_address: 'alice@acme.co', matches_sso_connection: true }); + serveFapi(signedInWithEmails([PRIMARY, sso])); + const { clerk } = await renderWithClerk( + + + , + ); + vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + const actor = userEvent.setup(); + + await manageEmail(actor, 'alice@acme.co', 'Verify'); + await actor.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Connect' })); + + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + const body = new URLSearchParams(await prepare.requests[0]?.text()); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe(returnParam); + expect(params.has('__clerk_modal_state')).toBe(false); + prepare.fail(); + }); + it('does not offer to add the username as an address', async () => { const { actor } = await renderSection( signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })), diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts index b94eb88cfd5..68825eb2b85 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts @@ -6,6 +6,7 @@ import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import { allowsIdentificationCreation } from '../../../utils/allows-identification-creation'; import { save } from '../../../utils/errors'; import { useReverificationFlow } from '../../reverification'; +import { useUserProfileModalReturn } from '../user-profile.modal-return'; import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types'; import { byId, toContactAccess, toContacts } from '../user-profile-contact.utils'; import { useUserProfileUserModel } from '../user-profile-user.model'; @@ -33,6 +34,7 @@ function startEmailVerification( email: EmailAddressResource, linkRedirectUrl: string | undefined, router: MosaicRouter, + withModalReturn: (url: string) => string, ): UserProfileEmailVerification { if (email.matchesSsoConnection) { return { @@ -41,17 +43,12 @@ function startEmailVerification( Prepared on the click rather than on entering the step, so the redirect URL the server answers with is in hand by the time we navigate. Preparing on entry leaves the button live before the response lands, and an early click has nowhere to go. - - TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this - redirect when the profile is mounted as a modal, so returning from the provider reopens the - modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has - to encode it here too, or the user comes back to a closed dialog and a lost flow. */ connect: () => save(async () => { const prepared = await email.prepareVerification({ strategy: 'enterprise_sso', - redirectUrl: window.location.href, + redirectUrl: withModalReturn(window.location.href), }); const url = prepared.verification.externalVerificationRedirectURL; if (!url) { @@ -76,9 +73,10 @@ function toEmailVerifier( email: EmailAddressResource, linkRedirectUrl: string | undefined, router: MosaicRouter, + withModalReturn: (url: string) => string, ): UserProfileEmailVerifier { return { - start: () => startEmailVerification(email, linkRedirectUrl, router), + start: () => startEmailVerification(email, linkRedirectUrl, router, withModalReturn), verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), }; } @@ -86,6 +84,7 @@ function toEmailVerifier( export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel { const model = useUserProfileUserModel(); const router = useMosaicRouter(); + const withModalReturn = useUserProfileModalReturn(); const [removeEmail, removeReverification] = useReverificationFlow((user: UserResource, id: string) => byId(user.emailAddresses, id, 'email address').destroy(), ); @@ -114,7 +113,7 @@ export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; - const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); + const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router, withModalReturn); return { status: 'ready', diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx index c439ee6409c..ce59c8b008e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts.feature.test.tsx @@ -185,21 +185,25 @@ describe('enterprise accounts', () => { ); }); - it('preserves modal return state in the redirect URL', async () => { + it.each([ + { mode: 'modal' as const, returnParam: 'user-profile/account' }, + { mode: 'mounted' as const, returnParam: null }, + ])('marks the identity provider return for a $mode profile', async ({ mode, returnParam }) => { serveFapi(signedIn()); - await renderWithClerk( - + const { clerk } = await renderWithClerk( + , ); + vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); const request = holdRequests('post', '/v1/me/external_accounts'); await userEvent.setup().click(await screen.findByRole('button', { name: 'Connect Acme Okta' })); await waitFor(() => expect(request.requests).toHaveLength(1)); const body = new URLSearchParams(await request.requests[0]?.text()); - const encoded = new URL(body.get('redirect_url') || '').searchParams.get('__clerk_modal_state'); - expect(encoded).toBeTruthy(); - expect(JSON.parse(window.atob(encoded || ''))).toMatchObject({ componentName: 'UserProfile' }); + const params = new URL(body.get('redirect_url') || '').searchParams; + expect(params.get('__clerk_mosaic_modal')).toBe(returnParam); + expect(params.has('__clerk_modal_state')).toBe(false); request.fail(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts index f6666ce1740..47a55ec930a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.model.ts @@ -1,5 +1,4 @@ import { ClerkRuntimeError } from '@clerk/shared/error'; -import { appendModalState } from '@clerk/shared/internal/clerk-js/queryStateParams'; import { __internal_useUserEnterpriseConnections, useClerk, useUser } from '@clerk/shared/react'; import type { EnterpriseAccountResource, EnterpriseConnectionResource, UserResource } from '@clerk/shared/types'; @@ -7,7 +6,7 @@ import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; import type { ReverificationController } from '../../reverification'; import { useReverificationFlow } from '../../reverification'; -import { useUserProfileOptions } from '../user-profile.provider'; +import { useUserProfileModalReturn } from '../user-profile.modal-return'; import { isMonochromeProvider } from '../user-profile-provider.utils'; import type { EnterpriseAccountActionResult, @@ -83,7 +82,7 @@ export type UserProfileEnterpriseAccountsModel = }; export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAccountsModel { - const { mode } = useUserProfileOptions(); + const withModalReturn = useUserProfileModalReturn(); const clerk = useClerk(); const router = useMosaicRouter(); const [createAccount, connectReverification] = useReverificationFlow( @@ -137,8 +136,7 @@ export function useUserProfileEnterpriseAccountsModel(): UserProfileEnterpriseAc if (!projection.connections.some(connection => connection.id === connectionId)) { throw unavailable(); } - const url = window.location.href; - const redirectUrl = mode === 'modal' ? appendModalState({ url, componentName: 'UserProfile' }) : url; + const redirectUrl = withModalReturn(window.location.href); const account = await createAccount(current, { enterpriseConnectionId: connectionId, redirectUrl }); currentUser(); const redirect = account.verification?.externalVerificationRedirectURL; diff --git a/packages/mosaic/src/features/user-profile/user-profile.controller.ts b/packages/mosaic/src/features/user-profile/user-profile.controller.ts new file mode 100644 index 00000000000..39f839a2119 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.controller.ts @@ -0,0 +1,45 @@ +import { useState } from 'react'; + +import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout'; +import { + getAvailableUserProfilePages, + ORGANIZATION_PAGE_PREFIX, + resolveUserProfilePages, + withoutOrganizationPaths, +} from './user-profile.layout'; +import type { UserProfileViewProps } from './user-profile.view'; + +type UserProfileControllerOptions = Pick & { + initialPage: UserProfileViewProps['activePage']; + organization?: + | { status: 'loading' | 'hidden' } + | { status: 'ready'; pageList: NonNullable }; +}; + +export function useUserProfileController({ + initialPage, + pages, + customPages, + pageOrder, + organization, +}: UserProfileControllerOptions) { + const [activePage, setActivePage] = useState(initialPage); + + if (organization && organization.status !== 'loading' && activePage.startsWith(ORGANIZATION_PAGE_PREFIX)) { + const organizationPageIds = + organization.status === 'ready' + ? organizationProfilePageIds(organization.pageList).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`) + : []; + if (!organizationPageIds.includes(activePage)) { + setActivePage( + resolveUserProfilePages( + getAvailableUserProfilePages(pages), + withoutOrganizationPaths(customPages), + pageOrder, + )[0]?.id ?? 'account', + ); + } + } + + return { activePage, onPageChange: setActivePage }; +} 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..ca3265c66cd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.layout.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.layout.ts @@ -1,14 +1,20 @@ +import { logger } from '@clerk/shared/logger'; + import type { IconComponent } from '../../icons/glyph'; import { Code } from '../../icons/glyphs/code'; 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 +45,27 @@ 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); } + +export function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] { + return 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; + }); +} 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-content.tsx b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx new file mode 100644 index 00000000000..f0830a0b37e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-content.tsx @@ -0,0 +1,24 @@ +import { useUser } from '@clerk/shared/react'; +import React from 'react'; + +import { UserProfile } from './user-profile'; +import type { UserProfileModalContentProps } from './user-profile.modal.types'; + +export default function UserProfileModalContent({ options, close }: UserProfileModalContentProps): React.ReactElement { + const { isLoaded, user } = useUser(); + const signedOut = isLoaded && !user; + + React.useEffect(() => { + if (signedOut) { + close(); + } + }, [signedOut, close]); + + const { page, ...props } = options ?? {}; + return ( + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts new file mode 100644 index 00000000000..e1dbdae3812 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal-return.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +import { useModal } from '../../modals/use-modal'; +import { useModalOpenedBy } from '../../modals/use-modal-opener'; +import { userProfileModal } from './user-profile.modal'; +import { useUserProfileOptions } from './user-profile.provider'; + +const MODAL_PARAM = '__clerk_mosaic_modal'; +const OPENER_PARAM = '__clerk_mosaic_opener'; +const ACCOUNT_PAGE = 'user-profile/account'; + +export function useUserProfileModalReturn(): (url: string) => string { + const { mode } = useUserProfileOptions(); + const opener = useModalOpenedBy(); + return url => (mode === 'modal' ? withUserProfileModalReturn(url, opener) : url); +} + +export function UserProfileModalReturn(): null { + const { open } = useModal(userProfileModal); + + React.useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.get(MODAL_PARAM) !== ACCOUNT_PAGE) { + return; + } + const opener = url.searchParams.get(OPENER_PARAM) ?? undefined; + url.searchParams.delete(MODAL_PARAM); + url.searchParams.delete(OPENER_PARAM); + window.history.replaceState(null, '', url); + open({ page: 'account' }, undefined, opener); + }, [open]); + + return null; +} + +function withUserProfileModalReturn(url: string, opener: string | undefined): string { + const next = new URL(url); + next.searchParams.set(MODAL_PARAM, ACCOUNT_PAGE); + if (opener !== undefined) { + next.searchParams.set(OPENER_PARAM, opener); + } + return next.toString(); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.modal.ts b/packages/mosaic/src/features/user-profile/user-profile.modal.ts new file mode 100644 index 00000000000..636d8aa504a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.modal.ts @@ -0,0 +1,8 @@ +import { defineModalSurface } from '../../modals/define-modal-surface'; +import type { UserProfileModalOptions } from './user-profile.modal.types'; + +export const userProfileModal = defineModalSurface({ + id: 'userProfile', + variant: 'profile', + load: () => import('./user-profile.modal-content'), +}); 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..f77d428ef11 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile.tsx @@ -1,10 +1,15 @@ 'use client'; 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 { useUserProfileController } from './user-profile.controller'; 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,27 @@ 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. `pageOrder` names the organization's pages + * without the `organization/` prefix, as in `['members', 'general']`. + */ + 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 +81,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 * ( - defaultPage ?? pageOrder?.[0] ?? 'account', - ); if (model.status === 'loading') { return fallback ?? null; @@ -96,26 +139,77 @@ export function UserProfile({ return null; } + const viewProps: AccountViewProps = { + elevation: 'flush', + initialPage: defaultPage ?? pageOrder?.[0] ?? 'account', + customPages, + pageOrder, + pages: { + account: , + security: , + apiKeys: + builtInPages.includes('apiKeys') && !hideAPIKeys ? : undefined, + }, + }; + return ( + {mode === 'user' ? ( + + ) : ( + + )} + + ); +} + +type AccountViewProps = Omit & { + initialPage: UserProfileViewProps['activePage']; +}; + +function AccountUserProfileView({ initialPage, ...viewProps }: AccountViewProps) { + const navigation = useUserProfileController({ initialPage, ...viewProps }); + + return ( + + ); +} + +function CombinedUserProfileView({ + initialPage, + organizationProfileProps: { customPages, pageOrder, apiKeysProps, afterLeaveOrganizationUrl } = {}, + ...viewProps +}: AccountViewProps & Pick) { + const model = useOrganizationProfileModel(); + const organizationState = + model.status === 'ready' + ? { + status: 'ready' as const, + pageList: { pages: organizationProfilePanels(model.pages, apiKeysProps), customPages, pageOrder }, + } + : { status: model.status }; + const navigation = useUserProfileController({ + initialPage, + ...viewProps, + organization: organizationState, + }); + + return ( + , - security: , - apiKeys: - builtInPages.includes('apiKeys') && !hideAPIKeys ? ( - - ) : undefined, - }} + {...viewProps} + {...navigation} + organization={organizationState.status === 'ready' ? organizationState.pageList : undefined} /> - + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index dcd57d0fa44..77ff769e925 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -40,17 +40,3 @@ export interface CustomProfilePage { /** Rendered as the page itself. */ content: ReactNode; } - -/** A row in the profile's navigation that leaves for somewhere else. */ -export interface CustomProfileLink { - /** Names the row in the profile's navigation. */ - label: string; - /** Identifies the row, for ordering. */ - path: string; - /** Where the row goes. */ - href: string; - icon?: ReactNode; - content?: never; -} - -export type CustomProfileItem = CustomProfilePage | CustomProfileLink; 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..bda54dbce02 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,32 @@ 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, + withoutOrganizationPaths, +} 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 +36,71 @@ 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 accountPages = userProfilePageList( + { pages, customPages: withoutOrganizationPaths(customPages), 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/index.ts b/packages/mosaic/src/index.ts index b9690e71e22..ad03bffa48f 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -4,6 +4,10 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher'; export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; +export type { OrganizationProfileProps } from './features/organization-profile/organization-profile'; +export { OrganizationProfile } from './features/organization-profile/organization-profile'; +export type { UserProfileMode, UserProfileProps } from './features/user-profile/user-profile'; +export { UserProfile } from './features/user-profile/user-profile'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; diff --git a/packages/mosaic/src/modals/modal-host.tsx b/packages/mosaic/src/modals/modal-host.tsx index b4fd3d901d9..fe8ba05fb21 100644 --- a/packages/mosaic/src/modals/modal-host.tsx +++ b/packages/mosaic/src/modals/modal-host.tsx @@ -7,7 +7,8 @@ import { useMessages } from '../localization'; import { useActor } from '../machine/use-machine'; import type { PortalContainer } from '../primitives/portal'; import { PortalProvider } from '../primitives/portal'; -import type { ModalHandle, ModalSurface, ModalSurfaces } from './modal.types'; +import type { ModalHandle, ModalOpener, ModalSurface, ModalSurfaces } from './modal.types'; +import { ModalOpenedByProvider } from './use-modal-opener'; export function ModalHost({ surfaces, @@ -39,21 +40,29 @@ export function ModalSurfaceHost({ open: false, options: undefined, container: undefined, + opener: undefined, openCount: 0, }); + const [openers, setOpeners] = React.useState>>([]); + + const registerOpener = React.useCallback((next: ModalOpener) => { + setOpeners(previous => [...previous, next]); + return () => setOpeners(previous => previous.filter(entry => entry !== next)); + }, []); const modal = React.useMemo>(() => { const preload = () => surface.loader.send({ type: 'LOAD' }); return { - open: (options, container) => { + open: (options, container, opener) => { preload(); - setState(previous => ({ open: true, options, container, openCount: previous.openCount + 1 })); + setState(previous => ({ open: true, options, container, opener, openCount: previous.openCount + 1 })); }, close: () => setState(previous => (previous.open ? { ...previous, open: false } : previous)), preload, isOpen: state.open, + registerOpener, }; - }, [surface, state.open]); + }, [surface, state.open, registerOpener]); return ( @@ -61,6 +70,7 @@ export function ModalSurfaceHost({ @@ -70,10 +80,12 @@ export function ModalSurfaceHost({ function ModalSlot({ surface, state, + options, close, }: { surface: ModalSurface; state: ModalState; + options: Options | undefined; close: () => void; }) { const [content] = useActor(surface.loader); @@ -91,11 +103,13 @@ function ModalSlot({ > {Content ? ( - + + + ) : failed ? ( ) : null} @@ -106,6 +120,14 @@ function ModalSlot({ return state.container === undefined ? dialog : {dialog}; } +function withOpenerOptions( + openers: ReadonlyArray>, + state: ModalState, +): Options | undefined { + const opener = openers.findLast(entry => entry.id === state.opener) ?? openers.at(-1); + return opener === undefined ? state.options : { ...opener.options(), ...state.options }; +} + function LoadFailed() { const m = useMessages('errors'); const dialog = React.useContext(DialogContext); @@ -121,5 +143,6 @@ interface ModalState { open: boolean; options: Options | undefined; container: PortalContainer | undefined; + opener: string | undefined; openCount: number; } diff --git a/packages/mosaic/src/modals/modal.types.ts b/packages/mosaic/src/modals/modal.types.ts index b68d7d88c97..58a81d1f854 100644 --- a/packages/mosaic/src/modals/modal.types.ts +++ b/packages/mosaic/src/modals/modal.types.ts @@ -28,8 +28,14 @@ export interface ModalSurface extends ModalSurfaceConfig, Moda } export interface ModalHandle { - open: (options?: Options, container?: PortalContainer) => void; + open: (options?: Options, container?: PortalContainer, opener?: string) => void; close: () => void; preload: () => void; isOpen: boolean; + registerOpener: (opener: ModalOpener) => () => void; +} + +export interface ModalOpener { + id: string; + options: () => Options; } diff --git a/packages/mosaic/src/modals/use-modal-opener.ts b/packages/mosaic/src/modals/use-modal-opener.ts new file mode 100644 index 00000000000..fa6c03912cb --- /dev/null +++ b/packages/mosaic/src/modals/use-modal-opener.ts @@ -0,0 +1,27 @@ +import React from 'react'; + +import type { ModalHandle, ModalSurface } from './modal.types'; +import { useModal } from './use-modal'; + +export function useModalOpener( + surface: ModalSurface, + opener: string, + options: Options, +): ModalHandle { + const modal = useModal(surface); + const { registerOpener } = modal; + const latest = React.useRef(options); + latest.current = options; + + React.useEffect(() => registerOpener({ id: opener, options: () => latest.current }), [registerOpener, opener]); + + return { ...modal, open: (overrides, container) => modal.open(overrides, container, opener) }; +} + +const ModalOpenedByContext = React.createContext(undefined); + +export const ModalOpenedByProvider = ModalOpenedByContext.Provider; + +export function useModalOpenedBy(): string | undefined { + return React.useContext(ModalOpenedByContext); +} diff --git a/packages/mosaic/src/modals/use-modal.test.tsx b/packages/mosaic/src/modals/use-modal.test.tsx index 609989e0188..be846e68221 100644 --- a/packages/mosaic/src/modals/use-modal.test.tsx +++ b/packages/mosaic/src/modals/use-modal.test.tsx @@ -8,6 +8,7 @@ import { defineModalSurface } from './define-modal-surface'; import type { ModalContentProps, ModalHandle, ModalSurface, ModalSurfaces } from './modal.types'; import { ModalHost } from './modal-host'; import { useModal } from './use-modal'; +import { useModalOpenedBy, useModalOpener } from './use-modal-opener'; afterEach(() => cleanup()); @@ -74,6 +75,22 @@ function renderCaller(surface: ModalSurface, providerProps?: Omit({ + surface, + opener, + options, + onHandle, +}: { + surface: ModalSurface; + opener: string; + options: Options; + onHandle?: (handle: ModalHandle) => void; +}) { + const handle = useModalOpener(surface, opener, options); + onHandle?.(handle); + return null; +} + class AppBoundary extends React.Component<{ children: React.ReactNode }, { error: Error | null }> { state: { error: Error | null } = { error: null }; @@ -458,3 +475,269 @@ describe('useModal', () => { ).toThrow(/unknown.*MosaicProvider/); }); }); + +describe('useModalOpener', () => { + function renderWithOpeners(surface: ModalSurface, openers: React.ReactNode) { + const ref: { handle: ModalHandle | undefined } = { handle: undefined }; + const caller = ( + (ref.handle = h)} + /> + ); + const result = renderWithModals( + [surface], + <> + {caller} + {openers} + , + ); + const rerender = (next: React.ReactNode) => + result.rerender( + <> + {caller} + {next} + , + ); + return { ref, rerender }; + } + + it('opens with the options it registered', async () => { + const surface = stubSurface(); + let handle: ModalHandle | undefined; + renderWithOpeners( + surface, + (handle = h)} + />, + ); + await flush(); + + act(() => handle?.open()); + await flush(); + + expect(readOptions()).toEqual({ label: 'registered' }); + }); + + it('lets the options passed to open override the registered ones', async () => { + const surface = stubSurface(); + let handle: ModalHandle | undefined; + renderWithOpeners( + surface, + (handle = h)} + />, + ); + await flush(); + + act(() => handle?.open({ page: 'security' })); + await flush(); + + expect(readOptions()).toEqual({ label: 'registered', page: 'security' }); + }); + + it('keeps the options of the opener that opened it when another registers later', async () => { + const surface = stubSurface(); + let handle: ModalHandle | undefined; + renderWithOpeners( + surface, + <> + (handle = h)} + /> + + , + ); + await flush(); + + act(() => handle?.open()); + await flush(); + + expect(readOptions()).toEqual({ label: 'button' }); + }); + + it('uses the options of the opener it is reopened for', async () => { + const surface = stubSurface(); + const { ref } = renderWithOpeners( + surface, + <> + + + , + ); + await flush(); + + act(() => ref.handle?.open({ page: 'account' }, undefined, 'button')); + await flush(); + + expect(readOptions()).toEqual({ label: 'button', page: 'account' }); + }); + + it('falls back to the last registered options when the opener it names is not mounted', async () => { + const surface = stubSurface(); + const { ref } = renderWithOpeners( + surface, + <> + + + , + ); + await flush(); + + act(() => ref.handle?.open({ page: 'account' }, undefined, 'missing')); + await flush(); + + expect(readOptions()).toEqual({ label: 'switcher', page: 'account' }); + }); + + it('uses the last registered options when opened without an opener', async () => { + const surface = stubSurface(); + const { ref } = renderWithOpeners( + surface, + <> + + + , + ); + await flush(); + + act(() => ref.handle?.open()); + await flush(); + + expect(readOptions()).toEqual({ label: 'switcher' }); + }); + + it('picks up options registered after the modal opened', async () => { + const surface = stubSurface(); + const { ref, rerender } = renderWithOpeners(surface, null); + await flush(); + act(() => ref.handle?.open({ page: 'account' })); + await flush(); + + rerender( + , + ); + await flush(); + + expect(readOptions()).toEqual({ label: 'late', page: 'account' }); + }); + + it('switches to the named opener when it registers after another', async () => { + const surface = stubSurface(); + const switcher = ( + + ); + const { ref, rerender } = renderWithOpeners(surface, switcher); + await flush(); + act(() => ref.handle?.open({ page: 'account' }, undefined, 'button')); + await flush(); + expect(readOptions()).toEqual({ label: 'switcher', page: 'account' }); + + rerender( + <> + {switcher} + + , + ); + await flush(); + + expect(readOptions()).toEqual({ label: 'button', page: 'account' }); + }); + + it('drops the options of an opener that unmounts', async () => { + const surface = stubSurface(); + const { ref, rerender } = renderWithOpeners( + surface, + , + ); + await flush(); + + rerender(null); + await flush(); + act(() => ref.handle?.open({ page: 'account' })); + await flush(); + + expect(readOptions()).toEqual({ page: 'account' }); + }); + + it('tells the content which opener opened it', async () => { + function OpenedByContent() { + return {useModalOpenedBy() ?? 'nobody'}; + } + const surface = stubSurface('opened-by', () => Promise.resolve({ default: OpenedByContent })); + let handle: ModalHandle | undefined; + const { ref } = renderWithOpeners( + surface, + (handle = h)} + />, + ); + await flush(); + + act(() => handle?.open()); + await flush(); + expect(screen.getByTestId('opened-by')).toHaveTextContent('button'); + + act(() => ref.handle?.open()); + await flush(); + expect(screen.getByTestId('opened-by')).toHaveTextContent('nobody'); + }); +}); diff --git a/packages/mosaic/src/mosaic-provider.tsx b/packages/mosaic/src/mosaic-provider.tsx index 7eb3f61e87f..4a5193df0a6 100644 --- a/packages/mosaic/src/mosaic-provider.tsx +++ b/packages/mosaic/src/mosaic-provider.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { ToastProvider } from './components/toast/toast'; +import { organizationProfileModal } from './features/organization-profile/organization-profile.modal'; +import { userProfileModal } from './features/user-profile/user-profile.modal'; +import { UserProfileModalReturn } from './features/user-profile/user-profile.modal-return'; import { MosaicNowProvider } from './hooks/use-now'; import type { MosaicIconOverrides } from './icons/overrides'; import { MosaicIconsProvider } from './icons/overrides'; @@ -35,7 +38,10 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos - {children} + + + {children} + @@ -44,4 +50,4 @@ export function MosaicProvider({ children, direction, icons, localization }: Mos ); } -const modalSurfaces: ModalSurfaces = []; +const modalSurfaces: ModalSurfaces = [userProfileModal, organizationProfileModal]; 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` | ` + +
); }