diff --git a/.changeset/mosaic-user-profile-add-email-code.md b/.changeset/mosaic-user-profile-add-email-code.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-add-email-code.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index b23b793d4c0..31c0971d14e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -30,7 +30,11 @@ let user: { enterpriseAccounts: { active: boolean; provider: string; - enterpriseConnection: { name: string; logoPublicUrl: string | null } | null; + enterpriseConnection: { + name: string; + logoPublicUrl: string | null; + disableAdditionalIdentifications?: boolean; + } | null; }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; @@ -39,14 +43,18 @@ let user: { emailAddress: string; verification: FakeVerification; destroy?: ReturnType; + prepareVerification?: ReturnType; + attemptVerification?: ReturnType; }[]; phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; update: ReturnType; + createEmailAddress: ReturnType; } | null; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; +let enterpriseSSOEnabled: boolean; function attribute(overrides: Partial = {}): FakeAttribute { return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; @@ -58,7 +66,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ - __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, + __internal_environment: environmentHydrated + ? { userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } } } + : null, }), }; }); @@ -67,7 +77,7 @@ function renderModel() { return renderHook(() => useUserProfileAccountSectionModel()).result.current; } -async function rejection(save: Promise | undefined): Promise { +async function rejection(save: void | Promise | undefined): Promise { try { await save; return undefined; @@ -101,6 +111,7 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; + enterpriseSSOEnabled = true; usernameSettings = { min_length: 4, max_length: 64 }; attributes = { first_name: attribute(), @@ -124,6 +135,8 @@ beforeEach(() => { emailAddress: 'other@clerk.dev', verification: { status: null }, destroy: vi.fn(() => Promise.resolve()), + prepareVerification: vi.fn(() => Promise.resolve()), + attemptVerification: vi.fn(() => Promise.resolve()), }, { id: 'email_1', @@ -135,6 +148,7 @@ beforeEach(() => { phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], setProfileImage: vi.fn(() => Promise.resolve({})), update: vi.fn(() => Promise.resolve(user)), + createEmailAddress: vi.fn(() => Promise.resolve({ id: 'email_new' })), }; }); @@ -252,6 +266,73 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('maps a create error onto the email field', async () => { + user?.createEmailAddress.mockRejectedValue(apiError('email_address')); + await expect(rejection(ready().onCreateEmail?.('taken@clerk.dev'))).resolves.toEqual({ + fields: { + emailAddress: { + code: 'form_param_invalid', + paramName: 'email_address', + message: 'That value is invalid.', + }, + }, + }); + }); + + it('sends a code to the chosen email', async () => { + const verification = ready().getEmailVerifier?.('email_2').start(); + if (verification?.method !== 'code') { + throw new Error('expected a code verification'); + } + await expect(verification.sent).resolves.toBeUndefined(); + expect(user?.emailAddresses[0]?.prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); + }); + + it('creates an email and verifies the created address before the user reloads', async () => { + const prepareVerification = vi.fn(() => Promise.resolve()); + user?.createEmailAddress.mockResolvedValue({ id: 'email_new', prepareVerification }); + const email = await ready().onCreateEmail?.('new@clerk.dev'); + expect(user?.createEmailAddress).toHaveBeenCalledWith({ email: 'new@clerk.dev' }); + email?.start(); + expect(prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); + }); + + it('verifies the code for the chosen email', async () => { + await ready().getEmailVerifier?.('email_2').verifyCode('123456'); + expect(user?.emailAddresses[0]?.attemptVerification).toHaveBeenCalledWith({ code: '123456' }); + }); + + it('maps a wrong code onto the code field', async () => { + user?.emailAddresses[0]?.attemptVerification?.mockRejectedValue(apiError('code')); + await expect(rejection(ready().getEmailVerifier?.('email_2').verifyCode('000000'))).resolves.toEqual({ + fields: { code: { code: 'form_param_invalid', paramName: 'code', message: 'That value is invalid.' } }, + }); + }); + + it('offers no new email when the email address is immutable', () => { + attributes.email_address = attribute({ immutable: true }); + expect(ready().onCreateEmail).toBeUndefined(); + }); + + it('offers no new email when the active enterprise connection forbids more identifications', () => { + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, + }); + expect(ready().onCreateEmail).toBeUndefined(); + }); + + it('ignores the enterprise restriction when the instance has enterprise SSO off', () => { + enterpriseSSOEnabled = false; + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, + }); + expect(ready().onCreateEmail).toBeDefined(); + }); + it('still offers a new primary but no removal when the email address is immutable', () => { attributes.email_address = attribute({ immutable: true }); const model = ready(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx index 86eaa964d24..fba6c43ccba 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -54,7 +54,7 @@ describe('UserProfileAddEmailDialog', () => { 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' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); expect(props.onSubmit).not.toHaveBeenCalled(); expect(screen.getByRole('textbox', { name: 'Email' })).toBeInvalid(); }); @@ -63,8 +63,7 @@ describe('UserProfileAddEmailDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); - await user.click(screen.getByRole('textbox', { name: 'Verification code' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); if (method === 'typing') { await user.keyboard('12345'); @@ -77,7 +76,7 @@ describe('UserProfileAddEmailDialog', () => { expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); }); - it('focuses the email field and submits through the form or Send code', async () => { + it('focuses the email field and submits through the form or Continue', async () => { const user = userEvent.setup(); const { props } = renderView(); @@ -91,7 +90,7 @@ describe('UserProfileAddEmailDialog', () => { emailForm.requestSubmit(); expect(props.onSubmit).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); expect(props.onSubmit).toHaveBeenCalledTimes(2); }); @@ -181,6 +180,11 @@ describe('UserProfileAddEmailDialog', () => { /> , ); - expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Sending code…' })).toBeDisabled(); + }); + + it('disables Continue when the email cannot be submitted', () => { + renderView({ canSubmitEmail: false }); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index 6b82e9d908f..a054dc8f95d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -10,8 +10,9 @@ describe('profile add email', () => { '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()); + const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); + const verifyCode = vi.fn(() => Promise.resolve()); + const onCreate = vi.fn(() => Promise.resolve({ start, verifyCode })); render( { username='test' emails={[]} phones={[]} - onSendEmailCode={onSend} - onVerifyEmailCode={onVerify} + onCreateEmail={onCreate} + getEmailVerifier={() => ({ start, verifyCode })} /> , ); @@ -29,14 +30,46 @@ describe('profile 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 user.click(screen.getByRole('button', { name: 'Continue' })); const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); await waitFor(() => expect(codeInput).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'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(start).toHaveBeenCalledOnce(); + expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); await waitFor(() => expect(trigger).toHaveFocus()); }, ); + + it('verifies an unverified email from its menu and returns focus to the menu', async () => { + const user = userEvent.setup(); + const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); + const verifyCode = vi.fn(() => Promise.resolve()); + const getEmailVerifier = vi.fn(() => ({ start, verifyCode })); + render( + + + , + ); + expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument(); + const trigger = screen.getByRole('button', { name: 'Manage other@example.com' }); + await user.click(trigger); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + expect(dialog).toHaveTextContent('other@example.com'); + expect(getEmailVerifier).toHaveBeenCalledExactlyOnceWith('email_2'); + await waitFor(() => expect(start).toHaveBeenCalledOnce()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 8301014f25c..6e4afe99a66 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -8,6 +8,11 @@ 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'; +const codeVerifier = { + start: () => ({ method: 'code', sent: Promise.resolve() }) as const, + verifyCode: () => Promise.resolve(), +}; + function renderEmail(overrides: Partial = {}) { return render( @@ -56,8 +61,8 @@ describe('email actions', () => { username='test' phones={[]} emails={emails} - onSendEmailCode={() => Promise.resolve()} - onVerifyEmailCode={() => Promise.resolve()} + onCreateEmail={() => Promise.resolve(codeVerifier)} + getEmailVerifier={() => codeVerifier} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} /> diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 35ab98d2f47..f75090a7269 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -7,11 +7,13 @@ import { save } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, + UserProfileEmailVerifier, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; import { isAttributeAvailable, sortByVerification } from './user-profile-account-section.utils'; import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; +import type { UserProfileAddEmailField } from './user-profile-add-email.controller'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -29,6 +31,8 @@ type UserProfileAccountSectionData = Pick< | 'username' | 'emails' | 'phones' + | 'onCreateEmail' + | 'getEmailVerifier' | 'onSetPrimaryEmail' | 'onRemoveEmail' | 'onProfilePictureChange' @@ -44,6 +48,7 @@ export type UserProfileAccountSectionModel = const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; +const ADD_EMAIL_FIELDS: readonly UserProfileAddEmailField[] = ['emailAddress', 'code']; function emailById(user: UserResource, id: string): EmailAddressResource { const email = user.emailAddresses.find(email => email.id === id); @@ -53,6 +58,22 @@ function emailById(user: UserResource, id: string): EmailAddressResource { return email; } +function toEmailVerifier(email: EmailAddressResource): UserProfileEmailVerifier { + return { + start: () => ({ method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }), + verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), + }; +} + +function canAddIdentifications(user: UserResource, enterpriseSSOEnabled: boolean): boolean { + return ( + !enterpriseSSOEnabled || + !user.enterpriseAccounts.some( + account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, + ) + ); +} + function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { if (!account) { return undefined; @@ -98,13 +119,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } - const { attributes, usernameSettings } = environment.userSettings; + const { attributes, usernameSettings, enterpriseSSO } = environment.userSettings; const usernameAttribute = attributes.username; const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); const showEmails = isAttributeAvailable(attributes.email_address); const emailsImmutable = Boolean(attributes.email_address?.immutable); + const canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled); const showPhones = isAttributeAvailable(attributes.phone_number); return { @@ -121,6 +143,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, + onCreateEmail: canCreateEmail + ? async emailAddress => { + const request = user.createEmailAddress({ email: emailAddress }); + await save(() => request, ADD_EMAIL_FIELDS); + return toEmailVerifier(await request); + } + : undefined, + getEmailVerifier: showEmails ? id => toEmailVerifier(emailById(user, id)) : undefined, onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts index d7afb176a53..62b73c573c3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -16,3 +16,13 @@ export interface UserProfileContact { export type UserProfileEmail = UserProfileContact; export type UserProfilePhone = UserProfileContact; + +export interface UserProfileEmailVerification { + method: 'code'; + sent: Promise; +} + +export interface UserProfileEmailVerifier { + start: () => UserProfileEmailVerification; + verifyCode: (code: string) => Promise; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index bf3923075d0..d8b9bd9baff 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -7,6 +7,7 @@ import type { UserProfileManagedBy } from '../user-profile-managed-by'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, + UserProfileEmailVerifier, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; @@ -45,8 +46,8 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; - onSendEmailCode?: (emailAddress: string) => Promise; - onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onCreateEmail?: (emailAddress: string) => Promise; + getEmailVerifier?: (id: string) => UserProfileEmailVerifier; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; @@ -78,8 +79,8 @@ export function UserProfileAccountSectionView({ onSubmitName, onSubmitUsername, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -110,8 +111,8 @@ export function UserProfileAccountSectionView({ emails={emails} allowMultipleAccounts={allowMultipleAccounts} onAddEmail={onAddEmail} - onSendEmailCode={onSendEmailCode} - onVerifyEmailCode={onVerifyEmailCode} + onCreateEmail={onCreateEmail} + getEmailVerifier={getEmailVerifier} onManageEmail={onManageEmail} onVerifyEmail={onVerifyEmail} onSetPrimaryEmail={onSetPrimaryEmail} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts index ed7f6cf6ef3..bd2f056cef8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts @@ -2,8 +2,25 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SaveError } from '../../../utils/form-error'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from './user-profile-account-section.types'; import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +const sentCode = (): UserProfileEmailVerification => ({ method: 'code', sent: Promise.resolve() }); + +function verifier( + start: () => UserProfileEmailVerification = sentCode, + verifyCode: (code: string) => Promise = () => Promise.resolve(), +) { + return { start: vi.fn(start), verifyCode: vi.fn(verifyCode) } satisfies UserProfileEmailVerifier; +} + +const created = () => Promise.resolve(verifier()); + +function saveError(message: string, field?: 'emailAddress' | 'code') { + return new SaveError(field ? { fields: { [field]: { message } } } : { global: { message } }); +} + describe('useUserProfileAddEmailController', () => { afterEach(() => vi.useRealTimers()); @@ -12,10 +29,12 @@ describe('useUserProfileAddEmailController', () => { const verification = createDeferredPromise(); const { result } = renderHook(() => useUserProfileAddEmailController({ - onSend: () => Promise.resolve(), - onVerify: async () => { - await verification.promise; - }, + onCreate: () => + Promise.resolve( + verifier(sentCode, async () => { + await verification.promise; + }), + ), }), ); act(() => result.current.onOpenChange(true)); @@ -29,7 +48,7 @@ describe('useUserProfileAddEmailController', () => { } expect(result.current.resendSeconds).toBe(0); await act(async () => { - verification.reject(new Error('Incorrect code')); + verification.reject(saveError('Incorrect code', 'code')); await Promise.resolve(); }); expect(result.current.errorMessage).toBe('Incorrect code'); @@ -40,36 +59,35 @@ describe('useUserProfileAddEmailController', () => { const { result } = renderHook(() => useUserProfileAddEmailController({ initialEmailAddress: 'saved@example.com', - onSend: () => Promise.resolve(), - onVerify: () => Promise.resolve(), + onCreate: created, }), ); 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 () => { + it('ignores cancellation and duplicate submissions while creating, then resets on reopen', async () => { const request = createDeferredPromise(); - const onSend = vi.fn(async () => { + const onCreate = vi.fn(async () => { await request.promise; + return verifier(); }); - const { result } = renderHook(() => - useUserProfileAddEmailController({ onSend, onVerify: () => Promise.resolve() }), - ); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); 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); }); + act(() => result.current.onOpenChange(false)); expect(result.current.open).toBe(true); - expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); await act(async () => { request.resolve(); await request.promise; }); + await waitFor(() => expect(result.current.isResending).toBe(false)); act(() => result.current.onCodeChange('123')); act(() => result.current.onOpenChange(false)); expect(result.current.open).toBe(false); @@ -82,17 +100,16 @@ describe('useUserProfileAddEmailController', () => { 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 })); + const email = verifier(); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate: () => Promise.resolve(email) })); act(() => result.current.onOpenChange(true)); await act(async () => { result.current.onSubmit(); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); }); expect(result.current.resendSeconds).toBe(12); act(() => result.current.onResend()); - expect(onSend).toHaveBeenCalledTimes(1); + expect(email.start).toHaveBeenCalledTimes(1); for (let second = 0; second < 12; second++) { await act(async () => vi.advanceTimersByTimeAsync(1000)); } @@ -105,18 +122,17 @@ describe('useUserProfileAddEmailController', () => { result.current.onOpenChange(false); await Promise.resolve(); }); - expect(onSend).toHaveBeenCalledTimes(2); - expect(onVerify).not.toHaveBeenCalled(); + expect(email.start).toHaveBeenCalledTimes(2); + expect(email.verifyCode).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 operation = vi.fn().mockRejectedValueOnce(saveError('Try again')).mockResolvedValue(verifier()); const { result } = renderHook(() => useUserProfileAddEmailController({ - onSend: step === 'email' ? operation : () => Promise.resolve(), - onVerify: step === 'verify' ? operation : () => Promise.resolve(), + onCreate: step === 'email' ? operation : () => Promise.resolve(verifier(sentCode, operation)), }), ); act(() => result.current.onOpenChange(true)); @@ -137,10 +153,10 @@ describe('useUserProfileAddEmailController', () => { 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 })); + it('creates the email, sends it a code, verifies the submitted code, and closes on success', async () => { + const email = verifier(); + const onCreate = vi.fn(() => Promise.resolve(email)); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); expect(result.current.open).toBe(false); act(() => result.current.onOpenChange(true)); @@ -149,10 +165,75 @@ describe('useUserProfileAddEmailController', () => { 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'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); + await waitFor(() => expect(email.start).toHaveBeenCalledOnce()); act(() => result.current.onSubmit('123456')); await waitFor(() => expect(result.current.open).toBe(false)); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('verifies an existing email without creating one', async () => { + const onCreate = vi.fn(created); + const email = verifier(); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + expect(result.current.open).toBe(true); + expect(result.current.step).toBe('verify'); + expect(result.current.emailAddress).toBe('other@example.com'); + await waitFor(() => expect(email.start).toHaveBeenCalledOnce()); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('stays on the code step when sending fails, and lets the user resend at once', async () => { + const email = verifier(); + email.start.mockImplementationOnce(() => ({ + method: 'code', + sent: Promise.reject(saveError('Too many requests')), + })); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate: created })); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests')); + expect(result.current.step).toBe('verify'); + expect(result.current.resendSeconds).toBe(0); + + act(() => result.current.onResend()); + await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2)); + }); + + it('shows the error for the field that failed', async () => { + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onCreate: () => Promise.reject(saveError('That email is taken.', 'emailAddress')), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.errorMessage).toBe('That email is taken.')); + }); + + it('moves from the email step straight to the code step', async () => { + const shown: string[] = []; + const { result } = renderHook(() => { + const controller = useUserProfileAddEmailController({ + onCreate: created, + }); + if (controller.open && shown.at(-1) !== controller.step) { + shown.push(controller.step); + } + return controller; + }); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.resendSeconds).toBeGreaterThan(0)); + expect(shown).toEqual(['email', 'verify']); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts index 91230c5c20c..cbadbc6a0b6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts @@ -1,133 +1,132 @@ import { useEffect } from 'react'; -import { useMessages } from '../../../localization'; +import { useForm } from '../../../components/form'; +import { useErrorText } from '../../../localization'; import { setup } from '../../../machine/setup'; +import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/useMachine'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; +import type { UserProfileEmailVerifier } from './user-profile-account-section.types'; import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; +export type UserProfileAddEmailField = 'emailAddress' | 'code'; + export interface UserProfileAddEmailControllerOptions { initialEmailAddress?: string; - onSend: (emailAddress: string) => Promise; - onVerify: (emailAddress: string, code: string) => Promise; + onCreate?: (emailAddress: string) => Promise; +} + +export interface UserProfileAddEmailController extends UserProfileAddEmailDialogProps { + onVerifyEmail: (emailAddress: string, verifier: UserProfileEmailVerifier) => void; } -interface Context extends UserProfileAddEmailControllerOptions { +interface Context { emailAddress: string; - code: string; - error: unknown; + verifier: UserProfileEmailVerifier | undefined; + error: FormError | undefined; resendSeconds: number; } type Event = - | { type: 'OPEN' } + | { type: 'ADD' } + | { type: 'START'; emailAddress: string; verifier: UserProfileEmailVerifier } + | { type: 'VERIFIED' } | { type: 'CANCEL' } | { type: 'RESEND' } - | { type: 'TICK' } - | { type: 'TYPE_EMAIL'; value: string } - | { type: 'TYPE_CODE'; value: string } - | { type: 'SUBMIT'; code?: string }; + | { type: 'TICK' }; const { createMachine, assign, fromPromise } = setup(); function missingDependency(): Promise { - return Promise.reject(new Error('Add email callbacks are missing')); + return Promise.reject(new Error('Email verification is missing')); } -function errorMessage(cause: unknown, fallback: string): string | undefined { - if (cause === undefined) { - return undefined; - } - return cause instanceof Error ? cause.message : fallback; -} +const CODE_RESEND_SECONDS = 12; -const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; +const start = { + target: 'sending', + actions: assign>((_, event) => ({ + emailAddress: event.emailAddress, + verifier: event.verifier, + error: undefined, + resendSeconds: 0, + })), +}; const machine = createMachine({ id: 'addEmail', initial: 'idle', context: { - onSend: missingDependency, - onVerify: missingDependency, emailAddress: '', - code: '', + verifier: undefined, error: undefined, resendSeconds: 0, }, states: { idle: { on: { - OPEN: { + ADD: { target: 'email', - actions: assign(context => ({ - emailAddress: context.initialEmailAddress ?? '', - code: '', - error: undefined, - resendSeconds: 0, - })), + actions: assign(() => ({ verifier: undefined, error: undefined, resendSeconds: 0 })), }, + START: start, }, }, email: { - on: { - CANCEL: 'idle', - TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, error: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, - }, + on: { CANCEL: 'idle', START: start }, }, sending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + invoke: fromPromise(context => (context.verifier ? context.verifier.start().sent : missingDependency()), { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, onError: { - target: 'email', - actions: assign((_, event) => ({ error: event.error })), + target: 'verify', + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, verify: { on: { CANCEL: 'idle', - TICK: tick, + TICK: { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }, RESEND: { - target: 'resending', + target: 'sending', guard: context => context.resendSeconds === 0, actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, - SUBMIT: { - target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), - }, + VERIFIED: 'idle', }, }, - resending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, - verifying: { - on: { TICK: tick }, - invoke: fromPromise(context => context.onVerify(context.emailAddress, context.code), { - onDone: 'idle', - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, }, }); -export function useUserProfileAddEmailController( - options: UserProfileAddEmailControllerOptions, -): UserProfileAddEmailDialogProps { - const m = useMessages('userProfileAddEmail'); - const [snapshot, send] = useMachine(machine, { context: options }); - const { resendSeconds } = snapshot.context; - const open = snapshot.value !== 'idle'; +export function useUserProfileAddEmailController({ + initialEmailAddress = '', + onCreate, +}: UserProfileAddEmailControllerOptions): UserProfileAddEmailController { + const errorText = useErrorText(); + const [snapshot, send, actor] = useMachine(machine); + const { resendSeconds, error } = snapshot.context; + const emailForm = useForm({ + initialValues: { emailAddress: initialEmailAddress }, + onSubmit: async ({ emailAddress }) => { + if (onCreate) { + send({ type: 'START', emailAddress, verifier: await onCreate(emailAddress) }); + } + }, + }); + const codeForm = useForm({ + initialValues: { code: '' }, + onSubmit: async ({ code }) => { + const { verifier } = actor.getSnapshot().context; + if (verifier) { + await verifier.verifyCode(code); + send({ type: 'VERIFIED' }); + } + }, + }); + const state = snapshot.value; + const open = state !== 'idle'; useEffect(() => { if (!open || resendSeconds === 0) { return; @@ -136,22 +135,55 @@ export function useUserProfileAddEmailController( return () => clearTimeout(timer); }, [open, resendSeconds, send]); + const step = state === 'email' ? 'email' : 'verify'; + const form = step === 'email' ? emailForm : codeForm; + const formError = step === 'email' ? emailForm.fields.emailAddress.feedback : codeForm.fields.code.feedback; + const machineError = error?.global ? errorText(error.global) : undefined; + return { resendSeconds, - isResending: snapshot.value === 'resending', + isResending: state === 'sending', open, - step: - snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' - ? 'verify' - : 'email', - emailAddress: snapshot.context.emailAddress, - code: snapshot.context.code, - errorMessage: errorMessage(snapshot.context.error, m.error), - 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' }), + step, + emailAddress: step === 'email' ? emailForm.values.emailAddress : snapshot.context.emailAddress, + canSubmitEmail: emailForm.canSubmit || emailForm.isSubmitting, + code: codeForm.values.code, + errorMessage: formError?.message ?? form.error ?? machineError, + isPending: form.isSubmitting, + onOpenChange: open => { + if (form.isSubmitting) { + return; + } + if (open) { + emailForm.reset(); + codeForm.reset(); + send({ type: 'ADD' }); + } else { + send({ type: 'CANCEL' }); + } + }, + onEmailAddressChange: value => emailForm.setValue('emailAddress', value), + onCodeChange: value => codeForm.setValue('code', value), + onSubmit: code => { + const state = actor.getSnapshot().value; + if (state === 'email') { + emailForm.submit(); + } else if (state === 'verify') { + if (code !== undefined) { + codeForm.setValue('code', code); + } + codeForm.submit(); + } + }, + onResend: () => { + if (!codeForm.isSubmitting && actor.can({ type: 'RESEND' })) { + codeForm.reset(); + send({ type: 'RESEND' }); + } + }, + onVerifyEmail: (emailAddress, verifier) => { + codeForm.reset(); + send({ type: 'START', emailAddress, verifier }); + }, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx index 50d1237fa28..923d78ceea3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -1,10 +1,11 @@ +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { FormEvent, Ref } 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 type { DialogFocusTarget, DialogHandle } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; @@ -16,10 +17,12 @@ import { styles } from '../user-profile-profile-panel.styles'; export interface UserProfileAddEmailDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; + handle?: DialogHandle; + finalFocus?: DialogFocusTarget; step: 'email' | 'verify'; emailAddress: string; onEmailAddressChange: (value: string) => void; + canSubmitEmail?: boolean; code: string; onCodeChange: (value: string) => void; onSubmit: (code?: string) => void; @@ -32,16 +35,18 @@ export interface UserProfileAddEmailDialogProps { export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { const emailRef = useRef(null); + const codeRef = useRef(null); return ( - {props.trigger ? : null} void; onSubmit: () => void; + canSubmit: boolean; isPending?: boolean; errorMessage?: string; } @@ -138,6 +146,7 @@ function EnterEmailStep(props: EnterEmailStepProps) { @@ -149,6 +158,7 @@ function EnterEmailStep(props: EnterEmailStepProps) { } interface VerifyEmailStepProps { + inputRef: Ref; emailAddress: string; code: string; onCodeChange: (value: string) => void; @@ -163,6 +173,7 @@ interface VerifyEmailStepProps { function VerifyEmailStep(props: VerifyEmailStepProps) { const m = useMessages('userProfileAddEmail'); const verifyFormId = useId(); + const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -190,7 +201,7 @@ function VerifyEmailStep(props: VerifyEmailStepProps) { > {m.verify.label} ()} + ref={inputRef} name='code' value={props.code} onValueChange={props.onCodeChange} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts index d665475f099..4ea7a8340a1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts @@ -1,11 +1,10 @@ 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.', + description: 'You’ll need to verify this email address before it’s added to your account.', label: 'Email', - submit: 'Send code', - pending: 'Sending code', + submit: 'Continue', + pending: 'Adding email', }, verify: { title: 'Verify your email', @@ -15,7 +14,7 @@ export const userProfileAddEmailMessages = { pending: 'Verifying', cancel: 'Cancel', resend: 'Didn’t receive a code? Resend', - resending: 'Sending a new code…', + resending: 'Sending code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx index 9db44fe83cf..35ca5aaaace 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -2,12 +2,12 @@ import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; +import { Dialog } from '../../../components/dialog'; import { Icon } from '../../../components/icon'; import { Text } from '../../../components/text'; import { useListRemovalFocus } from '../../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../../localization'; -import type { UserProfileEmail } from './user-profile-account-section.types'; -import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; +import type { UserProfileEmail, UserProfileEmailVerifier } from './user-profile-account-section.types'; 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'; @@ -18,8 +18,8 @@ export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; allowMultipleAccounts?: boolean; onAddEmail?: () => void; - onSendEmailCode?: (emailAddress: string) => Promise; - onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onCreateEmail?: (emailAddress: string) => Promise; + getEmailVerifier?: (id: string) => UserProfileEmailVerifier; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; @@ -30,8 +30,8 @@ export function UserProfileEmailRowView({ emails, allowMultipleAccounts = false, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -44,30 +44,55 @@ export function UserProfileEmailRowView({ onRemove: onRemoveEmail, fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, }); + const verification = useUserProfileAddEmailController({ onCreate: onCreateEmail }); + const verificationDialog = useMemo(() => Dialog.createHandle(), []); + const canVerify = Boolean(getEmailVerifier); + const addEmailLabel = ( + <> + {allowMultipleAccounts ? ( + + ) : null} + {allowMultipleAccounts ? m.add : m.email.add} + + ); const addEmailAction = - onSendEmailCode && onVerifyEmailCode ? ( - + canVerify && onCreateEmail ? ( + + } + > + {addEmailLabel} + ) : onAddEmail ? ( ) : undefined; + const verifyingId = useRef(undefined); + const verifyEmail = (id: string) => { + const email = emails.find(email => email.id === id); + if (email && getEmailVerifier) { + verifyingId.current = id; + verification.onVerifyEmail(email.value, getEmailVerifier(id)); + } + }; const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); const primary = useUserProfileSetPrimaryController({ items: emails, @@ -82,15 +107,30 @@ export function UserProfileEmailRowView({ } }; + const dialog = canVerify ? ( + { + const id = verifyingId.current; + verifyingId.current = undefined; + return id ? removalFocus.trigger(id) : null; + }} + /> + ) : null; + if (!allowMultipleAccounts) { return ( - + <> + + {dialog} + ); } @@ -105,8 +145,9 @@ export function UserProfileEmailRowView({ addAction={addEmailAction} onRemove={onRemoveEmail ? removeEmail : undefined} onSetPrimary={primary.onSetPrimary} - onVerify={onVerifyEmail} + onVerify={canVerify ? verifyEmail : onVerifyEmail} /> + {dialog} {primary.error ? ( ); } - -function AddEmail({ options, compact }: { options: UserProfileAddEmailControllerOptions; compact: boolean }) { - const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileAddEmailController(options); - return ( - - {compact ? ( - - ) : null} - {compact ? m.add : m.email.add} - - } - /> - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index b11ff784ede..19e2bf427d2 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -61,8 +61,8 @@ export function UserProfileProfilePanelView({ onSubmitName, onSubmitUsername, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -103,10 +103,10 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} + onCreateEmail={onCreateEmail} onSendPhoneCode={onSendPhoneCode} onVerifyPhoneCode={onVerifyPhoneCode} - onSendEmailCode={onSendEmailCode} - onVerifyEmailCode={onVerifyEmailCode} + getEmailVerifier={getEmailVerifier} onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange} diff --git a/packages/mosaic/src/hooks/useListRemovalFocus.ts b/packages/mosaic/src/hooks/useListRemovalFocus.ts index 1c38c6ae607..73621b38222 100644 --- a/packages/mosaic/src/hooks/useListRemovalFocus.ts +++ b/packages/mosaic/src/hooks/useListRemovalFocus.ts @@ -40,5 +40,7 @@ export function useListRemovalFocus({ return (next ? triggers.current.get(next) : undefined) ?? fallback(); }; - return { registerTrigger, remove, finalFocus }; + const trigger = (id: string) => triggers.current.get(id) ?? null; + + return { registerTrigger, trigger, remove, finalFocus }; } 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 3735331b61d..15feb07e4ac 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -1,28 +1,39 @@ +import type { UserProfileEmailVerifier } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileAccountSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; -import type { UserProfileAddEmailDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog'; interface FixtureOptions { - failAt?: UserProfileAddEmailDialogProps['step']; - onVerified?: (emailAddress: string) => void; + fail?: 'create' | 'verify'; + onCreated?: (id: string, emailAddress: string) => void; + onVerified?: (id: string) => void; } -export function createUserProfileAddEmailFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function createUserProfileAddEmailFixture({ fail, onCreated, onVerified }: FixtureOptions = {}): Pick< UserProfileAccountSectionViewProps, - 'onSendEmailCode' | 'onVerifyEmailCode' + 'onCreateEmail' | 'getEmailVerifier' > { - return { - onSendEmailCode: async () => { - await new Promise(resolve => setTimeout(resolve, 700)); - if (failAt === 'email') { - throw new Error('We couldn’t send a code. Try again.'); + const verifier = (id: string): UserProfileEmailVerifier => ({ + start: () => ({ method: 'code', sent: delay(700).then(() => undefined) }), + verifyCode: async code => { + await delay(700); + if (fail === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); } + onVerified?.(id); }, - 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.'); + }); + + return { + onCreateEmail: async emailAddress => { + await delay(700); + if (fail === 'create') { + throw new Error('We couldn’t add this email. Try again.'); } - onVerified?.(emailAddress); + const id = `email_${Date.now()}`; + onCreated?.(id, emailAddress); + return verifier(id); }, + getEmailVerifier: verifier, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 1019e574e6d..42b68223c4b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -64,8 +64,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const addEmail = (value: string) => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: false }]); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), + onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), + onVerified: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), }); const pages: UserProfileViewProps['pages'] = { @@ -88,8 +89,8 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions emails, phones, onAddEmail, - onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, - onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, + onCreateEmail: onAddEmail ? undefined : emailFlow.onCreateEmail, + getEmailVerifier: onAddEmail ? undefined : emailFlow.getEmailVerifier, ...createUserProfileAddPhoneFixture({ onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), 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 56d77d311b6..db0d92f2fd9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -74,9 +74,10 @@ function AccountSection({ setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const emailFlow = createUserProfileAddEmailFixture({ - failAt: failEmailVerification ? 'verify' : undefined, - onVerified: value => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), + fail: failEmailVerification ? 'verify' : undefined, + onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), + onVerified: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), }); return ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 7e392ec0679..db825033efb 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -55,8 +55,8 @@ import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile }; })} onVerifyEmail={verifyEmail} - onSendEmailCode={sendEmailCode} - onVerifyEmailCode={verifyEmailCode} + onCreateEmail={createEmail} + getEmailVerifier={getEmailVerifier} 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 96400c4ec62..c20f1ef71e0 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -42,8 +42,9 @@ export function Default(_args: Record) { const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), + onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), + onVerified: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), }); return (