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 ? (
-
-
-
+
+
+
+ {label}
+
+ {addControl ? (
+ {addControl}
+ ) : onAdd ? (
+
+
- Add
-
-
- ) : 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';
+
+
+
+
+;
+```