From b7ddbbda903b5cf24e14b4fff64d89ea025a38d7 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 09:56:34 -0600 Subject: [PATCH 1/6] 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 | 124 ++++++++++++++++-- .../user-profile-profile-panel.styles.ts | 5 +- 7 files changed, 135 insertions(+), 10 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 88229333df9..c6b4fa0b590 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -63,6 +63,7 @@ function AccountSection({ allowMultipleAccounts }: { allowMultipleAccounts: bool onManagePhone={() => undefined} onProfilePictureChange={showFile} 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))} onRemoveProfilePicture={clearImage} onNameChange={() => undefined} 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 334506009b2..313d73ead28 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -93,7 +93,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={() => undefined} 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 328812f019a..b224022834b 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 @@ -392,6 +392,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.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts index d8dfca184c3..6b848853bf0 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts @@ -37,6 +37,7 @@ export const userProfileAccountSectionBase = { }, primary: 'Primary', add: 'Add', + remove: 'Remove', manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', @@ -49,6 +50,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.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index aeedf789db6..235b46cd4ab 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -1,13 +1,15 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Avatar } from '../components/avatar'; import { Badge } from '../components/badge'; import { Button } from '../components/button'; +import { createConfirmHandle, Dialog } from '../components/dialog'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; +import { Text } from '../components/text'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; @@ -59,8 +61,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; onAddPhone?: () => void; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; @@ -206,7 +208,7 @@ export function UserProfileAccountSectionView({ {allowMultipleAccounts ? ( - void; onManage?: (id: string) => void; onVerify?: (id: string) => void; - onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onSetPrimary?: (id: string) => void | Promise; + onRemove?: (id: string) => void | Promise; } function ContactSection(props: ContactSectionProps) { @@ -310,6 +312,112 @@ function ContactSection(props: ContactSectionProps) { ); } +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 + ); + }} + /> + + ); +} + function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; @@ -393,14 +501,14 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar onClick: () => onVerify(item.id), }); } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); + actions.push({ label: m.setPrimary, onClick: () => void onSetPrimary(item.id) }); } if (onRemove && item.canRemove !== false) { actions.push({ label: m[kind].remove, color: 'negative', - onClick: () => onRemove(item.id), + onClick: () => void onRemove(item.id), }); } 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 8a3cc17a27c..6fe2aadd747 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 @@ -1,8 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + confirmationContactValue: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', From 60fa358018365a5c4f120ebf52c6ebd6ff31587a Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 13:33:47 -0600 Subject: [PATCH 2/6] 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.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts index 6b848853bf0..cc5aa9e7ad5 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.messages.ts @@ -37,7 +37,6 @@ export const userProfileAccountSectionBase = { }, primary: 'Primary', add: 'Add', - remove: 'Remove', manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', @@ -50,10 +49,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.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 235b46cd4ab..f8bda5aaf48 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -351,15 +351,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 5cd8b340e3118424f26a73f4e67818966a5f8b0d Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 13:54:32 -0600 Subject: [PATCH 3/6] 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 | 66 +++++- .../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, 1211 insertions(+), 18 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 ebe2f7b9fab..945fbda5a64 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,7 +188,10 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { + 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'; @@ -467,6 +470,9 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + 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 9b8a9ce3eb8..cf97a9cade3 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,10 +13,11 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; 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; } @@ -108,6 +109,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: { @@ -118,7 +122,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions username: 'prestonxyz', emails, phones, - onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), + onAddEmail, + onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, + onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, onAddPhone: () => setPhones(current => [ ...current, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index e7a319005b4..8293772354f 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -18,6 +18,10 @@ Account details, profile image, email addresses, and phone numbers composed with ## 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. + + +## 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 5d37eb913d6..efb807da4a1 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,15 +1,19 @@ +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, UserProfilePhone, } 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 { UserProfileVerifyEmailLinkView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-link.view'; 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 { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; +import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -25,9 +29,11 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, failWith, + failEmailVerification = false, }: { allowMultipleAccounts: boolean; failWith?: UserProfileFormError; + failEmailVerification?: boolean; }) { const editName = useUserProfileEditNameFixture({ failWith }); const [emails, setEmails] = useState( @@ -42,22 +48,21 @@ function AccountSection({ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); + 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 }, - ]) - } onAddPhone={() => setPhones(current => [ ...current, @@ -88,6 +93,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 d8d20390859..f9016443cfe 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -5,6 +5,7 @@ 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 { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; @@ -31,10 +32,14 @@ export function Default(_args: Record) { ]); const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const editName = useUserProfileEditNameFixture(); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( ) { imageUrl={imageUrl} phones={phones} username='prestonxyz' - onAddEmail={() => - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } onAddPhone={() => setPhones(current => [ ...current, 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 31c5e1cde2d..bbea9a9a41b 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,6 +1,7 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; import { useMemo, useRef, useState } from 'react'; import { Avatar } from '../../components/avatar'; @@ -12,6 +13,9 @@ import { Section } from '../../components/section'; import { Text } from '../../components/text'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; +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 { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -70,6 +74,8 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onUsernameChange?: (value: string) => void; 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; @@ -99,6 +105,8 @@ export function UserProfileAccountSectionView({ onSubmitName, onUsernameChange, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -109,6 +117,13 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addEmailAction = + onSendEmailCode && onVerifyEmailCode ? ( + + ) : undefined; const initials = name .split(/\s+/) .map(part => part[0]) @@ -205,6 +220,7 @@ export function UserProfileAccountSectionView({ items={emails} kind='email' label={m.email.label} + addAction={addEmailAction} onAdd={onAddEmail} onManage={onManageEmail} /> @@ -225,6 +241,7 @@ export function UserProfileAccountSectionView({ items={emails} kind='email' label={m.email.label} + addAction={addEmailAction} onAdd={onAddEmail} onManage={onManageEmail} onRemove={onRemoveEmail} @@ -338,7 +355,34 @@ function EditName({ ); } +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 }>; @@ -468,7 +512,7 @@ function EmailContactSection(props: ContactSectionProps) { ); } -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { +function SingleContactRow({ kind, label, items, onAdd, onManage, addAction }: ContactSectionProps) { const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; @@ -488,7 +532,9 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {emptyDescription} )} - {onClick ? ( + {!item && addAction ? ( + {addAction} + ) : onClick ? ( + + + + + } + > + {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 ffd3b4bf349..204e16eefb2 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, onUsernameChange, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -92,6 +94,8 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} + onSendEmailCode={onSendEmailCode} + onVerifyEmailCode={onVerifyEmailCode} onAddPhone={onAddPhone} onManageEmail={onManageEmail} onManagePhone={onManagePhone} 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 9bbe37f8e5af9fed77ded42048ac88643fa0b21f Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Fri, 11 Sep 2026 14:02:13 -0600 Subject: [PATCH 4/6] 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/6] 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} + + + + + + ); +}