From 99cf9fae08e384420f6f6161cdee23da033344a0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 9 Oct 2026 19:07:58 -0400 Subject: [PATCH] feat(mosaic): render self-contained sections in user profile panels --- .changeset/mosaic-user-profile-panels.md | 2 + ...file-passkeys-composition.feature.test.tsx | 73 +----- ...ser-profile-profile-panel.feature.test.tsx | 52 ++++ .../user-profile-profile-panel.view.test.tsx | 59 +---- ...rofile-security-panel-mfa.feature.test.tsx | 104 -------- ...er-profile-security-panel.feature.test.tsx | 84 +++---- .../user-profile-security-panel.view.test.tsx | 230 ++++++++---------- ...le-active-devices-section.feature.test.tsx | 6 +- ...er-profile-connected-accounts.fixtures.tsx | 11 +- ...-enterprise-accounts-host.feature.test.tsx | 21 +- .../user-profile-passkeys-section.view.tsx | 116 ++++----- ...rofile-passkeys-lifecycle.feature.test.tsx | 39 +-- ...-profile-passkeys-section.feature.test.tsx | 10 +- .../user-profile-password-section.view.tsx | 30 +-- .../user-profile-profile-panel.tsx | 35 +++ .../user-profile-profile-panel.view.tsx | 29 +-- .../user-profile-security-list.tsx | 86 +++---- .../user-profile-security-panel.tsx | 30 ++- .../user-profile-security-panel.view.tsx | 55 +---- .../fixtures/user-profile-mfa-example.tsx | 21 +- .../src/stories/fixtures/user-profile.tsx | 43 ++-- .../stories/user-profile-profile-panel.mdx | 30 +-- .../user-profile-profile-panel.stories.tsx | 33 ++- .../stories/user-profile-security-panel.mdx | 29 ++- 24 files changed, 490 insertions(+), 738 deletions(-) create mode 100644 .changeset/mosaic-user-profile-panels.md create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel-mfa.feature.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx diff --git a/.changeset/mosaic-user-profile-panels.md b/.changeset/mosaic-user-profile-panels.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-panels.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx index 8c9b3ea65b6..78bd18daa56 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-passkeys-composition.feature.test.tsx @@ -1,17 +1,13 @@ -import { act, screen, within } from '@testing-library/react'; +import { act, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { serveFapi } from '../../../__tests__/feature/fake-fapi'; import { fapiClient, fapiEnvironment, fapiPasskey, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; import { UserProfileView } from '../user-profile.view'; -import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; -import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; -import { passkeysSectionNode } from '../user-profile-passkeys-section/user-profile-passkeys-section'; -import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section/user-profile-passkeys-section.model'; -import { renderPasswordSection } from '../user-profile-password-section/user-profile-password-section'; -import { useUserProfilePasswordModel } from '../user-profile-password-section/user-profile-password-section.model'; -import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; +import { UserProfilePasskeysSection } from '../user-profile-passkeys-section/user-profile-passkeys-section'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; +import { UserProfileSecurityPanel } from '../user-profile-security-panel'; afterEach(() => vi.restoreAllMocks()); @@ -34,22 +30,7 @@ function serveAccounts() { }); } -function SecurityHost() { - const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null); - const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel()); - return ( - } - /> - ); -} - function ProfileHost() { - const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null); - const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel()); return ( , + children: ( + <> + + + + ), }, }} /> ); } -describe('Composing connected authentication sections', () => { - it('renders a plain passkeys section node inside Authentication', async () => { - serveAccounts(); - await renderWithClerk( - - } - />, - ); - - const authentication = screen.getByRole('region', { name: 'Authentication' }); - expect(within(authentication).getByText('No passkeys added')).toBeVisible(); - expect(within(authentication).getByRole('button', { name: 'Add passkey' })).toBeVisible(); - }); - +describe('Composing connected security sections', () => { it.each([ - { name: 'Security', Host: SecurityHost }, + { name: 'Security', Host: UserProfileSecurityPanel }, { name: 'UserProfile', Host: ProfileHost }, ])('keeps one current-account section of each kind after a user switch in $name', async ({ Host }) => { serveAccounts(); @@ -100,18 +65,4 @@ describe('Composing connected authentication sections', () => { expect(screen.getAllByRole('group', { name: 'Passkeys' })).toHaveLength(1); expect(screen.queryByText('Alice laptop')).toBeNull(); }); - - it('places Password and Passkeys before MFA and devices', async () => { - serveAccounts(); - await renderWithClerk(); - const authentication = screen.getByRole('region', { name: 'Authentication' }); - const password = within(authentication).getByText('Password'); - const passkeys = within(authentication).getByText('Passkeys'); - const mfa = within(authentication).getByText('2-step verification'); - expect(password.compareDocumentPosition(passkeys) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(passkeys.compareDocumentPosition(mfa) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect( - mfa.compareDocumentPosition(screen.getByText('Active devices')) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.feature.test.tsx new file mode 100644 index 00000000000..f9ee0019301 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.feature.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEmailAddress, fapiSession, fapiUser } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileEmailSection } from '../user-profile-email-section/user-profile-email-section'; +import { UserProfileProfilePanel } from '../user-profile-profile-panel'; +import { UserProfileProfileSection } from '../user-profile-profile-section/user-profile-profile-section'; + +const email = fapiEmailAddress({ id: 'idn_1', email_address: 'alice@example.com' }); +const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [email], + primary_email_address_id: 'idn_1', +}); + +function sectionHeadings() { + return screen.getAllByRole('heading', { level: 3 }).map(heading => heading.textContent); +} + +describe('UserProfileProfilePanel', () => { + it('renders the account sections the instance enables', async () => { + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); + await renderWithClerk(); + + expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); + expect(sectionHeadings()).toEqual(['Profile', 'Email', 'Danger zone']); + }); + + it('renders only the sections passed as children, in their order', async () => { + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); + await renderWithClerk( + + + + , + ); + + expect(sectionHeadings()).toEqual(['Email', 'Profile']); + }); + + it('keeps the panel title when every child section is hidden', async () => { + serveFapi({ client: fapiClient() }); + await renderWithClerk(); + + expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); + expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 2d613db4787..bad8c2a18b7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -32,22 +32,6 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('tab')).toBeNull(); }); - it('places the sections in the order of the composed profile page', () => { - renderView({ - dangerSlot:
, - web3WalletsSlot:
, - enterpriseAccountsSlot:
, - connectedAccountsSlot:
, - phoneSlot:
, - emailSlot:
, - profileSlot:
, - }); - - const order = ['profile', 'email', 'phone', 'connected', 'enterprise', 'web3', 'danger']; - const rendered = Array.from(document.querySelectorAll('[data-testid]'), node => node.getAttribute('data-testid')); - expect(rendered).toEqual(order); - }); - it('keeps the final wallet confirmation mounted until removal settles', async () => { const user = userEvent.setup(); const titleRef = createRef(); @@ -57,7 +41,7 @@ describe('UserProfileProfilePanelView', () => { }); const { rerender } = renderView({ titleRef, - web3WalletsSlot: ( + children: ( titleRef.current} @@ -70,16 +54,13 @@ describe('UserProfileProfilePanelView', () => { await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); rerender( - titleRef.current} - onRemove={onRemoveWeb3Wallet} - /> - } - /> + + titleRef.current} + onRemove={onRemoveWeb3Wallet} + /> + , ); expect(screen.queryByRole('heading', { name: 'Web3 wallets' })).not.toBeInTheDocument(); @@ -93,7 +74,7 @@ describe('UserProfileProfilePanelView', () => { it('renders the danger zone when provided', () => { renderView({ - dangerSlot: , + children: , }); expect(screen.getByRole('heading', { level: 3, name: 'Danger zone' })).toBeInTheDocument(); @@ -103,27 +84,9 @@ describe('UserProfileProfilePanelView', () => { ); }); - it('places enterprise accounts before Web3 wallets and the danger zone', () => { - renderView({ - web3WalletsSlot: ( - - ), - enterpriseAccountsSlot:
Enterprise accounts
, - dangerSlot: , - }); - - const wallets = screen.getByRole('group', { name: 'Web3 wallets' }); - const enterprise = screen.getByTestId('enterprise'); - const danger = screen.getByRole('heading', { name: 'Danger zone' }); - expect(enterprise.compareDocumentPosition(wallets) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(enterprise.compareDocumentPosition(danger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - }); - it('renders Web3 images inside icon frames', () => { const { container } = renderView({ - web3WalletsSlot: ( + children: ( { it('composes linked wallets and available providers', () => { renderView({ - web3WalletsSlot: ( + children: ( { - it('renders a plain MFA node in Authentication over legacy methods', async () => { - serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - }); - await renderWithClerk( - Connected MFA
} - mfaMethods={[{ id: 'injected', type: 'authenticator' }]} - />, - ); - - const authentication = screen.getByRole('region', { name: 'Authentication' }); - expect(within(authentication).getByText('Connected MFA')).toBeVisible(); - expect(within(authentication).queryByRole('heading', { name: '2-step verification' })).toBeNull(); - }); - - it('suppresses injected MFA when the slot is explicitly null', async () => { - serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - }); - await renderWithClerk( - , - ); - - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); - }); - - it('places a connected MFA slot after password in the security panel', async () => { - function Panel() { - const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); - const passwordSlot = renderPasswordSection(useUserProfilePasswordModel(), null); - return ( - - ); - } - serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - }); - await renderWithClerk(); - const authentication = screen.getByRole('region', { name: 'Authentication' }); - expect(authentication).toHaveTextContent('Password'); - expect(authentication).toHaveTextContent('2-step verification'); - expect(authentication.textContent?.indexOf('Password')).toBeLessThan( - authentication.textContent?.indexOf('2-step verification') ?? 0, - ); - }); - - it('uses the connected slot instead of duplicate injected MFA props', async () => { - function Panel() { - const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); - return ( - - ); - } - serveFapi({ - environment: mfaEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - }); - await renderWithClerk(); - expect(screen.getAllByText('2-step verification')).toHaveLength(1); - expect(screen.queryByText('Authenticator app')).toBeNull(); - }); - - it('suppresses injected MFA when the connected slot is hidden', async () => { - function Panel() { - const mfaSlot = mfaSectionNode(useUserProfileMfaModel()); - return ( - - ); - } - serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]) }); - await renderWithClerk(); - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.feature.test.tsx index 78bbd8b90f3..7bed9e4a2fa 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.feature.test.tsx @@ -1,5 +1,5 @@ import { screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { serveFapi } from '../../../__tests__/feature/fake-fapi'; import { @@ -11,69 +11,71 @@ import { fapiUser, } from '../../../__tests__/feature/fapi'; import { renderWithClerk } from '../../../__tests__/feature/render'; -import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; +import { UserProfileActiveDevicesSection } from '../user-profile-active-devices-section/user-profile-active-devices-section'; +import { mfaEnvironment } from '../user-profile-mfa-section/__tests__/mfa-feature-setup'; +import { UserProfilePasswordSection } from '../user-profile-password-section/user-profile-password-section'; import { UserProfileSecurityPanel } from '../user-profile-security-panel'; const email = fapiEmailAddress({ id: 'idn_1', email_address: 'person@example.com' }); const alice = fapiUser({ id: 'user_1', email_addresses: [email] }); +afterEach(() => vi.restoreAllMocks()); + describe('UserProfileSecurityPanel', () => { - it('omits Authentication while the only method loads without a fallback', async () => { - serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); - const loading = renderWithClerk(); - try { - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); - } finally { - await loading; - } - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + function sectionHeadings() { + return screen.getAllByRole('heading', { level: 3 }).map(heading => heading.textContent); + } + + it('renders the security sections the instance enables', async () => { + vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false); + const environment = mfaEnvironment(); + environment.user_settings.attributes.passkey.enabled = true; + serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), activeDevices: [] }); + await renderWithClerk(); + + expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); + await screen.findByRole('heading', { level: 3, name: 'Active devices' }); + expect(sectionHeadings()).toEqual(['Password', 'Passkeys', '2-step verification', 'Active devices']); }); - it.each([false, 0, ''])('omits Authentication while loading with a %s fallback', async passwordFallback => { - serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); - const loading = renderWithClerk(); - try { - const hasAuthentication = screen.queryByRole('region', { name: 'Authentication' }) !== null; - expect(hasAuthentication).toBe(false); - } finally { - await loading; - } - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + it('renders only the sections passed as children, in their order', async () => { + serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]), activeDevices: [] }); + await renderWithClerk( + + + + , + ); + + await screen.findByRole('heading', { level: 3, name: 'Active devices' }); + expect(sectionHeadings()).toEqual(['Active devices', 'Password']); }); - it('keeps Authentication around a visible loading fallback', async () => { + it('shows a section fallback while that section loads', async () => { serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); const loading = renderWithClerk( - Loading password section
} />, + + Loading password section
} /> + , ); try { - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Loading password section'); + expect(screen.getByText('Loading password section')).toBeInTheDocument(); } finally { await loading; } - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + expect(screen.getByRole('heading', { level: 3, name: 'Password' })).toBeInTheDocument(); expect(screen.queryByText('Loading password section')).toBeNull(); }); - it('shows no password action when nobody is signed in', async () => { + it('keeps the panel title when nobody is signed in', async () => { serveFapi({ client: fapiClient() }); await renderWithClerk(); - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); - expect(screen.queryByText('Password')).toBeNull(); - }); - - it('keeps Authentication for other methods when passwords are disabled', async () => { - const environment = fapiEnvironment(); - environment.user_settings.attributes.password.enabled = false; - serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user: alice })]) }); - await renderWithClerk(} />); - - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Passkeys'); - expect(screen.queryByText('Password')).toBeNull(); + expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); + expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0); }); - it.each(['disabled', 'editable', 'managed'])('resolves the Authentication section for %s passwords', async policy => { + it.each(['disabled', 'editable', 'managed'])('resolves the password section for %s passwords', async policy => { const environment = fapiEnvironment(); environment.user_settings.attributes.password.enabled = policy !== 'disabled'; const user = fapiUser({ @@ -83,9 +85,9 @@ describe('UserProfileSecurityPanel', () => { serveFapi({ environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); await renderWithClerk(); if (policy === 'disabled') { - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); + expect(screen.queryByRole('heading', { level: 3, name: 'Password' })).toBeNull(); } else { - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); + expect(screen.getByRole('heading', { level: 3, name: 'Password' })).toBeInTheDocument(); } }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx index f4649c0cf4d..11bf4805c88 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-security-panel.view.test.tsx @@ -1,21 +1,17 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { useDestructiveController } from '../../../blocks/destructive/destructive.controller'; import { MosaicProvider } from '../../../mosaic-provider'; import type { UserProfileDevice } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; import { UserProfileActiveDevicesSectionView } from '../user-profile-active-devices-section/user-profile-active-devices-section.view'; -import { UserProfileDangerSectionView } from '../user-profile-danger-section/user-profile-danger-section.view'; +import type { UserProfileMfaSectionViewProps } from '../user-profile-mfa-section/user-profile-mfa-section.view'; +import { UserProfileMfaSectionView } from '../user-profile-mfa-section/user-profile-mfa-section.view'; import { UserProfilePasskeysSectionView } from '../user-profile-passkeys-section.view'; -import type { UserProfileSecurityPanelViewProps } from '../user-profile-security-panel.view'; import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view'; -function DeleteAccount() { - const controller = useDestructiveController({ onDelete: () => Promise.resolve() }); - return ; -} const devices: UserProfileDevice[] = [ { id: 'current', @@ -47,84 +43,69 @@ const passkeys = [ }, ]; -const props: UserProfileSecurityPanelViewProps = { - passkeysSlot: , - mfaMethods: [ - { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, - { id: 'totp_1', type: 'authenticator' }, - { id: 'backup_1', type: 'backup-codes' }, - ], - activeDevicesSlot: , -}; - -function renderView(overrides: Partial = {}) { +const mfaMethods: UserProfileMfaSectionViewProps['methods'] = [ + { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, + { id: 'totp_1', type: 'authenticator' }, + { id: 'backup_1', type: 'backup-codes' }, +]; + +function renderView(children: ReactNode) { return render( - + {children} , ); } describe('UserProfileSecurityPanelView', () => { - it('renders supplied password content without a separate visibility flag', () => { - renderView({ - passwordSlot:
Password
, - passkeysSlot: undefined, - mfaMethods: undefined, - }); - - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Password'); - expect(screen.getByText('Password')).toBeVisible(); - }); - - it('composes authentication and active devices without rendering a supplied danger zone', () => { - const staleSecurityProps = { ...props, dangerSlot: }; - renderView(staleSecurityProps); + it('renders its sections in the order given', () => { + renderView( + <> + + + + , + ); expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); - const authentication = screen.getByRole('region', { name: 'Authentication' }); - expect(screen.queryByRole('heading', { name: 'Authentication' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { level: 3, name: 'Active devices' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { level: 3, name: 'Danger zone' })).not.toBeInTheDocument(); - expect(within(authentication).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeInTheDocument(); - expect(within(authentication).getByRole('heading', { level: 3, name: '2-step verification' })).toBeInTheDocument(); - expect(within(authentication).getByRole('group', { name: 'Passkeys' })).toBeInTheDocument(); - expect(within(authentication).getByRole('group', { name: '2-step verification' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 3 }).map(heading => heading.textContent)).toEqual([ + 'Passkeys', + '2-step verification', + 'Active devices', + ]); const activeDevices = screen.getByRole('group', { name: 'Active devices' }); expect(within(activeDevices).getAllByRole('listitem')).toHaveLength(3); expect(screen.queryByRole('button', { name: 'Sign out of all devices' })).not.toBeInTheDocument(); expect(screen.getByText('This device')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Delete account' })).not.toBeInTheDocument(); }); - it('omits active devices when no slot is supplied', () => { - renderView({ activeDevicesSlot: undefined }); + it('keeps the title when it has no sections', () => { + renderView(null); - expect(screen.queryByRole('heading', { name: 'Active devices' })).not.toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Security' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Security' })).toBeInTheDocument(); + expect(screen.queryAllByRole('heading', { level: 3 })).toHaveLength(0); }); it('adds an available MFA method through the picker', async () => { - const onAddMfaMethod = vi.fn(); + const onAdd = vi.fn(); const user = userEvent.setup(); - renderView({ - mfaMethods: [ - { id: 'sms_1', type: 'sms', description: '+1 801-888-8181' }, - { id: 'backup_1', type: 'backup-codes' }, - ], - onAddMfaMethod, - addableMfaMethods: ['authenticator'], - }); + renderView( + , + ); await user.click(screen.getByRole('button', { name: 'Add verification method' })); expect(screen.queryByRole('button', { name: /SMS verification Get a code/ })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /Authenticator app Get codes/ })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onAddMfaMethod).toHaveBeenCalledWith('authenticator'); + expect(onAdd).toHaveBeenCalledWith('authenticator'); }); it('forwards security actions', async () => { @@ -132,15 +113,13 @@ describe('UserProfileSecurityPanelView', () => { const onSignOutAllOtherDevices = vi.fn(); const user = userEvent.setup(); - renderView({ - activeDevicesSlot: ( - - ), - }); + renderView( + , + ); const signOutAll = screen.getByRole('button', { name: 'Sign out of all devices' }); expect(signOutAll).toHaveAttribute('data-variant', 'outline'); @@ -162,18 +141,20 @@ describe('UserProfileSecurityPanelView', () => { }); it('keeps supported empty MFA methods and devices actionable', () => { - renderView({ - passkeysSlot: ( + renderView( + <> - ), - mfaMethods: [], - activeDevicesSlot: , - onAddMfaMethod: vi.fn(), - addableMfaMethods: ['sms', 'authenticator'], - }); + + + , + ); expect(screen.getByText('No verification methods added')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Add verification method' })).toBeInTheDocument(); @@ -182,40 +163,24 @@ describe('UserProfileSecurityPanelView', () => { it('withholds sign out from the current device', async () => { const user = userEvent.setup(); - renderView({ - activeDevicesSlot: ( - - ), - }); + renderView( + , + ); await user.click(screen.getByRole('button', { name: 'Manage Safari on macOS' })); expect(screen.getByRole('menuitem', { name: 'View details' })).toBeInTheDocument(); expect(screen.queryByRole('menuitem', { name: 'Sign out' })).not.toBeInTheDocument(); }); - it('keeps the authentication section on MFA when existing passkeys are hidden', () => { - renderView({ - passkeysSlot: null, - }); + it('keeps the passkeys card when passkeys are empty and Add is unavailable', () => { + renderView(); - expect(screen.queryByText('Passkeys')).not.toBeInTheDocument(); - expect(screen.queryByText('Passkey')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); - expect(screen.getByRole('heading', { level: 3, name: '2-step verification' })).toBeVisible(); - }); - - it('keeps the passkeys card in the authentication section when passkeys are empty and Add is unavailable', () => { - renderView({ passkeysSlot: }); - - const section = screen.getByRole('region', { name: 'Authentication' }); - expect(within(section).getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); - expect(within(section).getByText('No passkeys added')).toBeVisible(); + expect(screen.getByRole('heading', { level: 3, name: 'Passkeys' })).toBeVisible(); + expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); - expect(screen.getByRole('group', { name: '2-step verification' })).toBeVisible(); }); it('keeps the empty section and final passkey confirmation mounted without Add', async () => { @@ -224,15 +189,12 @@ describe('UserProfileSecurityPanelView', () => { const onRemovePasskey = vi.fn(async () => { await removal.promise; }); - const { rerender } = renderView({ - mfaMethods: undefined, - passkeysSlot: ( - - ), - }); + const { rerender } = renderView( + , + ); await user.click(screen.getByRole('button', { name: 'Manage Passkey' })); await user.click(screen.getByRole('menuitem', { name: 'Remove passkey' })); @@ -240,17 +202,14 @@ describe('UserProfileSecurityPanelView', () => { rerender( - - } - /> + + + , ); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeInTheDocument(); expect(screen.getByRole('alertdialog', { name: 'Remove passkey' })).toBeVisible(); await act(async () => { @@ -258,7 +217,6 @@ describe('UserProfileSecurityPanelView', () => { await removal.promise; }); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); expect(screen.getByRole('heading', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).not.toBeInTheDocument(); @@ -266,23 +224,27 @@ describe('UserProfileSecurityPanelView', () => { it('shows supplied backup codes independently and only allows regeneration', async () => { const onRegenerateBackupCodes = vi.fn(); - const onRemoveMfaMethod = vi.fn(); + const onRemove = vi.fn(); const backupCodes = { id: 'backup_1', type: 'backup-codes' as const }; - const backupOnlyView = renderView({ - mfaMethods: [backupCodes], - onRegenerateBackupCodes, - onRemoveMfaMethod, - }); + const backupOnlyView = renderView( + , + ); expect(screen.getByText('Backup codes')).toBeVisible(); backupOnlyView.unmount(); const user = userEvent.setup(); - renderView({ - mfaMethods: [{ id: 'sms_1', type: 'sms' }, backupCodes], - onRegenerateBackupCodes, - onRemoveMfaMethod, - }); + renderView( + , + ); expect(screen.getByText('Backup codes')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Manage SMS verification' })); @@ -292,14 +254,14 @@ describe('UserProfileSecurityPanelView', () => { expect(dialog).toHaveAccessibleDescription( 'This phone number will no longer receive sign-in verification codes. It will remain on your account.', ); - expect(onRemoveMfaMethod).not.toHaveBeenCalled(); + expect(onRemove).not.toHaveBeenCalled(); await user.click(within(dialog).getByRole('button', { name: 'Remove', exact: true })); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); await user.click(screen.getByRole('button', { name: 'Manage Backup codes' })); expect(screen.queryByRole('menuitem', { name: 'Remove method' })).not.toBeInTheDocument(); await user.click(screen.getByRole('menuitem', { name: 'Regenerate' })); - expect(onRemoveMfaMethod).toHaveBeenCalledWith('sms_1'); + expect(onRemove).toHaveBeenCalledWith('sms_1'); expect(onRegenerateBackupCodes).toHaveBeenCalledOnce(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx index 94353ef0677..17a724c5502 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.feature.test.tsx @@ -117,7 +117,11 @@ describe('Active devices', () => { }, { ...device('sess_expired', 'active', { device_type: 'Expired laptop' }), expire_at: 1 }, ]); - await renderWithClerk(} />); + await renderWithClerk( + + + , + ); expect(await screen.findByRole('button', { name: 'Manage Safari on MacBook Pro' })).toBeInTheDocument(); expect(screen.getByText('This device')).toBeVisible(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx index 0ea6ac3d1cb..0e8d0096d89 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts.fixtures.tsx @@ -67,14 +67,9 @@ export async function renderSection(accounts = [google], overrides: FakeFapiSeed const fapi = serveFapi(signedIn(accounts, overrides)); const titleRef = createRef(); const view = await renderWithClerk( - titleRef.current} />} - />, + + titleRef.current} /> + , ); return { ...view, fapi }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 73f76259b50..f4a2ae0d11d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -3,24 +3,13 @@ import { expect, it } from 'vitest'; import { serveFapi } from '../../../../__tests__/feature/fake-fapi'; import { renderWithClerk } from '../../../../__tests__/feature/render'; -import { UserProfileProfilePanelView } from '../../user-profile-profile-panel.view'; -import { UserProfileWeb3WalletsSectionView } from '../../user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; -import { UserProfileEnterpriseAccountsSection } from '../user-profile-enterprise-accounts-section'; +import { UserProfileProfilePanel } from '../../user-profile-profile-panel'; import { enterpriseAccountSeed } from './enterprise-accounts.fixtures'; -it('places the connected enterprise section before Web3 wallets in the profile host', async () => { +it('renders the enterprise section in the default profile panel before the danger zone', async () => { serveFapi(enterpriseAccountSeed()); - await renderWithClerk( - } - web3WalletsSlot={ - - } - />, - ); + await renderWithClerk(); const enterprise = await screen.findByRole('group', { name: 'Enterprise accounts' }); - const wallets = screen.getByRole('group', { name: 'Web3 wallets' }); - expect(enterprise.compareDocumentPosition(wallets) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const danger = screen.getByRole('heading', { name: 'Danger zone' }); + expect(enterprise.compareDocumentPosition(danger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx index 5a0c24d00b1..2fffebbdd1c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section.view.tsx @@ -47,65 +47,67 @@ export function UserProfilePasskeysSectionView({ return ( <> - - - - {m.label} - - {addError} - - - {onAdd ? ( - - - - ) : null} - - - - {passkeys.length > 0 ? ( - passkeys.map(passkey => ( - removePasskey.open(passkey) : undefined} - /> - )) - ) : ( - - - {m.empty} - - - )} - - - + variant='outline' + disabled={isAdding} + aria-busy={isAdding} + onClick={onAdd} + > + + {m.add} + + + ) : null} + + + + {passkeys.length > 0 ? ( + passkeys.map(passkey => ( + removePasskey.open(passkey) : undefined} + /> + )) + ) : ( + + + {m.empty} + + + )} + + + + {onRemove ? ( vi.restoreAllMocks()); -function SecurityHost(props: Pick = {}) { - const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel()); - return ( - - ); -} - function serveAccounts(enabled = true) { vi.spyOn(navigator, 'webdriver', 'get').mockReturnValue(false); const environment = fapiEnvironment(); @@ -181,28 +166,6 @@ describe('Composing passkeys in Security', () => { expect(screen.queryByRole('group', { name: 'Passkeys' })).toBeNull(); expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull(); }); - - it('does not leave an empty Authentication region when a connected passkeys slot is hidden', async () => { - serveAccounts(false); - await renderWithClerk(); - expect(screen.queryByText('Passkeys')).toBeNull(); - expect(screen.queryByRole('region', { name: 'Authentication' })).toBeNull(); - }); - - it('orders connected passkeys before MFA and active devices', async () => { - serveAccounts(); - await renderWithClerk( - , - ); - expect(screen.getByRole('region', { name: 'Authentication' })).toHaveTextContent('Passkeys'); - expect( - screen.getByText('Passkeys').compareDocumentPosition(screen.getByText('2-step verification')) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - }); }); describe('Submitting passkey actions', () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx index cad3db5838e..f15164de3ef 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-passkeys-section/__tests__/user-profile-passkeys-section.feature.test.tsx @@ -17,7 +17,7 @@ import { renderWithClerk } from '../../../../__tests__/feature/render'; import { MosaicProvider } from '../../../../mosaic-provider'; import { UserProfilePasskeysSectionView } from '../../user-profile-passkeys-section.view'; import { UserProfileSecurityPanelView } from '../../user-profile-security-panel.view'; -import { passkeysSectionNode, UserProfilePasskeysSection } from '../user-profile-passkeys-section'; +import { UserProfilePasskeysSection } from '../user-profile-passkeys-section'; import { useUserProfilePasskeysModel } from '../user-profile-passkeys-section.model'; function ReadonlyPasskeys() { @@ -26,8 +26,11 @@ function ReadonlyPasskeys() { } function SecurityPanel() { - const passkeysSlot = passkeysSectionNode(useUserProfilePasskeysModel()); - return ; + return ( + + + + ); } function servePasskeys(passkeys: PasskeyJSON[] = [fapiPasskey({ id: 'pk_1' })]) { @@ -473,7 +476,6 @@ describe('Satellite passkeys', () => { expect(screen.getByRole('group', { name: 'Passkeys' })).toBeVisible(); expect(screen.getByText('No passkeys added')).toBeVisible(); expect(screen.queryByRole('button', { name: 'Add passkey' })).toBeNull(); - expect(screen.getByRole('region', { name: 'Authentication' })).toBeVisible(); }); it('removes the final passkey without Add', async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index c0a8fd73b95..37cf5301cf5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -22,19 +22,21 @@ export function UserProfilePasswordSectionView({ } return ( - - - - {m.label} - - - - - - + + + + + {m.label} + + + + + + + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx new file mode 100644 index 00000000000..9bf7400227d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.tsx @@ -0,0 +1,35 @@ +import { type ReactNode, useRef } from 'react'; + +import { UserProfileConnectedAccountsSection } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section'; +import { UserProfileDangerSection } from './user-profile-danger-section/user-profile-danger-section'; +import { UserProfileEmailSection } from './user-profile-email-section/user-profile-email-section'; +import { UserProfileEnterpriseAccountsSection } from './user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section'; +import { UserProfilePhoneSection } from './user-profile-phone-section/user-profile-phone-section'; +import { UserProfileProfilePanelView } from './user-profile-profile-panel.view'; +import { UserProfileProfileSection } from './user-profile-profile-section/user-profile-profile-section'; +import { UserProfileWeb3WalletsSection } from './user-profile-web3-wallets-section/user-profile-web3-wallets-section'; + +export interface UserProfileProfilePanelProps { + children?: ReactNode; +} + +export function UserProfileProfilePanel({ children }: UserProfileProfilePanelProps) { + const titleRef = useRef(null); + const focusTitle = () => titleRef.current; + + return ( + + {children ?? ( + <> + + + + + + + + + )} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 0dee162ac7c..021de69f020 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -12,25 +12,10 @@ export type { UserProfileEditNameValue } from './user-profile-profile-section/us export interface UserProfileProfilePanelViewProps { titleRef?: Ref; - profileSlot?: ReactNode; - emailSlot?: ReactNode; - phoneSlot?: ReactNode; - connectedAccountsSlot?: ReactNode; - web3WalletsSlot?: ReactNode; - enterpriseAccountsSlot?: ReactNode; - dangerSlot?: ReactNode; + children?: ReactNode; } -export function UserProfileProfilePanelView({ - titleRef, - profileSlot, - emailSlot, - phoneSlot, - connectedAccountsSlot, - web3WalletsSlot, - enterpriseAccountsSlot, - dangerSlot, -}: UserProfileProfilePanelViewProps): ReactElement { +export function UserProfileProfilePanelView({ titleRef, children }: UserProfileProfilePanelViewProps): ReactElement { return ( }> Account - - {profileSlot} - {emailSlot} - {phoneSlot} - {connectedAccountsSlot} - {enterpriseAccountsSlot} - {web3WalletsSlot} - {dangerSlot} - + {children} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx index 7fef904b6ad..6af91bd593e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-list.tsx @@ -24,48 +24,50 @@ export function UserProfileSecurityList({ children: ReactNode; }) { return ( - - - - {label} - - {addControl ? ( - {addControl} - ) : onAdd ? ( - - - - ) : null} - - - - {hasItems ? ( - children - ) : ( - - - {emptyLabel} - - - )} - - - + variant='outline' + onClick={onAdd} + > + + Add + + + ) : null} + + + + {hasItems ? ( + children + ) : ( + + + {emptyLabel} + + + )} + + + + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.tsx index 031d401419e..7f7566d4be0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.tsx @@ -1,22 +1,26 @@ import type { ReactNode } from 'react'; -import { renderPasswordSection } from './user-profile-password-section/user-profile-password-section'; -import { useUserProfilePasswordModel } from './user-profile-password-section/user-profile-password-section.model'; -import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; +import { UserProfileActiveDevicesSection } from './user-profile-active-devices-section/user-profile-active-devices-section'; +import { UserProfileMfaSection } from './user-profile-mfa-section/user-profile-mfa-section'; +import { UserProfilePasskeysSection } from './user-profile-passkeys-section/user-profile-passkeys-section'; +import { UserProfilePasswordSection } from './user-profile-password-section/user-profile-password-section'; import { UserProfileSecurityPanelView } from './user-profile-security-panel.view'; -export interface UserProfileSecurityPanelProps extends Omit { - passwordFallback?: ReactNode; +export interface UserProfileSecurityPanelProps { + children?: ReactNode; } -export function UserProfileSecurityPanel({ passwordFallback = null, ...props }: UserProfileSecurityPanelProps) { - const password = useUserProfilePasswordModel(); - const passwordSlot = renderPasswordSection(password, passwordFallback); - +export function UserProfileSecurityPanel({ children }: UserProfileSecurityPanelProps) { return ( - + + {children ?? ( + <> + + + + + + )} + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 9c6d7405d99..8af1d1339ad 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -1,74 +1,25 @@ import type { ReactElement, ReactNode } from 'react'; import { Panel } from '../../components/panel'; -import { Section } from '../../components/section'; import { themeProps } from '../../props'; import type { UserProfileDevice } from './user-profile-active-devices-section/user-profile-active-devices-section.view'; import type { UserProfileMfaAddableMethod, UserProfileMfaMethod, } from './user-profile-mfa-section/user-profile-mfa-section.view'; -import { UserProfileMfaSectionView } from './user-profile-mfa-section/user-profile-mfa-section.view'; import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; export type { UserProfileDevice, UserProfileMfaAddableMethod, UserProfileMfaMethod, UserProfilePasskey }; export interface UserProfileSecurityPanelViewProps { - passwordSlot?: ReactNode; - passkeysSlot?: ReactNode; - mfaSlot?: ReactNode; - mfaMethods?: UserProfileMfaMethod[]; - addableMfaMethods?: readonly UserProfileMfaAddableMethod[]; - mfaAddControl?: ReactNode; - activeDevicesSlot?: ReactNode; - onAddMfaMethod?: (type: UserProfileMfaAddableMethod) => void; - onRegenerateBackupCodes?: () => void; - onRemoveMfaMethod?: (id: string) => void | Promise; - onSetDefaultMfaMethod?: (id: string) => void | Promise; + children?: ReactNode; } -export function UserProfileSecurityPanelView({ - passwordSlot, - passkeysSlot, - mfaSlot, - mfaMethods, - addableMfaMethods, - mfaAddControl, - activeDevicesSlot, - onAddMfaMethod, - onRegenerateBackupCodes, - onRemoveMfaMethod, - onSetDefaultMfaMethod, -}: UserProfileSecurityPanelViewProps): ReactElement { - const selectedMfaNode = - mfaSlot !== undefined ? ( - mfaSlot - ) : mfaMethods !== undefined ? ( - - ) : null; - const hasAuthentication = passwordSlot != null || Boolean(passkeysSlot) || Boolean(selectedMfaNode); - +export function UserProfileSecurityPanelView({ children }: UserProfileSecurityPanelViewProps): ReactElement { return ( }> Security - - {hasAuthentication ? ( - - {passwordSlot} - {passkeysSlot} - {selectedMfaNode} - - ) : null} - {activeDevicesSlot} - + {children} ); } diff --git a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx index 0a5e4aa3f58..81babebf5b1 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-mfa-example.tsx @@ -1,6 +1,5 @@ import { UserProfileAddMfaDialog } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-add-mfa.dialog'; import { UserProfileMfaSetupView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-setup.view'; -import type { UserProfileSecurityPanelViewProps } from '@clerk/mosaic/features/user-profile/user-profile-security-panel.view'; import { useUserProfileMfaFixture } from './user-profile-mfa'; @@ -24,23 +23,5 @@ export function useUserProfileMfaExample() { ); const section = { ...fixture.section, addControl }; - const security: Pick< - UserProfileSecurityPanelViewProps, - | 'mfaMethods' - | 'addableMfaMethods' - | 'mfaAddControl' - | 'onAddMfaMethod' - | 'onSetDefaultMfaMethod' - | 'onRemoveMfaMethod' - | 'onRegenerateBackupCodes' - > = { - mfaMethods: section.methods, - addableMfaMethods: section.addableMethods, - mfaAddControl: addControl, - onAddMfaMethod: section.onAdd, - onSetDefaultMfaMethod: section.onSetDefault, - onRemoveMfaMethod: section.onRemove, - onRegenerateBackupCodes: section.onRegenerateBackupCodes, - }; - return { section, security }; + return { section }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 9a199bb34ae..073b6297854 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -12,6 +12,7 @@ import type { } from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; import { UserProfileDangerSectionView } from '@clerk/mosaic/features/user-profile/user-profile-danger-section/user-profile-danger-section.view'; import { UserProfileEmailSectionView } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.view'; +import { UserProfileMfaSectionView } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-section.view'; import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.view'; @@ -108,28 +109,32 @@ export function useUserProfileFixture() { const pages: UserProfileViewProps['pages'] = { account: { titleRef, - profileSlot: , - emailSlot: , - phoneSlot: , - connectedAccountsSlot: ( - titleRef.current} - /> + children: ( + <> + + + + titleRef.current} + /> + titleRef.current} + /> + + ), - web3WalletsSlot: ( - titleRef.current} - /> - ), - dangerSlot: , }, security: { - passwordSlot: , - passkeysSlot: , - ...mfa.security, - activeDevicesSlot: , + children: ( + <> + + + + + + ), }, billing: { subscription, diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 78836052c38..00135378f8e 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -15,23 +15,23 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st ## Usage -Each section arrives as a slot, so the panel only arranges them under the Account title. +`UserProfileProfilePanel` renders the Account title and every account section. Each section shows itself only when the signed-in user and the instance settings call for it. ```tsx -import { useRef } from 'react'; -import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; -import { UserProfileProfileSection } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section'; -import { UserProfileEmailSection } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section'; -import { UserProfilePhoneSection } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section'; -import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section'; +import { UserProfileProfilePanel } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel'; -const titleRef = useRef(null); +; +``` -} - emailSlot={} - phoneSlot={} - connectedAccountsSlot={ titleRef.current} />} -/> +Pass sections as children to choose which ones render and in what order. + +```tsx +import { UserProfileEmailSection } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section'; +import { UserProfileProfilePanel } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel'; +import { UserProfileProfileSection } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section'; + + + + +; ``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index e976f55794b..2c7ce6ce15d 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -35,24 +35,19 @@ export function Default(_args: Record) { const phones = useUserProfilePhonesFixture(); return ( - } - emailSlot={} - phoneSlot={} - connectedAccountsSlot={ - titleRef.current} - /> - } - web3WalletsSlot={ - titleRef.current} - /> - } - dangerSlot={} - /> + + + + + titleRef.current} + /> + titleRef.current} + /> + + ); } diff --git a/packages/swingset/src/stories/user-profile-security-panel.mdx b/packages/swingset/src/stories/user-profile-security-panel.mdx index 15111d646fb..1684386a45d 100644 --- a/packages/swingset/src/stories/user-profile-security-panel.mdx +++ b/packages/swingset/src/stories/user-profile-security-panel.mdx @@ -2,12 +2,6 @@ import * as Stories from './user-profile-security-panel.stories'; # UserProfileSecurityPanel -The security panel groups the available authentication methods and active devices. For a live -password section, render `UserProfileSecurityPanel`. It reads the password model and -passes content only when the password section is available or has a loading fallback, -so an empty Authentication section stays hidden. Fixtures pass -`` directly as `passwordSlot`. - ## Example + +## Usage + +`UserProfileSecurityPanel` renders the Security title with the password, passkeys, 2-step verification, and active devices sections. Each section shows itself only when the signed-in user and the instance settings call for it. + +```tsx +import { UserProfileSecurityPanel } from '@clerk/mosaic/features/user-profile/user-profile-security-panel'; + +; +``` + +Pass sections as children to choose which ones render and in what order. + +```tsx +import { UserProfileMfaSection } from '@clerk/mosaic/features/user-profile/user-profile-mfa-section/user-profile-mfa-section'; +import { UserProfilePasswordSection } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section'; +import { UserProfileSecurityPanel } from '@clerk/mosaic/features/user-profile/user-profile-security-panel'; + + + + +; +```