From ed6f8c4b2cd1c06c4955fbc8b2e7c603d5230478 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 10 Jul 2026 16:52:47 -0400 Subject: [PATCH] refactor(ui): extract reusable Section components from profile pages --- .changeset/profile-extract-sections.md | 2 + .../components/ConfigureSSO/ConfigureSSO.tsx | 2 +- .../OrganizationBillingPage.tsx | 2 +- .../OrganizationGeneralPage.tsx | 66 +++--- .../OrganizationMembers.tsx | 1 + .../components/UserProfile/APIKeysPage.tsx | 1 + .../components/UserProfile/AccountPage.tsx | 89 ++------ .../UserProfile/AccountSections.tsx | 90 ++++++++ .../components/UserProfile/BillingPage.tsx | 2 +- .../components/UserProfile/SecurityPage.tsx | 52 +---- .../UserProfile/SecuritySections.tsx | 43 ++++ .../src/components/UserProfile/Web3Form.tsx | 11 + .../__tests__/AccountSections.test.tsx | 209 ++++++++++++++++++ .../__tests__/SecuritySections.test.tsx | 114 ++++++++++ .../__tests__/Web3Section.test.tsx | 52 ++++- 15 files changed, 596 insertions(+), 140 deletions(-) create mode 100644 .changeset/profile-extract-sections.md create mode 100644 packages/ui/src/components/UserProfile/AccountSections.tsx create mode 100644 packages/ui/src/components/UserProfile/SecuritySections.tsx create mode 100644 packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx create mode 100644 packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx diff --git a/.changeset/profile-extract-sections.md b/.changeset/profile-extract-sections.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-extract-sections.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index 5b9aa8764a0..f247f215933 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -43,7 +43,7 @@ const AuthenticatedContent = withCoreUserGuard(() => { ); }); -const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { +export const ConfigureSSOContent = ({ contentRef }: { contentRef: React.RefObject }) => { const { isLoading, enterpriseConnection, diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx index f47d28b8aad..2470257420d 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -29,7 +29,7 @@ const OrganizationBillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { export const OrganizationGeneralPage = () => { return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - - - - - - - + + + + + + + ); }; -const OrganizationProfileSection = () => { +/** + * Renders the organization profile section (name, logo) with inline edit when the user has + * `org:sys_profile:manage`. + * + * @returns The profile section, or `null` when no organization is active. + */ +export const OrganizationProfileSection = (): JSX.Element | null => { const { organization } = useOrganization(); if (!organization) { @@ -134,7 +127,13 @@ const OrganizationProfileSection = () => { ); }; -const OrganizationDomainsSection = () => { +/** + * Renders the verified-domains section. + * + * @returns The domains section, or `null` when domains are disabled, no organization is active, or + * there are no domains and the user cannot add any. + */ +export const OrganizationDomainsSection = (): JSX.Element | null => { const { organizationSettings } = useEnvironment(); const { organization, domains } = useOrganization({ domains: { infinite: true } }); const canManageDomains = useProtect({ permission: 'org:sys_domains:manage' }); @@ -190,7 +189,12 @@ const OrganizationDomainsSection = () => { ); }; -const OrganizationLeaveSection = () => { +/** + * Renders the "leave organization" action in the danger section. + * + * @returns The leave-organization section, or `null` when no organization is active. + */ +export const OrganizationLeaveSection = (): JSX.Element | null => { const { organization } = useOrganization(); if (!organization) { @@ -236,7 +240,13 @@ const OrganizationLeaveSection = () => { ); }; -const OrganizationDeleteSection = () => { +/** + * Renders the "delete organization" action in the danger section. + * + * @returns The delete-organization section, or `null` when no organization is active, the user + * lacks `org:sys_profile:delete`, or admin delete is disabled. + */ +export const OrganizationDeleteSection = (): JSX.Element | null => { const { organization } = useOrganization(); const canDeleteOrganization = useProtect({ permission: 'org:sys_profile:delete' }); diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx index 0b103b559e0..9109a5e2c1e 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationMembers.tsx @@ -55,6 +55,7 @@ export const OrganizationMembers = withCardStateProvider(() => { { { - const { attributes, social, enterpriseSSO } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); - - const showUsername = isAttributeAvailable(attributes.username); - const showEmail = isAttributeAvailable(attributes.email_address); - const showPhone = isAttributeAvailable(attributes.phone_number); - const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0; - const showEnterpriseAccounts = user && enterpriseSSO.enabled; - const showWeb3 = attributes.web3_wallet?.enabled; - - const isEmailImmutable = immutableAttributes.has('email_address'); - const isPhoneImmutable = immutableAttributes.has('phone_number'); - const isUsernameImmutable = immutableAttributes.has('username'); return ( - ({ gap: t.space.$8, color: t.colors.$colorForeground })} + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - - {card.error} - - - {showUsername && } - {showEmail && ( - - )} - {showPhone && ( - - )} - {showConnectedAccounts && ( - - )} - - {/*TODO-STEP-UP: Verify that these work as expected*/} - {showEnterpriseAccounts && } - {showWeb3 && } - - + + + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/AccountSections.tsx b/packages/ui/src/components/UserProfile/AccountSections.tsx new file mode 100644 index 00000000000..f7ceb3c5742 --- /dev/null +++ b/packages/ui/src/components/UserProfile/AccountSections.tsx @@ -0,0 +1,90 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { ConnectedAccountsSection } from './ConnectedAccountsSection'; +import { EmailsSection } from './EmailsSection'; +import { EnterpriseAccountsSection } from './EnterpriseAccountsSection'; +import { PhoneSection } from './PhoneSection'; +import { UsernameSection } from './UsernameSection'; +import { isAttributeAvailable } from './utils'; +import { Web3Section } from './Web3Section'; + +export function AccountUsername(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.username)) { + return null; + } + + const isImmutable = immutableAttributes.has('username'); + return ; +} + +export function AccountEmails(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.email_address)) { + return null; + } + + const isImmutable = immutableAttributes.has('email_address'); + return ( + + ); +} + +export function AccountPhone(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation, immutableAttributes } = useUserProfileContext(); + + if (!isAttributeAvailable(attributes.phone_number)) { + return null; + } + + const isImmutable = immutableAttributes.has('phone_number'); + return ( + + ); +} + +export function AccountConnectedAccounts(): ReactNode { + const { social } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!social || Object.values(social).filter(p => p.enabled).length === 0) { + return null; + } + + return ; +} + +export function AccountEnterpriseAccounts(): ReactNode { + const { enterpriseSSO } = useEnvironment().userSettings; + const { user } = useUser(); + + if (!user || !enterpriseSSO.enabled) { + return null; + } + + return ; +} + +export function AccountWeb3(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.web3_wallet?.enabled) { + return null; + } + + return ; +} diff --git a/packages/ui/src/components/UserProfile/BillingPage.tsx b/packages/ui/src/components/UserProfile/BillingPage.tsx index 3d555676142..e48beb9b6c4 100644 --- a/packages/ui/src/components/UserProfile/BillingPage.tsx +++ b/packages/ui/src/components/UserProfile/BillingPage.tsx @@ -27,7 +27,7 @@ const BillingPageInternal = withCardStateProvider(() => { ({ gap: t.space.$8, color: t.colors.$colorForeground })} + sx={t => ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} > { - const { attributes, instanceIsPasswordBased } = useEnvironment().userSettings; const card = useCardState(); - const { user } = useUser(); - const { shouldAllowIdentificationCreation } = useUserProfileContext(); - const showPassword = instanceIsPasswordBased; - const showPasskey = attributes.passkey?.enabled && shouldAllowIdentificationCreation; - const showMfa = getSecondFactors(attributes).length > 0; - const showDelete = user?.deleteSelfEnabled; return ( - ({ gap: t.space.$8 })} + - - - ({ marginBottom: t.space.$4 })} - textVariant='h2' - /> - - {card.error} - {showPassword && } - {showPasskey && } - {showMfa && } - - {showDelete && } - - + + + + + + ); }); diff --git a/packages/ui/src/components/UserProfile/SecuritySections.tsx b/packages/ui/src/components/UserProfile/SecuritySections.tsx new file mode 100644 index 00000000000..a1638f8dd7a --- /dev/null +++ b/packages/ui/src/components/UserProfile/SecuritySections.tsx @@ -0,0 +1,43 @@ +import { useUser } from '@clerk/shared/react'; +import type { ReactNode } from 'react'; + +import { getSecondFactors } from '@/ui/utils/mfa'; + +import { useEnvironment, useUserProfileContext } from '../../contexts'; +import { MfaSection } from './MfaSection'; +import { PasskeySection } from './PasskeySection'; +import { PasswordSection } from './PasswordSection'; +import { DeleteSection } from './DeleteSection'; + +export function SecurityPassword(): ReactNode { + const { instanceIsPasswordBased } = useEnvironment().userSettings; + + if (!instanceIsPasswordBased) return null; + + return ; +} + +export function SecurityPasskeys(): ReactNode { + const { attributes } = useEnvironment().userSettings; + const { shouldAllowIdentificationCreation } = useUserProfileContext(); + + if (!attributes.passkey?.enabled || !shouldAllowIdentificationCreation) return null; + + return ; +} + +export function SecurityMfa(): ReactNode { + const { attributes } = useEnvironment().userSettings; + + if (getSecondFactors(attributes).length === 0) return null; + + return ; +} + +export function SecurityDelete(): ReactNode { + const { user } = useUser(); + + if (!user?.deleteSelfEnabled) return null; + + return ; +} diff --git a/packages/ui/src/components/UserProfile/Web3Form.tsx b/packages/ui/src/components/UserProfile/Web3Form.tsx index 19c4b320c7a..39cb0e54f0a 100644 --- a/packages/ui/src/components/UserProfile/Web3Form.tsx +++ b/packages/ui/src/components/UserProfile/Web3Form.tsx @@ -1,3 +1,4 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; import { createWeb3 } from '@clerk/shared/internal/clerk-js/web3'; import { useReverification, useUser } from '@clerk/shared/react'; import type { Web3Provider, Web3Strategy } from '@clerk/shared/types'; @@ -56,6 +57,16 @@ export const AddWeb3WalletActionMenu = () => { throw new Error('user is not defined'); } + // `getWeb3Identifier` returns '' when the wallet provider (or its dynamic + // import) is unavailable. Posting an empty string yields an unhelpful 422 + // from FAPI (`form_param_nil` on `web3_wallet`); surface the missing- + // provider state locally instead. + if (!identifier) { + throw new ClerkRuntimeError('A Web3 Wallet extension cannot be found. Please install one to continue.', { + code: 'web3_missing_identifier', + }); + } + let web3Wallet = await createWeb3Wallet(identifier); web3Wallet = await web3Wallet?.prepareVerification({ strategy }); const message = web3Wallet?.verification.message as string; diff --git a/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx new file mode 100644 index 00000000000..8e039000601 --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/AccountSections.test.tsx @@ -0,0 +1,209 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { + AccountConnectedAccounts, + AccountEmails, + AccountEnterpriseAccounts, + AccountPhone, + AccountUsername, + AccountWeb3, +} from '../AccountSections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('AccountSections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('AccountUsername', () => { + it('renders when username is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'testuser' }); + }); + + render(, { wrapper }); + screen.getByText('testuser'); + }); + + it('returns null when username is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEmails', () => { + it('renders email list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['a@clerk.com', 'b@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText('a@clerk.com'); + screen.getByText('b@clerk.com'); + }); + + it('returns null when email is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountPhone', () => { + it('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('returns null when phone is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountConnectedAccounts', () => { + it('renders when social providers are 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('returns null when no social providers are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountEnterpriseAccounts', () => { + it('renders when enterprise SSO is enabled and the user has an active enterprise account', async () => { + const { wrapper } = await createFixtures(f => { + f.withEnterpriseSso(); + f.withUser({ + email_addresses: ['test@clerk.com'], + enterprise_accounts: [ + { + object: 'enterprise_account', + active: true, + first_name: 'Laura', + last_name: 'Serafim', + protocol: 'saml', + provider_user_id: null, + public_metadata: {}, + email_address: 'test@clerk.com', + provider: 'saml_okta', + enterprise_connection: { + object: 'enterprise_connection', + provider: 'saml_okta', + name: 'Okta Workforce', + id: 'ent_123', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + sync_user_attributes: false, + domain: 'foocorp.com', + created_at: 123, + updated_at: 123, + logo_public_url: null, + protocol: 'saml', + }, + verification: { + status: 'verified', + strategy: 'enterprise_sso', + verified_at_client: 'foo', + attempts: 0, + error: { + code: 'identifier_already_signed_in', + long_message: "You're already signed in", + message: "You're already signed in", + }, + expire_at: 123, + id: 'ver_123', + object: 'verification', + }, + id: 'eac_123', + }, + ], + }); + }); + + render(, { wrapper }); + screen.getByText(/enterprise accounts/i); + }); + + it('returns null when enterprise SSO is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('AccountWeb3', () => { + it('renders when web3_wallet is enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render(, { wrapper }); + screen.getByText(/web3 wallets/i); + }); + + it('returns null when web3_wallet is disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx new file mode 100644 index 00000000000..707cf50b8f2 --- /dev/null +++ b/packages/ui/src/components/UserProfile/__tests__/SecuritySections.test.tsx @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; +import { CardStateProvider } from '@/ui/elements/contexts'; + +import { clearFetchCache } from '../../../hooks'; +import { SecurityPassword, SecurityPasskeys, SecurityMfa, SecurityDelete } from '../SecuritySections'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('SecuritySections — self-gating visibility', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('SecurityPassword', () => { + it('renders when instance is password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withPassword(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^password/i); + }); + + it('returns null when instance is not password-based', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityPasskeys', () => { + it('renders when passkeys are enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + screen.getByText(/^passkeys/i); + }); + + it('returns null when passkeys are disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityMfa', () => { + it('renders when second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber({ used_for_second_factor: true, second_factors: ['phone_code'] }); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + expect(screen.getAllByText(/two-step verification/i).length).toBeGreaterThan(0); + }); + + it('returns null when no second factors are available', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); + + describe('SecurityDelete', () => { + it('renders when delete_self_enabled is true', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + + render(, { wrapper }); + expect(screen.getAllByText(/delete account/i).length).toBeGreaterThan(0); + }); + + it('returns null when delete_self_enabled is false', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false }); + }); + + const { container } = render(, { wrapper }); + expect(container.innerHTML).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx index 322ae3935d3..e972492ea2f 100644 --- a/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/Web3Section.test.tsx @@ -1,10 +1,25 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; -import { render } from '@/test/utils'; +import { render, waitFor } from '@/test/utils'; import { Web3Section } from '../Web3Section'; +vi.mock('@clerk/shared/internal/clerk-js/web3', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createWeb3: vi.fn(() => ({ + // Mirrors the empty-string fallback the shared helper returns when the + // wallet provider isn't resolvable (e.g. dynamic `@coinbase/wallet-sdk` + // import returned undefined because no module manager propagated to the + // composed/subcomponent UserProfile). + getWeb3Identifier: vi.fn(() => Promise.resolve('')), + generateWeb3Signature: vi.fn(() => Promise.resolve('')), + })), + }; +}); + const { createFixtures } = bindCreateFixtures('UserProfile'); const withMetamaskWallet = createFixtures.config(f => { @@ -130,4 +145,37 @@ describe('Web3Section', () => { // Admin wallet with just the address getByText('0xabcd...abcd'); }); + + // Regression: composed/subcomponent UserProfile mounts can fall back to a + // no-op ModuleManager when IsomorphicClerk doesn't propagate the wrapper's + // moduleManager (see packages/react isomorphicClerk test). When that + // happens, `createWeb3(...).getWeb3Identifier` returns '' instead of a + // wallet address — and we used to POST `{ web3_wallet: '' }` straight to + // FAPI, surfacing as a confusing 422 form_param_nil error. Guard the empty + // identifier locally so the failure mode is a clear UI message. + describe('AddWeb3WalletActionMenu — empty identifier guard', () => { + const withCoinbaseEnabled = createFixtures.config(f => { + f.withWeb3Wallet({ + first_factors: ['web3_coinbase_wallet_signature'], + verifications: ['web3_coinbase_wallet_signature'], + }); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + it('does not POST /me/web3_wallets when getWeb3Identifier returns ""', async () => { + const { wrapper, fixtures } = await createFixtures(withCoinbaseEnabled); + const createWeb3Wallet = vi.fn(); + fixtures.clerk.user!.createWeb3Wallet = createWeb3Wallet as any; + + const { getByRole, userEvent, findByText } = render(, { wrapper }); + + await userEvent.click(getByRole('button', { name: /Connect wallet/i })); + await userEvent.click(getByRole('menuitem', { name: /Coinbase Wallet/i })); + + await findByText(/Web3 Wallet extension cannot be found/i); + await waitFor(() => { + expect(createWeb3Wallet).not.toHaveBeenCalled(); + }); + }); + }); });