From 1b0042e9876e15488767ae00bf8461ea6b8b5e2c Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 09:56:34 -0600 Subject: [PATCH 1/9] feat(ui): confirm email removal and handle primary email actions --- .changeset/tidy-emails-confirm.md | 2 + .../user-profile-account-section.stories.tsx | 1 + .../user-profile-profile-panel.stories.tsx | 2 +- .../user-profile-profile-panel.view.test.tsx | 6 + .../user-profile-account-section.messages.ts | 5 + .../user-profile-account-section.view.tsx | 133 ++++++++++++++++-- .../user-profile-profile-panel.styles.ts | 3 + 7 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 .changeset/tidy-emails-confirm.md diff --git a/.changeset/tidy-emails-confirm.md b/.changeset/tidy-emails-confirm.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-emails-confirm.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 86c5288a77c..8569c35a16a 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -77,6 +77,7 @@ function AccountSection({ onManageEmail={() => undefined} onManagePhone={() => undefined} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} /> 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 e0ab322a5a8..30b1b2ecc8d 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -91,7 +91,7 @@ export function Default(_args: Record) { onConnectWeb3Wallet={() => undefined} onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} - onSetPrimaryEmail={() => undefined} + onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 3db4afc416a..79045328620 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -422,6 +422,12 @@ describe('UserProfileProfilePanelView', () => { const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); expect(removeEmail).toHaveAttribute('data-color', 'negative'); await user.click(removeEmail); + expect(onRemoveEmail).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove email address?' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 5a2e623702d..b0409ba5006 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -48,6 +48,7 @@ export const userProfileAccountSectionBase = { }, primary: 'Primary', add: 'Add', + remove: 'Remove', manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', @@ -60,6 +61,10 @@ export const userProfileAccountSectionBase = { add: 'Add email', verify: 'Verify', remove: 'Remove email', + removeTitle: 'Remove email address?', + removeDescription: 'will be removed from your account. You won’t be able to use it to sign in.', + primaryError: 'Unable to set the primary email address. Try again.', + removeError: 'Unable to remove this email address. Try again.', }, phone: { label: 'Phone', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index ae37e4229c5..96c7972bb11 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,8 +1,11 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import { useMemo, useRef, useState } from 'react'; +import { createConfirmHandle, Dialog } from '../../components/dialog'; +import { Text } from '../../components/text'; import { Section } from '../../components/section'; -import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute, UserProfilePhone } from './user-profile-account-section.types'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; @@ -49,8 +52,8 @@ export interface UserProfileAccountSectionViewProps { onAddEmail?: () => void; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void; - onRemoveEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void | Promise; + onRemoveEmail?: (id: string) => void | Promise; onSendPhoneCode?: (phoneNumber: string) => Promise; onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; onManagePhone?: (id: string) => void; @@ -139,9 +142,7 @@ export function UserProfileAccountSectionView({ {allowMultipleAccounts ? ( - - - - - ) : null} {allowMultipleAccounts ? ( @@ -162,3 +161,121 @@ export function UserProfileAccountSectionView({ ); } + +interface ContactSectionProps { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + onVerify?: (id: string) => void; + onSetPrimary?: (id: string) => void | Promise; + onRemove?: (id: string) => void | Promise; +} + +function EmailContactSection(props: ContactSectionProps) { + const { items, onSetPrimary, onRemove } = props; + const messages = m.email; + const sectionRef = useRef(null); + const removeConfirm = useMemo(() => createConfirmHandle(), []); + const [contactToRemove, setContactToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimary = async (id: string) => { + const contact = items.find(item => item.id === id); + if (!onSetPrimary || !contact?.isVerified || contact.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimary(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : messages.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removeContact = async (id: string) => { + const contact = items.find(item => item.id === id); + if (!contact || contact.canRemove === false || !onRemove || removing.current) { + return; + } + removing.current = true; + setContactToRemove(contact); + setRemoveError(undefined); + try { + const confirmed = await removeConfirm.show({ + title: messages.removeTitle, + description: ( + <> + {contact.value}{' '} + {messages.removeDescription} + + ), + actionLabel: m.remove, + destructive: true, + }); + if (confirmed) { + await onRemove(id); + } + } catch (error) { + setRemoveError(error instanceof Error ? error.message : messages.removeError); + } finally { + removing.current = false; + } + }; + + return ( + + + void setPrimary(id) : undefined} + onRemove={onRemove ? id => void removeContact(id) : undefined} + /> + + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + { + const buttons = Array.from(sectionRef.current?.querySelectorAll('button') ?? []); + const label = contactToRemove ? fill(m.manageValue, { value: contactToRemove.value }) : ''; + return ( + buttons.find(button => button.getAttribute('aria-label') === label) ?? + buttons.find(button => button.getAttribute('aria-label') === messages.add) ?? + buttons[0] ?? + false + ); + }} + /> + + ); +} + diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index fd3e6967fb3..d796c9e5d4f 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -6,6 +6,9 @@ export const styles = stylex.create({ confirmPhoneNumber: { fontWeight: fontWeightVars['--cl-font-medium'], }, + confirmationContactValue: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', From 38f254276dd1619d21fe9a3cc801438a61131420 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 13:33:47 -0600 Subject: [PATCH 2/9] refactor(ui): centralize email removal messages --- .../user-profile-account-section.messages.ts | 9 ++++++--- .../user-profile-account-section.view.tsx | 11 +++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index b0409ba5006..6d9ceab55dc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -48,7 +48,6 @@ export const userProfileAccountSectionBase = { }, primary: 'Primary', add: 'Add', - remove: 'Remove', manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', @@ -61,10 +60,14 @@ export const userProfileAccountSectionBase = { add: 'Add email', verify: 'Verify', remove: 'Remove email', - removeTitle: 'Remove email address?', - removeDescription: 'will be removed from your account. You won’t be able to use it to sign in.', primaryError: 'Unable to set the primary email address. Try again.', removeError: 'Unable to remove this email address. Try again.', + removeDialog: { + title: 'Remove email address?', + description: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', + confirm: 'Remove', + cancel: 'Cancel', + }, }, phone: { label: 'Phone', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 96c7972bb11..d68ba64357c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -212,15 +212,18 @@ function EmailContactSection(props: ContactSectionProps) { setContactToRemove(contact); setRemoveError(undefined); try { + const [beforeEmail, afterEmail] = messages.removeDialog.description.split('{emailAddress}'); const confirmed = await removeConfirm.show({ - title: messages.removeTitle, + title: messages.removeDialog.title, description: ( <> - {contact.value}{' '} - {messages.removeDescription} + {beforeEmail} + {contact.value} + {afterEmail} ), - actionLabel: m.remove, + actionLabel: messages.removeDialog.confirm, + cancelLabel: messages.removeDialog.cancel, destructive: true, }); if (confirmed) { From fafeb8068f6d82c6520c8a0eec436b2627e63be4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 13:54:32 -0600 Subject: [PATCH 3/9] feat(ui): add profile email verification flows --- packages/swingset/src/lib/registry.ts | 6 + .../fixtures/user-profile-add-email.ts | 28 +++ .../user-profile-verify-email-link.ts | 47 +++++ .../src/stories/fixtures/user-profile.ts | 10 +- .../stories/user-profile-account-section.mdx | 36 ++++ .../user-profile-account-section.stories.tsx | 60 +++++- .../stories/user-profile-profile-panel.mdx | 2 + .../user-profile-profile-panel.stories.tsx | 11 +- ...ser-profile-add-email.integration.test.tsx | 41 ++++ .../user-profile-add-email.view.test.tsx | 187 +++++++++++++++++ ...er-profile-verify-email-link.view.test.tsx | 65 ++++++ .../user-profile-account-section.view.tsx | 65 +++++- .../user-profile-add-email.controller.test.ts | 153 ++++++++++++++ .../user-profile-add-email.controller.ts | 159 ++++++++++++++ .../user-profile-add-email.messages.ts | 21 ++ .../user-profile-add-email.view.tsx | 196 ++++++++++++++++++ .../user-profile-profile-panel.view.tsx | 4 + ...user-profile-verify-email-link.messages.ts | 9 + .../user-profile-verify-email-link.styles.ts | 21 ++ .../user-profile-verify-email-link.view.tsx | 107 ++++++++++ 20 files changed, 1204 insertions(+), 24 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-profile-add-email.ts create mode 100644 packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 939241dfe69..49af4d50c7d 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -189,7 +189,10 @@ import { } from '../stories/user-profile.stories'; import { AddPhoneFails as UserProfileAccountSectionAddPhoneFails, + AddEmailFails as UserProfileAccountSectionAddEmailFails, Default as UserProfileAccountSectionDefault, + EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails, + EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, } from '../stories/user-profile-account-section.stories'; @@ -469,6 +472,9 @@ const userProfileAccountSectionModule: StoryModule = { Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, AddPhoneFails: UserProfileAccountSectionAddPhoneFails, + AddEmailFails: UserProfileAccountSectionAddEmailFails, + EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, + EmailLinkResendFails: UserProfileAccountSectionEmailLinkResendFails, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts new file mode 100644 index 00000000000..39677383f55 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -0,0 +1,28 @@ +import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddEmailViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-email.view'; + +interface FixtureOptions { + failAt?: UserProfileAddEmailViewProps['step']; + onVerified?: (emailAddress: string) => void; +} + +export function createUserProfileAddEmailFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< + UserProfileAccountSectionViewProps, + 'onSendEmailCode' | 'onVerifyEmailCode' +> { + return { + onSendEmailCode: async () => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'email') { + throw new Error('We couldn’t send a code. Try again.'); + } + }, + onVerifyEmailCode: async (emailAddress, code) => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(emailAddress); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts b/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts new file mode 100644 index 00000000000..974a36b16a4 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +export function useUserProfileVerifyEmailLinkFixture({ failResend = false } = {}) { + const [open, setOpen] = useState(false); + const [resendSeconds, setResendSeconds] = useState(12); + const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + if (!open) { + return; + } + if (isResending) { + const timer = setTimeout(() => { + setIsResending(false); + if (failResend) { + setErrorMessage('Unable to send the verification link. Try again.'); + } else { + setResendSeconds(12); + } + }, 700); + return () => clearTimeout(timer); + } + if (resendSeconds > 0) { + const timer = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); + return () => clearTimeout(timer); + } + }, [open, isResending, resendSeconds, failResend]); + + return { + open, + emailAddress: 'example@email.com', + resendSeconds, + isResending, + errorMessage, + onOpenChange: (value: boolean) => { + setOpen(value); + setResendSeconds(12); + setIsResending(false); + setErrorMessage(undefined); + }, + onResend: () => { + setErrorMessage(undefined); + setIsResending(true); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index f4411466b6d..992e1301db7 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -14,11 +14,12 @@ import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; +import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; export interface UserProfileFixtureOptions { - /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ + /** Replaces the default OTP flow, e.g. for a custom dialog example. */ onAddEmail?: () => void; } @@ -111,6 +112,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const addEmail = (value: string) => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); const pages: UserProfileViewProps['pages'] = { account: { @@ -121,7 +125,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions imageUrl, emails, phones, - onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), + onAddEmail, + onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, + onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, ...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), }), diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 532e38b6ce6..abd42adb222 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -21,6 +21,10 @@ Entering or pasting six digits submits automatically. Use `000000` to see an inc ## Multiple accounts +**Add email** opens email entry followed by a six-digit verification code. Resend is available after +the countdown. In this preview, `000000` shows an incorrect-code error; another six-digit code adds +the verified address. + @@ -38,3 +46,31 @@ Entering or pasting six digits submits automatically. Use `000000` to see an inc Add a phone number to see a failed send request while keeping the entered number. + +## Email verification error + +This example rejects every verification attempt so the error remains visible and the user can retry. + + + +## Email-link verification + +The profile uses OTP. This separate view displays an email-link verification in progress, with a +resend countdown and Cancel. Its caller supplies the address, pending state, errors, and callbacks. + + + +### Resend error + +After the countdown, resend to see the supplied error message. + + diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 8569c35a16a..9b95e9e96e3 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,3 +1,4 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; import type { UserProfileFormError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEmail, @@ -5,6 +6,7 @@ import type { } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; +import { UserProfileVerifyEmailLinkView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-link.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -13,6 +15,8 @@ import { usePreviewImage } from './fixtures/use-preview-image'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; +import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -30,11 +34,13 @@ function AccountSection({ failAt, failWith, usernameFailWith, + failEmailVerification = false, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; failWith?: UserProfileFormError; usernameFailWith?: UserProfileFormError; + failEmailVerification?: boolean; }) { const editName = useUserProfileEditNameFixture({ failWith }); const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); @@ -55,22 +61,21 @@ function AccountSection({ failAt, onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), }); + const emailFlow = createUserProfileAddEmailFixture({ + failAt: failEmailVerification ? 'verify' : undefined, + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } {...addPhone} onProfilePictureChange={showFile} onRemoveProfilePicture={clearImage} @@ -92,6 +97,49 @@ export function MultipleAccounts() { return ; } +export function AddEmailFails() { + return ( + + ); +} + +export function EmailLinkVerification() { + const fixture = useUserProfileVerifyEmailLinkFixture(); + return ( + + Verify email link + + } + /> + ); +} + +export function EmailLinkResendFails() { + const fixture = useUserProfileVerifyEmailLinkFixture({ failResend: true }); + return ( + + Verify email link + + } + /> + ); +} + /** Every save is rejected, so the dialog shows both halves of a failure at once. */ export function EditNameFails() { return ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 79512e48080..0d8d020f87a 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -55,6 +55,8 @@ import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user- }; })} onVerifyEmail={verifyEmail} + onSendEmailCode={sendEmailCode} + onVerifyEmailCode={verifyEmailCode} onSetPrimaryEmail={setPrimaryEmail} onRemoveEmail={removeEmail} onVerifyPhone={verifyPhone} 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 30b1b2ecc8d..ae09745e018 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -6,6 +6,7 @@ import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; @@ -34,11 +35,15 @@ export function Default(_args: Record) { const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( ) { hasImage={Boolean(imageUrl)} imageUrl={imageUrl} phones={phones} - onAddEmail={() => - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } {...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), })} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx new file mode 100644 index 00000000000..78f4f176d77 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +describe('profile add email', () => { + it.each([false, true])( + 'owns the dialog and returns focus with multiple accounts = %s', + async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add email' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx new file mode 100644 index 00000000000..c29a6e7f605 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx @@ -0,0 +1,187 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAddEmailViewProps } from '../user-profile-add-email.view'; +import { UserProfileAddEmailView } from '../user-profile-add-email.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddEmailViewProps = { + open: true, + onOpenChange: vi.fn(), + step: 'email', + emailAddress: 'person@example.com', + onEmailAddressChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + + return ( + + undefined} + step='verify' + emailAddress='person@example.com' + onEmailAddressChange={() => undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + onResend={() => undefined} + /> + + ); +} + +describe('UserProfileAddEmailView', () => { + it.each(['', 'invalid-address'])('uses native email validation for %j', async emailAddress => { + const user = userEvent.setup(); + const { props } = renderView({ emailAddress }); + await user.click(screen.getByRole('button', { name: 'Send code' })); + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox', { name: 'Email' })).toBeInvalid(); + }); + + it.each(['typing', 'pasting'] as const)('automatically submits a complete code after %s', async method => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + + if (method === 'typing') { + await user.keyboard('12345'); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard('6'); + } else { + await user.paste('123456'); + } + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('focuses the email field and submits through the form or Send code', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); + const email = screen.getByRole('textbox', { name: 'Email' }); + await waitFor(() => expect(email).toHaveFocus()); + const emailForm = email.closest('form'); + if (!emailForm) { + throw new Error('Email form missing'); + } + expect(emailForm).toHaveClass('cl-card-content'); + emailForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Send code' })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + }); + + it('moves to verification inside the same dialog and submits the code', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView(); + const dialog = screen.getByRole('dialog'); + + rerender( + + + , + ); + + expect(screen.getByRole('dialog', { name: 'Verify your email' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to person@example.com')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Email' })).not.toBeInTheDocument(); + const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(firstSlot).toHaveFocus()); + const verifyForm = firstSlot.closest('form'); + if (!verifyForm) { + throw new Error('Verification form missing'); + } + expect(verifyForm).toHaveClass('cl-card-content'); + verifyForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('blocks submission and resend while verification is pending', async () => { + const user = userEvent.setup(); + const { props } = renderView({ step: 'verify', code: '123456', isPending: true }); + + for (const slot of screen.getAllByRole('textbox')) { + expect(slot).toBeDisabled(); + } + const verify = screen.getByRole('button', { name: 'Verify', exact: true }); + expect(verify).toHaveAttribute('aria-busy', 'true'); + await user.click(verify); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.onResend).not.toHaveBeenCalled(); + }); + + it.each(['email', 'verify'] as const)('associates a %s error with its input', step => { + renderView({ step, errorMessage: 'Please try again.' }); + + const field = screen.getByRole('textbox', { name: step === 'email' ? 'Email' : 'Verification code' }); + expect(field).toHaveAttribute('aria-invalid', 'true'); + const describedControl = step === 'verify' ? screen.getByRole('group', { name: 'Verification code' }) : field; + expect(describedControl).toHaveAccessibleDescription('Please try again.'); + }); + + it('allows resending only after the countdown and the current request finish', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'verify', resendSeconds: 12 }); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })); + expect(props.onResend).not.toHaveBeenCalled(); + + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx new file mode 100644 index 00000000000..12eca6df898 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileVerifyEmailLinkViewProps } from '../user-profile-verify-email-link.view'; +import { UserProfileVerifyEmailLinkView } from '../user-profile-verify-email-link.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailLinkViewProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailLinkView', () => { + it('shows the address awaiting verification and lets the user resend the link', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( + 'A verification link was sent to example@email.com', + ); + expect(screen.getByRole('status')).toHaveTextContent('Check your email'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + }); + + it.each([ + { resendSeconds: 12, isResending: false, label: 'Didn’t receive a link? Resend (12)' }, + { resendSeconds: 0, isResending: true, label: 'Sending a new link…' }, + ])('prevents resending while $label', async ({ resendSeconds, isResending, label }) => { + const user = userEvent.setup(); + const { props } = renderView({ resendSeconds, isResending }); + const resend = screen.getByRole('button', { name: label }); + + expect(resend).toBeDisabled(); + await user.click(resend); + expect(props.onResend).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces a supplied resend error and allows retry', async () => { + const user = userEvent.setup(); + const { props } = renderView({ errorMessage: 'Unable to send the verification link. Try again.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to send the verification link. Try again.'); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index d68ba64357c..4c6500c639e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,10 +1,16 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; import { useMemo, useRef, useState } from 'react'; import { createConfirmHandle, Dialog } from '../../components/dialog'; import { Text } from '../../components/text'; import { Section } from '../../components/section'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import type { UserProfileAddEmailControllerOptions } from '../user-profile-add-email.controller'; +import { useUserProfileAddEmailController } from '../user-profile-add-email.controller'; +import { UserProfileAddEmailView } from '../user-profile-add-email.view'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute, UserProfilePhone } from './user-profile-account-section.types'; @@ -50,6 +56,8 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; + onSendEmailCode?: (emailAddress: string) => Promise; + onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; @@ -80,6 +88,8 @@ export function UserProfileAccountSectionView({ onSubmitName, onSubmitUsername, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -103,6 +113,13 @@ export function UserProfileAccountSectionView({ onRemovePhone={onRemovePhone} /> ); + const addEmailAction = + onSendEmailCode && onVerifyEmailCode ? ( + + ) : undefined; return (
@@ -134,6 +151,7 @@ export function UserProfileAccountSectionView({ items={emails} kind='email' label={m.email.label} + addAction={addEmailAction} onAdd={onAddEmail} onManage={onManageEmail} /> @@ -143,15 +161,16 @@ export function UserProfileAccountSectionView({ {allowMultipleAccounts ? ( + items={emails} + kind='email' + label={m.email.label} + addAction={addEmailAction} + onAdd={onAddEmail} + onManage={onManageEmail} + onRemove={onRemoveEmail} + onSetPrimary={onSetPrimaryEmail} + onVerify={onVerifyEmail} + /> ) : null} {allowMultipleAccounts ? ( @@ -162,7 +181,34 @@ export function UserProfileAccountSectionView({ ); } +function AddEmail({ options, compact }: { options: UserProfileAddEmailControllerOptions; compact: boolean }) { + const controller = useUserProfileAddEmailController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.email.add} + + } + /> + ); +} + interface ContactSectionProps { + addAction?: ReactNode; kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -281,4 +327,3 @@ function EmailContactSection(props: ContactSectionProps) { ); } - diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts new file mode 100644 index 00000000000..7e27db78e7a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; + +describe('useUserProfileAddEmailController', () => { + afterEach(() => vi.useRealTimers()); + + it('keeps the resend countdown running while verification is pending', async () => { + vi.useFakeTimers(); + const verification = Promise.withResolvers(); + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onSend: () => Promise.resolve(), + onVerify: () => verification.promise, + }), + ); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + act(() => result.current.onSubmit('123456')); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + await act(async () => { + verification.reject(new Error('Incorrect code')); + await Promise.resolve(); + }); + expect(result.current.errorMessage).toBe('Incorrect code'); + expect(result.current.resendSeconds).toBe(0); + }); + + it('starts with the supplied email address', () => { + const { result } = renderHook(() => + useUserProfileAddEmailController({ + initialEmailAddress: 'saved@example.com', + onSend: () => Promise.resolve(), + onVerify: () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + expect(result.current.emailAddress).toBe('saved@example.com'); + }); + + it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { + const request = Promise.withResolvers(); + const onSend = vi.fn(() => request.promise); + const { result } = renderHook(() => + useUserProfileAddEmailController({ onSend, onVerify: () => Promise.resolve() }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => { + result.current.onSubmit(); + result.current.onSubmit(); + result.current.onEmailAddressChange('other@example.com'); + result.current.onOpenChange(false); + }); + expect(result.current.open).toBe(true); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + await act(async () => { + request.resolve(); + await request.promise; + }); + act(() => result.current.onCodeChange('123')); + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + expect(result.current.step).toBe('email'); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(0); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { + vi.useFakeTimers(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + expect(result.current.resendSeconds).toBe(12); + act(() => result.current.onResend()); + expect(onSend).toHaveBeenCalledTimes(1); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + act(() => result.current.onCodeChange('123')); + await act(async () => { + result.current.onResend(); + result.current.onResend(); + result.current.onSubmit('123456'); + result.current.onOpenChange(false); + await Promise.resolve(); + }); + expect(onSend).toHaveBeenCalledTimes(2); + expect(onVerify).not.toHaveBeenCalled(); + expect(result.current.open).toBe(true); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(12); + }); + it.each(['email', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { + const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onSend: step === 'email' ? operation : () => Promise.resolve(), + onVerify: step === 'verify' ? operation : () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + if (step === 'verify') { + await waitFor(() => expect(result.current.step).toBe('verify')); + act(() => result.current.onSubmit('000000')); + } + await waitFor(() => expect(result.current.errorMessage).toBe('Try again')); + expect(result.current.isPending).toBe(false); + expect(result.current.step).toBe(step); + expect(result.current.emailAddress).toBe('new@example.com'); + if (step === 'verify') { + expect(result.current.code).toBe('000000'); + } + act(() => result.current.onSubmit()); + await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); + }); + it('sends a code, verifies the submitted code, and closes on success', async () => { + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + expect(result.current.isPending).toBe(true); + expect(result.current.open).toBe(true); + await waitFor(() => expect(result.current.step).toBe('verify')); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts new file mode 100644 index 00000000000..114327aea31 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts @@ -0,0 +1,159 @@ +import { useEffect } from 'react'; + +import { setup } from '../machine/setup'; +import { useMachine } from '../machine/useMachine'; +import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; +import type { UserProfileAddEmailViewProps } from './user-profile-add-email.view'; + +export interface UserProfileAddEmailControllerOptions { + initialEmailAddress?: string; + onSend: (emailAddress: string) => Promise; + onVerify: (emailAddress: string, code: string) => Promise; +} + +interface Context extends UserProfileAddEmailControllerOptions { + emailAddress: string; + code: string; + errorMessage: string | undefined; + resendSeconds: number; +} + +type Event = + | { type: 'OPEN' } + | { type: 'CANCEL' } + | { type: 'RESEND' } + | { type: 'TICK' } + | { type: 'TYPE_EMAIL'; value: string } + | { type: 'TYPE_CODE'; value: string } + | { type: 'SUBMIT'; code?: string }; + +const { createMachine, assign, fromPromise } = setup(); + +function missingDependency(): Promise { + return Promise.reject(new Error('Add email callbacks are missing')); +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : m.error; +} + +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; + +const machine = createMachine({ + id: 'addEmail', + initial: 'idle', + context: { + onSend: missingDependency, + onVerify: missingDependency, + emailAddress: '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + }, + states: { + idle: { + on: { + OPEN: { + target: 'email', + actions: assign(context => ({ + emailAddress: context.initialEmailAddress ?? '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + })), + }, + }, + }, + email: { + on: { + CANCEL: 'idle', + TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, errorMessage: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + }, + }, + sending: { + invoke: fromPromise(context => context.onSend(context.emailAddress), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'email', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verify: { + on: { + CANCEL: 'idle', + TICK: tick, + RESEND: { + target: 'resending', + guard: context => context.resendSeconds === 0, + actions: assign(() => ({ errorMessage: undefined })), + }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + SUBMIT: { + target: 'verifying', + actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + }, + }, + }, + resending: { + invoke: fromPromise(context => context.onSend(context.emailAddress), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verifying: { + on: { TICK: tick }, + invoke: fromPromise(context => context.onVerify(context.emailAddress, context.code), { + onDone: 'idle', + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + }, +}); + +export function useUserProfileAddEmailController( + options: UserProfileAddEmailControllerOptions, +): UserProfileAddEmailViewProps { + const [snapshot, send] = useMachine(machine, { context: options }); + const { resendSeconds } = snapshot.context; + const open = snapshot.value !== 'idle'; + useEffect(() => { + if (!open || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => send({ type: 'TICK' }), 1000); + return () => clearTimeout(timer); + }, [open, resendSeconds, send]); + + return { + resendSeconds, + isResending: snapshot.value === 'resending', + open, + step: + snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' + ? 'verify' + : 'email', + emailAddress: snapshot.context.emailAddress, + code: snapshot.context.code, + errorMessage: snapshot.context.errorMessage, + isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + onEmailAddressChange: value => send({ type: 'TYPE_EMAIL', value }), + onCodeChange: value => send({ type: 'TYPE_CODE', value }), + onSubmit: code => send({ type: 'SUBMIT', code }), + onResend: () => send({ type: 'RESEND' }), + }; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts new file mode 100644 index 00000000000..cfcad0728a0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts @@ -0,0 +1,21 @@ +export const userProfileAddEmailMessages = { + error: 'Something went wrong. Please try again.', + email: { + title: 'Add email', + description: 'We’ll send you a code to verify this email address.', + label: 'Email', + submit: 'Send code', + pending: 'Sending code', + }, + verify: { + title: 'Verify your email', + description: 'Enter the code sent to {emailAddress}', + label: 'Verification code', + submit: 'Verify', + pending: 'Verifying', + cancel: 'Cancel', + resend: 'Didn’t receive a code? Resend', + resending: 'Sending a new code…', + resendCountdown: 'Didn’t receive a code? Resend ({seconds})', + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx new file mode 100644 index 00000000000..1bc3b498633 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx @@ -0,0 +1,196 @@ +import type { FormEvent } from 'react'; +import { useEffect, useId, useRef } from 'react'; + +import { Button, SubmitButton } from '../components/button'; +import { Card } from '../components/card'; +import type { DialogTriggerProps } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Field } from '../components/field'; +import { Flow } from '../components/flow'; +import { Input } from '../components/input'; +import { Otp } from '../components/otp'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; + +export interface UserProfileAddEmailViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + step: 'email' | 'verify'; + emailAddress: string; + onEmailAddressChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddEmailView(props: UserProfileAddEmailViewProps) { + const emailFormId = useId(); + const verifyFormId = useId(); + const emailRef = useRef(null); + const verifyRef = useRef(null); + + useEffect(() => { + if (props.open && props.step === 'verify') { + verifyRef.current?.querySelector('input:not([type="hidden"])')?.focus(); + } + }, [props.open, props.step]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!props.isPending && !props.isResending) { + props.onSubmit(); + } + }; + + return ( + + {props.trigger ? : null} + + emailRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true + } + > + + + {current => ( + <> + + + {m.email.title} + {m.email.description} + + + } + > + + {m.email.label} + current.onEmailAddressChange(event.target.value)} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + {m.email.submit} + + + + + + {m.verify.title} + + {fill(m.verify.description, { emailAddress: current.emailAddress })} + + + + } + > + + {m.verify.label} + { + if (!current.isPending && !current.isResending) { + current.onSubmit(code); + } + }} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index 44f921e8b2f..01d9ee3b82e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -57,6 +57,8 @@ export function UserProfileProfilePanelView({ onSubmitName, onSubmitUsername, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -95,6 +97,8 @@ export function UserProfileProfilePanelView({ onAddEmail={onAddEmail} onSendPhoneCode={onSendPhoneCode} onVerifyPhoneCode={onVerifyPhoneCode} + onSendEmailCode={onSendEmailCode} + onVerifyEmailCode={onVerifyEmailCode} onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts new file mode 100644 index 00000000000..208233909e2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts @@ -0,0 +1,9 @@ +export const userProfileVerifyEmailLinkMessages = { + title: 'Verify email address', + waiting: 'Check your email', + description: 'A verification link was sent to {emailAddress}', + resend: 'Didn’t receive a link? Resend', + resendCountdown: 'Didn’t receive a link? Resend ({seconds})', + resending: 'Sending a new link…', + cancel: 'Cancel', +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts new file mode 100644 index 00000000000..43be8517d58 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts @@ -0,0 +1,21 @@ +import * as stylex from '@stylexjs/stylex'; + +import { fontWeightVars, space } from '../tokens.stylex'; + +export const styles = stylex.create({ + status: { + gap: space['2'], + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + }, + details: { + gap: space['1'], + display: 'grid', + justifyItems: 'start', + overflowWrap: 'anywhere', + }, + emphasis: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx new file mode 100644 index 00000000000..cc405f0f83d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx @@ -0,0 +1,107 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Banner } from '../components/banner'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import type { DialogTriggerProps } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Spinner } from '../components/spinner'; +import { Text } from '../components/text'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; +import { styles } from './user-profile-verify-email-link.styles'; + +export interface UserProfileVerifyEmailLinkViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + emailAddress: string; + onResend: () => void; + isResending?: boolean; + resendSeconds?: number; + errorMessage?: string; +} + +export function UserProfileVerifyEmailLinkView({ + open, + onOpenChange, + trigger, + emailAddress, + onResend, + isResending = false, + resendSeconds = 0, + errorMessage, +}: UserProfileVerifyEmailLinkViewProps) { + const [beforeEmail, afterEmail] = m.description.split('{emailAddress}'); + + return ( + + {trigger ? : null} + + + + {m.title} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ + {m.waiting} +
+
+ + {beforeEmail} + {emailAddress} + {afterEmail} + + +
+
+ + + } + > + {m.cancel} + + +
+
+
+ ); +} From 370c4d321bde037c7d9362b9080afdd6acb1f863 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 14:02:13 -0600 Subject: [PATCH 4/9] fix(ui): align email link verification spacing --- .../user-profile/user-profile-verify-email-link.styles.ts | 6 ++++-- .../user-profile/user-profile-verify-email-link.view.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts index 43be8517d58..10a4b5c44dc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts @@ -3,14 +3,16 @@ import * as stylex from '@stylexjs/stylex'; import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + content: { + gap: space['2.5'], + }, status: { - gap: space['2'], + gap: space['2.5'], alignItems: 'flex-start', display: 'flex', flexDirection: 'column', }, details: { - gap: space['1'], display: 'grid', justifyItems: 'start', overflowWrap: 'anywhere', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx index cc405f0f83d..e179e89d970 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx @@ -49,7 +49,7 @@ export function UserProfileVerifyEmailLinkView({ {m.title} - + {errorMessage ? ( Date: Fri, 11 Sep 2026 14:08:08 -0600 Subject: [PATCH 5/9] fix(ui): use primary color for email link address --- .../user-profile/user-profile-verify-email-link.styles.ts | 5 ++++- .../user-profile/user-profile-verify-email-link.view.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts index 10a4b5c44dc..3f91864cc70 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { fontWeightVars, space } from '../tokens.stylex'; +import { colorVars, fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ content: { @@ -20,4 +20,7 @@ export const styles = stylex.create({ emphasis: { fontWeight: fontWeightVars['--cl-font-medium'], }, + emailAddress: { + color: colorVars['--cl-color-primary'], + }, }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx index e179e89d970..b00c17de3d9 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx @@ -68,7 +68,7 @@ export function UserProfileVerifyEmailLinkView({
{beforeEmail} - {emailAddress} + {emailAddress} {afterEmail} + } + /> + ); +} + +export function EmailSsoConnectFails() { + const fixture = useUserProfileVerifyEmailSsoFixture({ failConnect: true }); + return ( + + Verify with SSO + + } + /> + ); +} + /** Every save is rejected, so the dialog shows both halves of a failure at once. */ export function EditNameFails() { return ( diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx new file mode 100644 index 00000000000..ba8c4f69b24 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileVerifyEmailSsoViewProps } from '../user-profile-verify-email-sso.view'; +import { UserProfileVerifyEmailSsoView } from '../user-profile-verify-email-sso.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailSsoViewProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + connection: { provider: 'Okta SSO', domain: 'acme.co' }, + onConnect: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailSsoView', () => { + it('shows the matching connection and lets the user connect to verify their email', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( + 'Connect below to verify example@email.com', + ); + expect(screen.getByText('Okta SSO')).toBeInTheDocument(); + expect(screen.getByText('acme.co · Enterprise SSO')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(props.onConnect).toHaveBeenCalledOnce(); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); + + it('prevents another connection attempt while connecting and still allows cancellation', async () => { + const user = userEvent.setup(); + const { props } = renderView({ isConnecting: true }); + const connect = screen.getByRole('button', { name: 'Connect' }); + + expect(connect).toBeDisabled(); + expect(connect).toHaveAttribute('aria-busy', 'true'); + await user.click(connect); + expect(props.onConnect).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces a supplied connection error and allows retry', async () => { + const user = userEvent.setup(); + const { props } = renderView({ errorMessage: 'Unable to connect to Okta. Try again.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect to Okta. Try again.'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(props.onConnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts new file mode 100644 index 00000000000..6cc7771087b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts @@ -0,0 +1,7 @@ +export const userProfileVerifyEmailSsoMessages = { + title: 'Verify email address', + description: 'Connect below to verify {emailAddress}', + connectionDescription: '{domain} · Enterprise SSO', + connect: 'Connect', + cancel: 'Cancel', +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx new file mode 100644 index 00000000000..6d43d6f958b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx @@ -0,0 +1,108 @@ +import { Banner } from '../components/banner'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import type { DialogTriggerProps } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Icon } from '../components/icon'; +import { Item } from '../components/item'; +import { Spinner } from '../components/spinner'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { userProfileVerifyEmailSsoMessages as m } from './user-profile-verify-email-sso.messages'; + +export interface UserProfileVerifyEmailSsoViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + emailAddress: string; + connection: { + provider: string; + domain: string; + iconUrl?: string; + }; + onConnect: () => void; + isConnecting?: boolean; + errorMessage?: string; +} + +export function UserProfileVerifyEmailSsoView({ + open, + onOpenChange, + trigger, + emailAddress, + connection, + onConnect, + isConnecting = false, + errorMessage, +}: UserProfileVerifyEmailSsoViewProps) { + return ( + + {trigger ? : null} + + + + {m.title} + {fill(m.description, { emailAddress })} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {connection.iconUrl ? : null} + + {connection.provider} + {fill(m.connectionDescription, { domain: connection.domain })} + + + + + + + + + } + > + {m.cancel} + + + + + + ); +} From a696e6ebc8e630a29addbe4df498d2f777cace89 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 08:07:17 -0600 Subject: [PATCH 7/9] refactor(ui): align email rows and dialogs with phone --- packages/swingset/src/lib/registry.ts | 2 +- .../fixtures/user-profile-add-email.ts | 4 +- .../src/stories/fixtures/user-profile.ts | 2 +- .../user-profile-account-section.stories.tsx | 14 +- .../user-profile-profile-panel.stories.tsx | 2 +- ...=> user-profile-add-email.dialog.test.tsx} | 23 +- ...ser-profile-add-email.integration.test.tsx | 3 +- .../user-profile-email-actions.test.tsx | 83 +++++++ ...profile-verify-email-link.dialog.test.tsx} | 12 +- ...-profile-verify-email-sso.dialog.test.tsx} | 12 +- .../user-profile-account-section.types.ts | 8 + .../user-profile-account-section.view.tsx | 222 ++---------------- .../user-profile-add-email.controller.test.ts | 0 .../user-profile-add-email.controller.ts | 8 +- .../user-profile-add-email.dialog.tsx} | 30 +-- .../user-profile-add-email.messages.ts | 0 .../user-profile-email-row.view.tsx | 212 +++++++++++++++++ .../user-profile-remove-email.dialog.tsx | 53 +++++ ...user-profile-verify-email-link.dialog.tsx} | 22 +- ...user-profile-verify-email-link.messages.ts | 0 .../user-profile-verify-email-link.styles.ts | 2 +- .../user-profile-verify-email-sso.dialog.tsx} | 26 +- .../user-profile-verify-email-sso.messages.ts | 0 23 files changed, 458 insertions(+), 282 deletions(-) rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-add-email.view.test.tsx => user-profile-add-email.dialog.test.tsx} (90%) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-verify-email-link.view.test.tsx => user-profile-verify-email-link.dialog.test.tsx} (84%) rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-verify-email-sso.view.test.tsx => user-profile-verify-email-sso.dialog.test.tsx} (83%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-add-email.controller.test.ts (100%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-add-email.controller.ts (95%) rename packages/ui/src/mosaic/user-profile/{user-profile-add-email.view.tsx => user-profile-account-section/user-profile-add-email.dialog.tsx} (88%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-add-email.messages.ts (100%) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx rename packages/ui/src/mosaic/user-profile/{user-profile-verify-email-link.view.tsx => user-profile-account-section/user-profile-verify-email-link.dialog.tsx} (82%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-verify-email-link.messages.ts (100%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-verify-email-link.styles.ts (87%) rename packages/ui/src/mosaic/user-profile/{user-profile-verify-email-sso.view.tsx => user-profile-account-section/user-profile-verify-email-sso.dialog.tsx} (79%) rename packages/ui/src/mosaic/user-profile/{ => user-profile-account-section}/user-profile-verify-email-sso.messages.ts (100%) diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index d604458e62b..f597fc70745 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,8 +188,8 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { - AddPhoneFails as UserProfileAccountSectionAddPhoneFails, AddEmailFails as UserProfileAccountSectionAddEmailFails, + AddPhoneFails as UserProfileAccountSectionAddPhoneFails, Default as UserProfileAccountSectionDefault, EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails, EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts index 39677383f55..647525923d9 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -1,8 +1,8 @@ import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; -import type { UserProfileAddEmailViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-email.view'; +import type { UserProfileAddEmailDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog'; interface FixtureOptions { - failAt?: UserProfileAddEmailViewProps['step']; + failAt?: UserProfileAddEmailDialogProps['step']; onVerified?: (emailAddress: string) => void; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 992e1301db7..5918268e5b9 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,8 +13,8 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; -import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { createUserProfileAddEmailFixture } from './user-profile-add-email'; +import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 5f67564df1a..13350807185 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -6,17 +6,17 @@ import type { } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; -import { UserProfileVerifyEmailLinkView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-link.view'; -import { UserProfileVerifyEmailSsoView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-sso.view'; +import { UserProfileVerifyEmailLinkDialog } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; +import { UserProfileVerifyEmailSsoDialog } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; -import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; import { useUserProfileVerifyEmailSsoFixture } from './fixtures/user-profile-verify-email-sso'; @@ -111,7 +111,7 @@ export function AddEmailFails() { export function EmailLinkVerification() { const fixture = useUserProfileVerifyEmailLinkFixture(); return ( - = {}) { - const props: UserProfileAddEmailViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileAddEmailDialogProps = { open: true, onOpenChange: vi.fn(), step: 'email', @@ -24,18 +24,18 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; } -function VerificationExample({ onSubmit }: Pick) { +function VerificationExample({ onSubmit }: Pick) { const [code, setCode] = useState(''); return ( - undefined} step='verify' @@ -50,7 +50,7 @@ function VerificationExample({ onSubmit }: Pick { +describe('UserProfileAddEmailDialog', () => { it.each(['', 'invalid-address'])('uses native email validation for %j', async emailAddress => { const user = userEvent.setup(); const { props } = renderView({ emailAddress }); @@ -103,7 +103,7 @@ describe('UserProfileAddEmailView', () => { rerender( - { expect(screen.getByText('Enter the code sent to person@example.com')).toBeInTheDocument(); expect(screen.queryByRole('textbox', { name: 'Email' })).not.toBeInTheDocument(); const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(firstSlot).toHaveFocus()); const verifyForm = firstSlot.closest('form'); if (!verifyForm) { throw new Error('Verification form missing'); @@ -164,7 +163,7 @@ describe('UserProfileAddEmailView', () => { rerender( - @@ -175,7 +174,7 @@ describe('UserProfileAddEmailView', () => { rerender( - { expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); await user.click(screen.getByRole('button', { name: 'Send code' })); - await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); - await user.keyboard('123456'); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx new file mode 100644 index 00000000000..22ecff91d45 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view'; + +function renderEmail(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('email actions', () => { + it('returns focus to the email menu after opening with the keyboard and canceling with Escape', async () => { + const user = userEvent.setup(); + const onRemoveEmail = vi.fn(); + renderEmail({ onRemoveEmail }); + const trigger = screen.getByRole('button', { name: 'Manage test@example.com' }); + + trigger.focus(); + await user.keyboard('{Enter}'); + await user.keyboard('{Enter}'); + expect(screen.getByRole('alertdialog', { name: 'Remove email address?' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemoveEmail).not.toHaveBeenCalled(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('returns focus to Add email when the removed email disappears', async () => { + const user = userEvent.setup(); + function Example() { + const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]); + return ( + + Promise.resolve()} + onVerifyEmailCode={() => Promise.resolve()} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Manage test@example.com' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add email' })).toHaveFocus()); + }); + + it('shows a primary update error without opening a dialog', async () => { + const user = userEvent.setup(); + const onSetPrimaryEmail = vi.fn().mockRejectedValue(new Error('Unable to update primary email.')); + renderEmail({ onSetPrimaryEmail }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledExactlyOnceWith('email_1'); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary email.'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx similarity index 84% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx index 12eca6df898..05a353e750d 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx @@ -3,11 +3,11 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileVerifyEmailLinkViewProps } from '../user-profile-verify-email-link.view'; -import { UserProfileVerifyEmailLinkView } from '../user-profile-verify-email-link.view'; +import type { UserProfileVerifyEmailLinkDialogProps } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; +import { UserProfileVerifyEmailLinkDialog } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileVerifyEmailLinkViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailLinkDialogProps = { open: true, onOpenChange: vi.fn(), emailAddress: 'example@email.com', @@ -18,13 +18,13 @@ function renderView(overrides: Partial = {} props, ...render( - + , ), }; } -describe('UserProfileVerifyEmailLinkView', () => { +describe('UserProfileVerifyEmailLinkDialog', () => { it('shows the address awaiting verification and lets the user resend the link', async () => { const user = userEvent.setup(); const { props } = renderView(); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx similarity index 83% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx index ba8c4f69b24..dfc5be099e6 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx @@ -3,11 +3,11 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileVerifyEmailSsoViewProps } from '../user-profile-verify-email-sso.view'; -import { UserProfileVerifyEmailSsoView } from '../user-profile-verify-email-sso.view'; +import type { UserProfileVerifyEmailSsoDialogProps } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; +import { UserProfileVerifyEmailSsoDialog } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileVerifyEmailSsoViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailSsoDialogProps = { open: true, onOpenChange: vi.fn(), emailAddress: 'example@email.com', @@ -19,13 +19,13 @@ function renderView(overrides: Partial = {}) props, ...render( - + , ), }; } -describe('UserProfileVerifyEmailSsoView', () => { +describe('UserProfileVerifyEmailSsoDialog', () => { it('shows the matching connection and lets the user connect to verify their email', async () => { const user = userEvent.setup(); const { props } = renderView(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts index 421f0c68606..16f56db158d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -32,3 +32,11 @@ export interface UserProfilePhone { isVerified?: boolean; canRemove?: boolean; } + +export interface UserProfileEmail { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 4c6500c639e..c19e81b21d5 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,36 +1,22 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; -import { useMemo, useRef, useState } from 'react'; -import { createConfirmHandle, Dialog } from '../../components/dialog'; -import { Text } from '../../components/text'; import { Section } from '../../components/section'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; -import type { UserProfileAddEmailControllerOptions } from '../user-profile-add-email.controller'; -import { useUserProfileAddEmailController } from '../user-profile-add-email.controller'; -import { UserProfileAddEmailView } from '../user-profile-add-email.view'; -import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; -import type { UserProfileNameAttribute, UserProfilePhone } from './user-profile-account-section.types'; -import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; -import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import type { + UserProfileEmail, + UserProfileNameAttribute, + UserProfilePhone, +} from './user-profile-account-section.types'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { UserProfileEmailRowView } from './user-profile-email-row.view'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; import { UserProfileUsernameRowView } from './user-profile-username-row.view'; -export type { UserProfilePhone } from './user-profile-account-section.types'; - -export interface UserProfileEmail { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} +export type { UserProfileEmail, UserProfilePhone } from './user-profile-account-section.types'; export interface UserProfileAccountSectionViewProps { allowMultipleAccounts?: boolean; @@ -113,13 +99,19 @@ export function UserProfileAccountSectionView({ onRemovePhone={onRemovePhone} /> ); - const addEmailAction = - onSendEmailCode && onVerifyEmailCode ? ( - - ) : undefined; + const emailRow = ( + + ); return (
@@ -146,31 +138,14 @@ export function UserProfileAccountSectionView({ username={username} onSubmit={onSubmitUsername} /> - {!allowMultipleAccounts ? ( - - ) : null} + {!allowMultipleAccounts ? emailRow : null} {!allowMultipleAccounts ? phoneRow : null} {allowMultipleAccounts ? ( - + + {emailRow} + ) : null} {allowMultipleAccounts ? ( @@ -180,150 +155,3 @@ export function UserProfileAccountSectionView({
); } - -function AddEmail({ options, compact }: { options: UserProfileAddEmailControllerOptions; compact: boolean }) { - const controller = useUserProfileAddEmailController(options); - return ( - - {compact ? ( - - ) : null} - {compact ? m.add : m.email.add} - - } - /> - ); -} - -interface ContactSectionProps { - addAction?: ReactNode; - kind: 'email' | 'phone'; - label: string; - items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; - onAdd?: () => void; - onManage?: (id: string) => void; - onVerify?: (id: string) => void; - onSetPrimary?: (id: string) => void | Promise; - onRemove?: (id: string) => void | Promise; -} - -function EmailContactSection(props: ContactSectionProps) { - const { items, onSetPrimary, onRemove } = props; - const messages = m.email; - const sectionRef = useRef(null); - const removeConfirm = useMemo(() => createConfirmHandle(), []); - const [contactToRemove, setContactToRemove] = useState(); - const [removeError, setRemoveError] = useState(); - const removing = useRef(false); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimary = async (id: string) => { - const contact = items.find(item => item.id === id); - if (!onSetPrimary || !contact?.isVerified || contact.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimary(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : messages.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; - - const removeContact = async (id: string) => { - const contact = items.find(item => item.id === id); - if (!contact || contact.canRemove === false || !onRemove || removing.current) { - return; - } - removing.current = true; - setContactToRemove(contact); - setRemoveError(undefined); - try { - const [beforeEmail, afterEmail] = messages.removeDialog.description.split('{emailAddress}'); - const confirmed = await removeConfirm.show({ - title: messages.removeDialog.title, - description: ( - <> - {beforeEmail} - {contact.value} - {afterEmail} - - ), - actionLabel: messages.removeDialog.confirm, - cancelLabel: messages.removeDialog.cancel, - destructive: true, - }); - if (confirmed) { - await onRemove(id); - } - } catch (error) { - setRemoveError(error instanceof Error ? error.message : messages.removeError); - } finally { - removing.current = false; - } - }; - - return ( - - - void setPrimary(id) : undefined} - onRemove={onRemove ? id => void removeContact(id) : undefined} - /> - - {primaryError ? ( - - {primaryError} - - ) : null} - {removeError ? ( - - {removeError} - - ) : null} - { - const buttons = Array.from(sectionRef.current?.querySelectorAll('button') ?? []); - const label = contactToRemove ? fill(m.manageValue, { value: contactToRemove.value }) : ''; - return ( - buttons.find(button => button.getAttribute('aria-label') === label) ?? - buttons.find(button => button.getAttribute('aria-label') === messages.add) ?? - buttons[0] ?? - false - ); - }} - /> - - ); -} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts similarity index 100% rename from packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.test.ts rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts similarity index 95% rename from packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts index 114327aea31..88fbb94be19 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-add-email.controller.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; -import { setup } from '../machine/setup'; -import { useMachine } from '../machine/useMachine'; +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; +import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; -import type { UserProfileAddEmailViewProps } from './user-profile-add-email.view'; export interface UserProfileAddEmailControllerOptions { initialEmailAddress?: string; @@ -126,7 +126,7 @@ const machine = createMachine({ export function useUserProfileAddEmailController( options: UserProfileAddEmailControllerOptions, -): UserProfileAddEmailViewProps { +): UserProfileAddEmailDialogProps { const [snapshot, send] = useMachine(machine, { context: options }); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx similarity index 88% rename from packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 1bc3b498633..1fea0bfd249 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-add-email.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -1,18 +1,18 @@ import type { FormEvent } from 'react'; -import { useEffect, useId, useRef } from 'react'; +import { useId, useRef } from 'react'; -import { Button, SubmitButton } from '../components/button'; -import { Card } from '../components/card'; -import type { DialogTriggerProps } from '../components/dialog'; -import { Dialog } from '../components/dialog'; -import { Field } from '../components/field'; -import { Flow } from '../components/flow'; -import { Input } from '../components/input'; -import { Otp } from '../components/otp'; -import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Flow } from '../../components/flow'; +import { Input } from '../../components/input'; +import { Otp } from '../../components/otp'; +import { fill } from './user-profile-account-section.messages'; import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; -export interface UserProfileAddEmailViewProps { +export interface UserProfileAddEmailDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -29,18 +29,12 @@ export interface UserProfileAddEmailViewProps { resendSeconds?: number; } -export function UserProfileAddEmailView(props: UserProfileAddEmailViewProps) { +export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { const emailFormId = useId(); const verifyFormId = useId(); const emailRef = useRef(null); const verifyRef = useRef(null); - useEffect(() => { - if (props.open && props.step === 'verify') { - verifyRef.current?.querySelector('input:not([type="hidden"])')?.focus(); - } - }, [props.open, props.step]); - const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!props.isPending && !props.isResending) { diff --git a/packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.messages.ts similarity index 100% rename from packages/ui/src/mosaic/user-profile/user-profile-add-email.messages.ts rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.messages.ts diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx new file mode 100644 index 00000000000..6733b910cf1 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -0,0 +1,212 @@ +import type { Ref } from 'react'; +import { useRef, useState } from 'react'; + +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfileEmail } from './user-profile-account-section.types'; +import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; +import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; +import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; +import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { UserProfileRemoveEmailDialog } from './user-profile-remove-email.dialog'; + +export interface UserProfileEmailRowViewProps { + emails: UserProfileEmail[]; + allowMultipleAccounts?: boolean; + onAddEmail?: () => void; + onSendEmailCode?: (emailAddress: string) => Promise; + onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onManageEmail?: (id: string) => void; + onVerifyEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void | Promise; + onRemoveEmail?: (id: string) => void | Promise; +} + +export function UserProfileEmailRowView({ + emails, + allowMultipleAccounts = false, + onAddEmail, + onSendEmailCode, + onVerifyEmailCode, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, +}: UserProfileEmailRowViewProps) { + const addEmailTriggerRef = useRef(null); + const addEmailAction = + onSendEmailCode && onVerifyEmailCode ? ( + + ) : onAddEmail ? ( + + ) : undefined; + const confirmedRemoval = useRef(false); + const [emailToRemove, setEmailToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimaryEmail = async (id: string) => { + const email = emails.find(email => email.id === id); + if (!onSetPrimaryEmail || !email?.isVerified || email.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimaryEmail(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : m.email.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removeEmail = (id: string) => { + const email = emails.find(email => email.id === id); + if (!email || email.canRemove === false || !onRemoveEmail || removing.current) { + return; + } + confirmedRemoval.current = false; + setEmailToRemove(email); + setRemoveError(undefined); + }; + + const confirmRemoveEmail = async () => { + if (!emailToRemove || !onRemoveEmail || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setEmailToRemove(undefined); + try { + await onRemoveEmail(emailToRemove.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.email.removeError); + } finally { + removing.current = false; + } + }; + + if (!allowMultipleAccounts) { + return ( + + ); + } + + return ( + <> + void setPrimaryEmail(id) : undefined} + onVerify={onVerifyEmail} + renderActionDialog={ + onRemoveEmail + ? email => ( + { + if (!open) { + setEmailToRemove(undefined); + } + }} + onConfirm={() => void confirmRemoveEmail()} + finalFocus={() => (confirmedRemoval.current ? addEmailTriggerRef.current : undefined)} + /> + ) + : undefined + } + /> + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + + ); +} + +function AddEmail({ + options, + compact, + triggerRef, +}: { + options: UserProfileAddEmailControllerOptions; + compact: boolean; + triggerRef?: Ref; +}) { + const controller = useUserProfileAddEmailController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.email.add} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx new file mode 100644 index 00000000000..6fd6f8d302a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../components/button'; +import type { DialogFocusTarget } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileRemoveEmailDialogProps { + emailAddress: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + finalFocus?: DialogFocusTarget; +} + +export function UserProfileRemoveEmailDialog({ + emailAddress, + open, + onOpenChange, + onConfirm, + finalFocus, +}: UserProfileRemoveEmailDialogProps) { + const [beforeEmail, afterEmail] = m.email.removeDialog.description.split('{emailAddress}'); + + return ( + + + }>{m.email.removeDialog.title} + }> + {beforeEmail} + {emailAddress} + {afterEmail} + + + }>{m.email.removeDialog.cancel} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx similarity index 82% rename from packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx index b00c17de3d9..d9432d89fa3 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx @@ -1,17 +1,17 @@ import * as stylex from '@stylexjs/stylex'; -import { Banner } from '../components/banner'; -import { Button } from '../components/button'; -import { Card } from '../components/card'; -import type { DialogTriggerProps } from '../components/dialog'; -import { Dialog } from '../components/dialog'; -import { Spinner } from '../components/spinner'; -import { Text } from '../components/text'; -import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { Banner } from '../../components/banner'; +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Spinner } from '../../components/spinner'; +import { Text } from '../../components/text'; +import { fill } from './user-profile-account-section.messages'; import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; import { styles } from './user-profile-verify-email-link.styles'; -export interface UserProfileVerifyEmailLinkViewProps { +export interface UserProfileVerifyEmailLinkDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -22,7 +22,7 @@ export interface UserProfileVerifyEmailLinkViewProps { errorMessage?: string; } -export function UserProfileVerifyEmailLinkView({ +export function UserProfileVerifyEmailLinkDialog({ open, onOpenChange, trigger, @@ -31,7 +31,7 @@ export function UserProfileVerifyEmailLinkView({ isResending = false, resendSeconds = 0, errorMessage, -}: UserProfileVerifyEmailLinkViewProps) { +}: UserProfileVerifyEmailLinkDialogProps) { const [beforeEmail, afterEmail] = m.description.split('{emailAddress}'); return ( diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts similarity index 100% rename from packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts similarity index 87% rename from packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts index 3f91864cc70..40f0164da89 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts @@ -1,6 +1,6 @@ import * as stylex from '@stylexjs/stylex'; -import { colorVars, fontWeightVars, space } from '../tokens.stylex'; +import { colorVars, fontWeightVars, space } from '../../tokens.stylex'; export const styles = stylex.create({ content: { diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx similarity index 79% rename from packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx index 6d43d6f958b..05695c4c10c 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx @@ -1,16 +1,16 @@ -import { Banner } from '../components/banner'; -import { Button } from '../components/button'; -import { Card } from '../components/card'; -import type { DialogTriggerProps } from '../components/dialog'; -import { Dialog } from '../components/dialog'; -import { Icon } from '../components/icon'; -import { Item } from '../components/item'; -import { Spinner } from '../components/spinner'; -import { fill } from './user-profile-account-section/user-profile-account-section.messages'; -import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { Banner } from '../../components/banner'; +import { Button } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Icon } from '../../components/icon'; +import { Item } from '../../components/item'; +import { Spinner } from '../../components/spinner'; +import { UserProfileProviderIcon } from '../user-profile-provider-icon'; +import { fill } from './user-profile-account-section.messages'; import { userProfileVerifyEmailSsoMessages as m } from './user-profile-verify-email-sso.messages'; -export interface UserProfileVerifyEmailSsoViewProps { +export interface UserProfileVerifyEmailSsoDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -25,7 +25,7 @@ export interface UserProfileVerifyEmailSsoViewProps { errorMessage?: string; } -export function UserProfileVerifyEmailSsoView({ +export function UserProfileVerifyEmailSsoDialog({ open, onOpenChange, trigger, @@ -34,7 +34,7 @@ export function UserProfileVerifyEmailSsoView({ onConnect, isConnecting = false, errorMessage, -}: UserProfileVerifyEmailSsoViewProps) { +}: UserProfileVerifyEmailSsoDialogProps) { return ( Date: Mon, 14 Sep 2026 10:45:45 -0600 Subject: [PATCH 8/9] test(ui): use compatible deferred promises in email tests --- .../user-profile-add-email.controller.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts index 7e27db78e7a..ed7f6cf6ef3 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -8,11 +9,13 @@ describe('useUserProfileAddEmailController', () => { it('keeps the resend countdown running while verification is pending', async () => { vi.useFakeTimers(); - const verification = Promise.withResolvers(); + const verification = createDeferredPromise(); const { result } = renderHook(() => useUserProfileAddEmailController({ onSend: () => Promise.resolve(), - onVerify: () => verification.promise, + onVerify: async () => { + await verification.promise; + }, }), ); act(() => result.current.onOpenChange(true)); @@ -46,8 +49,10 @@ describe('useUserProfileAddEmailController', () => { }); it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { - const request = Promise.withResolvers(); - const onSend = vi.fn(() => request.promise); + const request = createDeferredPromise(); + const onSend = vi.fn(async () => { + await request.promise; + }); const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify: () => Promise.resolve() }), ); From b79b1a9cd2007debb8ed745bf3dfc469368c0b01 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 11:07:18 -0600 Subject: [PATCH 9/9] fix(ui): adapt email dialogs to Mosaic xstyle props --- .../user-profile-verify-email-link.dialog.tsx | 4 ++-- .../user-profile-verify-email-sso.dialog.tsx | 3 ++- .../user-profile-verify-email-sso.styles.ts | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx index d9432d89fa3..4cf7aae4b72 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx @@ -49,7 +49,7 @@ export function UserProfileVerifyEmailLinkDialog({ {m.title} - + {errorMessage ? ( - {m.waiting} + {m.waiting}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx index 05695c4c10c..496df587021 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx @@ -9,6 +9,7 @@ import { Spinner } from '../../components/spinner'; import { UserProfileProviderIcon } from '../user-profile-provider-icon'; import { fill } from './user-profile-account-section.messages'; import { userProfileVerifyEmailSsoMessages as m } from './user-profile-verify-email-sso.messages'; +import { styles } from './user-profile-verify-email-sso.styles'; export interface UserProfileVerifyEmailSsoDialogProps { open: boolean; @@ -60,7 +61,7 @@ export function UserProfileVerifyEmailSsoDialog({ {errorMessage} ) : null} - + {connection.iconUrl ? : null} {connection.provider} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts new file mode 100644 index 00000000000..c9e34d58858 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts @@ -0,0 +1,7 @@ +import * as stylex from '@stylexjs/stylex'; + +export const styles = stylex.create({ + connection: { + paddingInline: 0, + }, +});