diff --git a/.changeset/profile-composed-userprofile.md b/.changeset/profile-composed-userprofile.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/profile-composed-userprofile.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx
new file mode 100644
index 00000000000..e627775110e
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { APIKeysSection } from '../APIKeysSection';
+
+const APIKeysPage = lazy(() =>
+ import('../../components/UserProfile/APIKeysPage').then(m => ({
+ default: m.APIKeysPage,
+ })),
+);
+
+export const UserProfileAPIKeysPanel = (): ReactNode => ;
diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx
new file mode 100644
index 00000000000..5672ae47c38
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Account.tsx
@@ -0,0 +1,40 @@
+'use client';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import { CardStateProvider, useCardState } from '@/ui/elements/contexts';
+import { ProfileCard } from '@/ui/elements/ProfileCard';
+
+import { AccountPage } from '../../components/UserProfile/AccountPage';
+import { localizationKeys } from '../../customizables';
+import { PageContext } from '../PageContext';
+
+function AccountComposed({ children }: PropsWithChildren): ReactNode {
+ const card = useCardState();
+ return (
+
+ ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })}
+ >
+ {children}
+
+
+ );
+}
+
+export function UserProfileAccountPanel({ children }: PropsWithChildren): ReactNode {
+ if (!children) {
+ return ;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx
new file mode 100644
index 00000000000..280352a05ef
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountConnectedAccounts as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileConnectedAccountsSection = createSection('UserProfileConnectedAccountsSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountEmails.tsx b/packages/ui/src/composed/UserProfile/AccountEmails.tsx
new file mode 100644
index 00000000000..f4aab0d638b
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountEmails.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountEmails as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileEmailSection = createSection('UserProfileEmailSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx
new file mode 100644
index 00000000000..e0f6e527b9b
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountEnterpriseAccounts as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileEnterpriseAccountsSection = createSection('UserProfileEnterpriseAccountsSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountPhone.tsx b/packages/ui/src/composed/UserProfile/AccountPhone.tsx
new file mode 100644
index 00000000000..b24c5eaa551
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountPhone.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountPhone as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfilePhoneSection = createSection('UserProfilePhoneSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountProfile.tsx b/packages/ui/src/composed/UserProfile/AccountProfile.tsx
new file mode 100644
index 00000000000..0e5ee1d5499
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountProfile.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { UserProfileSection } from '../../components/UserProfile/UserProfileSection';
+import { createSection } from '../createSection';
+
+export const UserProfileProfileSection = createSection('UserProfileProfileSection', UserProfileSection);
diff --git a/packages/ui/src/composed/UserProfile/AccountUsername.tsx b/packages/ui/src/composed/UserProfile/AccountUsername.tsx
new file mode 100644
index 00000000000..8976d221197
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountUsername.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountUsername as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileUsernameSection = createSection('UserProfileUsernameSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/AccountWeb3.tsx b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx
new file mode 100644
index 00000000000..44b1df38425
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { AccountWeb3 as Section } from '../../components/UserProfile/AccountSections';
+import { createSection } from '../createSection';
+
+export const UserProfileWeb3Section = createSection('UserProfileWeb3Section', Section);
diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx
new file mode 100644
index 00000000000..a8ad004e6f2
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Billing.tsx
@@ -0,0 +1,38 @@
+'use client';
+
+import { lazy, type ReactNode } from 'react';
+
+import { BillingSection } from '../BillingSection';
+
+const BillingPage = lazy(() =>
+ import('../../components/UserProfile/BillingPage').then(m => ({
+ default: m.BillingPage,
+ })),
+);
+
+const PlansPage = lazy(() =>
+ import('../../components/UserProfile/PlansPage').then(m => ({
+ default: m.PlansPage,
+ })),
+);
+
+const StatementPage = lazy(() =>
+ import('../../components/Statements').then(m => ({
+ default: m.StatementPage,
+ })),
+);
+
+const PaymentAttemptPage = lazy(() =>
+ import('../../components/PaymentAttempts').then(m => ({
+ default: m.PaymentAttemptPage,
+ })),
+);
+
+export const UserProfileBillingPanel = (): ReactNode => (
+
+);
diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx
new file mode 100644
index 00000000000..451e545467c
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/Security.tsx
@@ -0,0 +1,39 @@
+'use client';
+
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import { CardStateProvider, useCardState } from '@/ui/elements/contexts';
+import { ProfileCard } from '@/ui/elements/ProfileCard';
+
+import { SecurityPage } from '../../components/UserProfile/SecurityPage';
+import { localizationKeys } from '../../customizables';
+import { PageContext } from '../PageContext';
+
+function SecurityComposed({ children }: PropsWithChildren): ReactNode {
+ const card = useCardState();
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function UserProfileSecurityPanel({ children }: PropsWithChildren): ReactNode {
+ if (!children) {
+ return ;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx
new file mode 100644
index 00000000000..0f25cfad6ac
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection';
+import { createSection } from '../createSection';
+
+export const UserProfileActiveDevicesSection = createSection('UserProfileActiveDevicesSection', ActiveDevicesSection);
diff --git a/packages/ui/src/composed/UserProfile/SecurityDelete.tsx b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx
new file mode 100644
index 00000000000..9e116c958de
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityDelete as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfileDeleteSection = createSection('UserProfileDeleteSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityMfa.tsx b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx
new file mode 100644
index 00000000000..ad5c61564d6
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityMfa as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfileMfaSection = createSection('UserProfileMfaSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx
new file mode 100644
index 00000000000..b0cf767a6d1
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityPasskeys as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfilePasskeysSection = createSection('UserProfilePasskeysSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/SecurityPassword.tsx b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx
new file mode 100644
index 00000000000..3a4ad201999
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx
@@ -0,0 +1,6 @@
+'use client';
+
+import { SecurityPassword as Section } from '../../components/UserProfile/SecuritySections';
+import { createSection } from '../createSection';
+
+export const UserProfilePasswordSection = createSection('UserProfilePasswordSection', Section);
diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx
new file mode 100644
index 00000000000..a7b1e6f9311
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx
@@ -0,0 +1,53 @@
+'use client';
+
+import { useClerk, useUser } from '@clerk/shared/react';
+import type { EnvironmentResource, OAuthProvider, OAuthScope, UserProfileProps } from '@clerk/shared/types';
+import type { PropsWithChildren, ReactNode } from 'react';
+
+import type { Appearance } from '@/ui/internal/appearance';
+
+import { UserProfileContext } from '../../contexts/components/UserProfile';
+import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell';
+
+type UserProfileProviderProps = PropsWithChildren<{
+ appearance?: Appearance;
+ additionalOAuthScopes?: Partial>;
+ apiKeysProps?: UserProfileProps['apiKeysProps'];
+}>;
+
+export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => {
+ const { children, appearance, additionalOAuthScopes, apiKeysProps } = props;
+ const clerk = useClerk();
+ const { isLoaded, user } = useUser();
+
+ const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined;
+ const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager;
+
+ if (!isLoaded || !user || !environment) {
+ return null;
+ }
+
+ const userProfileCtxValue = {
+ componentName: 'UserProfile' as const,
+ mode: 'mounted' as const,
+ routing: 'hash' as const,
+ path: undefined,
+ additionalOAuthScopes,
+ apiKeysProps,
+ customPages: [],
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx
new file mode 100644
index 00000000000..7de69d4d962
--- /dev/null
+++ b/packages/ui/src/composed/UserProfile/index.tsx
@@ -0,0 +1,17 @@
+export { UserProfileProvider } from './UserProfileProvider';
+export { UserProfileAccountPanel } from './Account';
+export { UserProfileSecurityPanel } from './Security';
+export { UserProfileBillingPanel } from './Billing';
+export { UserProfileAPIKeysPanel } from './APIKeys';
+export { UserProfileProfileSection } from './AccountProfile';
+export { UserProfileUsernameSection } from './AccountUsername';
+export { UserProfileEmailSection } from './AccountEmails';
+export { UserProfilePhoneSection } from './AccountPhone';
+export { UserProfileConnectedAccountsSection } from './AccountConnectedAccounts';
+export { UserProfileEnterpriseAccountsSection } from './AccountEnterpriseAccounts';
+export { UserProfileWeb3Section } from './AccountWeb3';
+export { UserProfilePasswordSection } from './SecurityPassword';
+export { UserProfilePasskeysSection } from './SecurityPasskeys';
+export { UserProfileMfaSection } from './SecurityMfa';
+export { UserProfileActiveDevicesSection } from './SecurityActiveDevices';
+export { UserProfileDeleteSection } from './SecurityDelete';
diff --git a/packages/ui/src/composed/__tests__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx
new file mode 100644
index 00000000000..ba9d832903d
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx
@@ -0,0 +1,56 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { AccountPage } from '../../components/UserProfile/AccountPage';
+import { SecurityPage } from '../../components/UserProfile/SecurityPage';
+import { clearFetchCache } from '../../hooks';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('Experimental UserProfile', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ // The page components (AccountPage/SecurityPage) are thin wrappers that render
+ // the composed panels. The section-visibility matrix is asserted once at the
+ // component level in AccountSections/SecuritySections; here we only cover what
+ // the page level adds — the enterprise-SSO additional-identification guard —
+ // plus a single mount smoke per page.
+ describe('Account page', () => {
+ it('hides add buttons when enterprise SSO disables additional identifications', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ enterprise_accounts: [
+ {
+ active: true,
+ enterprise_connection: {
+ disable_additional_identifications: true,
+ },
+ } as any,
+ ],
+ });
+ f.withEnterpriseSso();
+ });
+
+ const { queryByRole } = render(, { wrapper });
+ expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Security page', () => {
+ it('renders active devices section', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ render(, { wrapper });
+ await waitFor(() => screen.getByText(/active devices/i));
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx
new file mode 100644
index 00000000000..1fcb41b7502
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx
@@ -0,0 +1,320 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { clearFetchCache } from '../../hooks';
+import { UserProfileAccountPanel } from '../UserProfile/Account';
+import { UserProfileConnectedAccountsSection } from '../UserProfile/AccountConnectedAccounts';
+import { UserProfileEmailSection } from '../UserProfile/AccountEmails';
+import { UserProfileEnterpriseAccountsSection } from '../UserProfile/AccountEnterpriseAccounts';
+import { UserProfilePhoneSection } from '../UserProfile/AccountPhone';
+import { UserProfileProfileSection } from '../UserProfile/AccountProfile';
+import { UserProfileUsernameSection } from '../UserProfile/AccountUsername';
+import { UserProfileWeb3Section } from '../UserProfile/AccountWeb3';
+import { UserProfileSecurityPanel } from '../UserProfile/Security';
+import { UserProfileActiveDevicesSection } from '../UserProfile/SecurityActiveDevices';
+import { UserProfileDeleteSection } from '../UserProfile/SecurityDelete';
+import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+describe('UserProfile composed sections', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ });
+
+ describe('Account — inline form flow', () => {
+ it('inline form flow: update profile opens form', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withName();
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ const { getByRole, getByLabelText, userEvent } = render(, { wrapper });
+ await userEvent.click(getByRole('button', { name: /update profile/i }));
+ await waitFor(() => getByLabelText(/first name/i));
+ expect(getByLabelText(/first name/i)).toBeInTheDocument();
+ });
+ });
+
+ describe('Account — section composition mode', () => {
+ it('renders only declared sections', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withPhoneNumber();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ phone_numbers: ['+11111111111'],
+ });
+ });
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Test User');
+ expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0);
+ expect(queryByText(/phone number/i)).not.toBeInTheDocument();
+ });
+
+ it('renders header', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Profile details');
+ });
+
+ it('renders custom content between sections', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ render(
+
+
+ Custom content
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getByTestId('custom-banner')).toBeInTheDocument();
+ screen.getByText('Custom content');
+ });
+
+ it('environment guard: disabled email renders null', async () => {
+ const { wrapper } = await createFixtures(f => {
+ // Email NOT enabled
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' });
+ });
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Test User');
+ expect(queryByText(/email address/i)).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Account — individual sections', () => {
+ it('UserProfileProfileSection renders user name', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Jane', last_name: 'Doe' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('Jane Doe');
+ });
+
+ it('UserProfileUsernameSection renders when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUsername();
+ f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('jdoe');
+ });
+
+ it('UserProfileUsernameSection renders null when disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' });
+ });
+
+ const { container } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(container.querySelector('[class*="profileSection"]')).not.toBeInTheDocument();
+ });
+
+ it('UserProfileEmailSection renders email list', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({ email_addresses: ['primary@clerk.com', 'secondary@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText('primary@clerk.com');
+ screen.getByText('secondary@clerk.com');
+ });
+
+ it('UserProfilePhoneSection renders phone list when enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withPhoneNumber();
+ f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0);
+ });
+
+ it('UserProfileConnectedAccountsSection renders when social providers enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withSocialProvider({ provider: 'google' });
+ f.withUser({
+ email_addresses: ['test@clerk.com'],
+ external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }],
+ });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText(/connected accounts/i);
+ });
+
+ it('UserProfileEnterpriseAccountsSection renders null when enterprise SSO disabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { queryByText } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ expect(queryByText(/enterprise accounts/i)).not.toBeInTheDocument();
+ });
+
+ it('UserProfileWeb3Section renders when web3_wallet enabled', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withWeb3Wallet();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ screen.getByText(/web3 wallets/i);
+ });
+ });
+
+ describe('Account — section outside page', () => {
+ it('useRequirePage throws when rendered outside a page component', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ expect(() => render(, { wrapper })).toThrow(
+ ' must be rendered inside a page component',
+ );
+ });
+ });
+
+ describe('Security — section composition mode', () => {
+ it('renders only declared sections', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withPassword();
+ f.withPasskey();
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/^password/i));
+ screen.getByText(/active devices/i);
+ expect(queryByText(/^passkeys/i)).not.toBeInTheDocument();
+ expect(queryByText(/delete account/i)).not.toBeInTheDocument();
+ });
+
+ it('UserProfileActiveDevicesSection renders without guard', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/active devices/i));
+ });
+
+ it('UserProfileDeleteSection respects user flag', async () => {
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false });
+ });
+ fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([]));
+
+ const { queryByText } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ await waitFor(() => screen.getByText(/active devices/i));
+ expect(queryByText(/delete account/i)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/composed/__tests__/action-animation.test.tsx b/packages/ui/src/composed/__tests__/action-animation.test.tsx
new file mode 100644
index 00000000000..6b46dfe868f
--- /dev/null
+++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.unmock('@formkit/auto-animate/react');
+vi.unmock('@formkit/auto-animate');
+
+import { bindCreateFixtures } from '@/test/create-fixtures';
+import { render, screen, waitFor } from '@/test/utils';
+
+import { clearFetchCache } from '../../hooks';
+import { UserProfileAccountPanel } from '../UserProfile/Account';
+import { UserProfileEmailSection } from '../UserProfile/AccountEmails';
+import { UserProfileProfileSection } from '../UserProfile/AccountProfile';
+
+const { createFixtures } = bindCreateFixtures('UserProfile');
+
+function findAddAnimationCall(calls: any[]) {
+ return calls.find(call => {
+ const keyframes = call[0];
+ if (!Array.isArray(keyframes)) {
+ return false;
+ }
+ return keyframes.some(
+ (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'),
+ );
+ });
+}
+
+describe('Action open animation', () => {
+ beforeEach(() => {
+ clearFetchCache();
+ vi.mocked(Element.prototype.animate).mockClear();
+ });
+
+ it('calls el.animate with add keyframes when "Add email" action opens', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ });
+ });
+
+ const { userEvent } = render(, { wrapper });
+ vi.mocked(Element.prototype.animate).mockClear();
+
+ await userEvent.click(screen.getByRole('button', { name: /add email address/i }));
+ await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument());
+
+ expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined();
+ });
+
+ it('composed sections: "Add email" triggers add animation', async () => {
+ const { wrapper } = await createFixtures(f => {
+ f.withEmailAddress();
+ f.withUser({
+ first_name: 'Test',
+ last_name: 'User',
+ email_addresses: ['test@clerk.com'],
+ });
+ });
+
+ const { userEvent } = render(
+
+
+
+ ,
+ { wrapper },
+ );
+
+ vi.mocked(Element.prototype.animate).mockClear();
+ await userEvent.click(screen.getByRole('button', { name: /add email address/i }));
+ await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument());
+
+ expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined();
+ });
+});