diff --git a/.changeset/heavy-pears-smile.md b/.changeset/heavy-pears-smile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/heavy-pears-smile.md @@ -0,0 +1,2 @@ +--- +--- 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/.changeset/wild-carpets-make.md b/.changeset/wild-carpets-make.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-carpets-make.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..f597fc70745 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,7 +188,13 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { + AddEmailFails as UserProfileAccountSectionAddEmailFails, + AddPhoneFails as UserProfileAccountSectionAddPhoneFails, Default as UserProfileAccountSectionDefault, + EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails, + EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, + EmailSsoConnectFails as UserProfileAccountSectionEmailSsoConnectFails, + EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, } from '../stories/user-profile-account-section.stories'; @@ -467,6 +473,12 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + AddPhoneFails: UserProfileAccountSectionAddPhoneFails, + AddEmailFails: UserProfileAccountSectionAddEmailFails, + EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, + EmailLinkResendFails: UserProfileAccountSectionEmailLinkResendFails, + EmailSsoVerification: UserProfileAccountSectionEmailSsoVerification, + EmailSsoConnectFails: UserProfileAccountSectionEmailSsoConnectFails, }; 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..647525923d9 --- /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 { UserProfileAddEmailDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog'; + +interface FixtureOptions { + failAt?: UserProfileAddEmailDialogProps['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-add-phone.ts b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts new file mode 100644 index 00000000000..c2908c6716e --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.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 { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; + +interface FixtureOptions { + failAt?: UserProfileAddPhoneDialogProps['step']; + onVerified?: (phoneNumber: string) => void; +} + +export function createUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< + UserProfileAccountSectionViewProps, + 'onSendPhoneCode' | 'onVerifyPhoneCode' +> { + return { + onSendPhoneCode: async () => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'phone') { + throw new Error('We couldn’t send a code. Try again.'); + } + }, + onVerifyPhoneCode: async (phoneNumber, code) => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(phoneNumber); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index c6e729cfecd..7604e9f09b1 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,6 +1,6 @@ import type { UserProfileFormError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditNameValue } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view'; +import type { UserProfileEditNameValue } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; import { useState } from 'react'; export interface UserProfileEditNameFixtureOptions { 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-verify-email-sso.ts b/packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts new file mode 100644 index 00000000000..7e2e662b624 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +export function useUserProfileVerifyEmailSsoFixture({ failConnect = false } = {}) { + const [open, setOpen] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + if (!isConnecting) { + return; + } + const timer = setTimeout(() => { + setIsConnecting(false); + if (failConnect) { + setErrorMessage('Unable to connect to Okta. Try again.'); + } else { + setOpen(false); + } + }, 1200); + return () => clearTimeout(timer); + }, [isConnecting, failConnect]); + + return { + open, + emailAddress: 'example@email.com', + connection: { + provider: 'Okta SSO', + domain: 'acme.co', + iconUrl: 'https://img.clerk.com/static/okta.svg', + }, + isConnecting, + errorMessage, + onOpenChange: (value: boolean) => { + setOpen(value); + setIsConnecting(false); + setErrorMessage(undefined); + }, + onConnect: () => { + setErrorMessage(undefined); + setIsConnecting(true); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index a11315b5831..5918268e5b9 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,11 +13,13 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { createUserProfileAddEmailFixture } from './user-profile-add-email'; +import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; 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; } @@ -110,6 +112,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const addEmail = (value: string) => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); const pages: UserProfileViewProps['pages'] = { account: { @@ -120,16 +125,12 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions imageUrl, emails, phones, - onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), - onAddPhone: () => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]), + onAddEmail, + onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, + onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, + ...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }), onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, onManagePhone: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index e7a319005b4..74dfc443796 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -5,6 +5,9 @@ import * as Stories from './user-profile-account-section.stories'; Account details, profile image, email addresses, and phone numbers composed with `Section`. The `allowMultipleAccounts` flag controls whether contact methods appear inline or in dedicated sections. +In the multiple-account example, Add phone opens the flow using local state and simulated requests. +Entering or pasting six digits submits automatically. Use `000000` to see an incorrect-code error. + ## Single account + +## Add phone failure + +Add a phone number to see a failed send request while keeping the entered number. + + + +## Email verification error + +This example rejects every verification attempt so the error remains visible and the user can retry. + + + +## Email-link verification + +The profile uses OTP. This separate view displays an email-link verification in progress, with a +resend countdown and Cancel. Its caller supplies the address, pending state, errors, and callbacks. + + + +### Resend error + +After the countdown, resend to see the supplied error message. + + + +## Enterprise SSO verification + +When an email matches an enterprise SSO connection, this view presents the provider and a Connect +action. The option composes `Item` with the shared provider icon, label, description, and button. +The caller supplies the connection, pending state, errors, and callback. In this preview, Connect +simulates completion and closes the dialog. + + + +### Connection error + +Connect to see the supplied error message. The user can retry or cancel. + + 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 200d1667d72..13350807185 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,16 +1,24 @@ +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 type { UserProfileAddPhoneDialogProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; +import { UserProfileVerifyEmailLinkDialog } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; +import { UserProfileVerifyEmailSsoDialog } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; +import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; +import { useUserProfileVerifyEmailSsoFixture } from './fixtures/user-profile-verify-email-sso'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -25,12 +33,16 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, + failAt, failWith, usernameFailWith, + failEmailVerification = false, }: { allowMultipleAccounts: boolean; + failAt?: UserProfileAddPhoneDialogProps['step']; failWith?: UserProfileFormError; usernameFailWith?: UserProfileFormError; + failEmailVerification?: boolean; }) { const editName = useUserProfileEditNameFixture({ failWith }); const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); @@ -44,40 +56,37 @@ function AccountSection({ ); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isVerified: true }] : []), ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); + const addPhone = createUserProfileAddPhoneFixture({ + failAt, + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }); + const emailFlow = createUserProfileAddEmailFixture({ + failAt: failEmailVerification ? 'verify' : undefined, + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } - onAddPhone={() => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...addPhone} + onProfilePictureChange={showFile} + onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} 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} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} /> ); } @@ -90,6 +99,83 @@ 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 + + } + /> + ); +} + +export function EmailSsoVerification() { + const fixture = useUserProfileVerifyEmailSsoFixture(); + return ( + + Verify with SSO + + } + /> + ); +} + +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 ( @@ -114,3 +200,12 @@ export function EditUsernameFails() { /> ); } + +export function AddPhoneFails() { + 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 15f36e0ed77..70f4e27142f 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,8 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; @@ -33,11 +35,15 @@ export function Default(_args: Record) { const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( ) { hasImage={Boolean(imageUrl)} imageUrl={imageUrl} phones={phones} - onAddEmail={() => - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } - onAddPhone={() => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + })} onConnectAccount={() => undefined} onDeleteAccount={() => Promise.resolve()} onManageEmail={() => undefined} @@ -97,8 +90,8 @@ export function Default(_args: Record) { onConnectWeb3Wallet={() => undefined} onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} - onSetPrimaryEmail={() => undefined} - onSetPrimaryPhone={() => undefined} + onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} /> diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.dialog.test.tsx new file mode 100644 index 00000000000..7872e2f83f1 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -0,0 +1,186 @@ +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 { UserProfileAddEmailDialogProps } from '../user-profile-account-section/user-profile-add-email.dialog'; +import { UserProfileAddEmailDialog } from '../user-profile-account-section/user-profile-add-email.dialog'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddEmailDialogProps = { + 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('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' })); + 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' }); + 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-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..28323efeb6d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -0,0 +1,40 @@ +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 user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx new file mode 100644 index 00000000000..85924c65bb3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx @@ -0,0 +1,178 @@ +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 { UserProfileAddPhoneDialogProps } from '../user-profile-account-section/user-profile-add-phone.dialog'; +import { UserProfileAddPhoneDialog } from '../user-profile-account-section/user-profile-add-phone.dialog'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddPhoneDialogProps = { + open: true, + onOpenChange: vi.fn(), + step: 'phone', + phoneNumber: '+18018888181', + onPhoneNumberChange: 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' + phoneNumber='+18018888181' + onPhoneNumberChange={() => undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + onResend={() => undefined} + /> + + ); +} + +describe('UserProfileAddPhoneDialog', () => { + 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 phone field and submits through the form or Send code', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + const phone = screen.getByRole('textbox', { name: 'Phone' }); + await waitFor(() => expect(phone).toHaveFocus()); + const phoneForm = phone.closest('form'); + if (!phoneForm) { + throw new Error('Phone form missing'); + } + expect(phoneForm).toHaveClass('cl-card-content'); + phoneForm.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 phone number' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to +1 (801) 888-8181')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Phone' })).not.toBeInTheDocument(); + const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); + 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(['phone', 'verify'] as const)('associates a %s error with its input', step => { + renderView({ step, errorMessage: 'Please try again.' }); + + const field = screen.getByRole('textbox', { name: step === 'phone' ? 'Phone' : '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-add-phone.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx new file mode 100644 index 00000000000..0cc7edb1827 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -0,0 +1,40 @@ +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 phone', () => { + 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 phone number' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx similarity index 92% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 3676ee01d29..7578a86d811 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileEditNameViewProps } from '../user-profile-account-section/user-profile-edit-name.view'; -import { UserProfileEditNameView } from '../user-profile-account-section/user-profile-edit-name.view'; +import type { UserProfileEditNameDialogProps } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from '../user-profile-account-section/user-profile-edit-name.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditNameViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileEditNameDialogProps = { open: true, onOpenChange: vi.fn(), firstName: 'Preston', @@ -22,7 +22,7 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; @@ -32,7 +32,7 @@ const firstNameField = () => screen.getByLabelText('First name'); const lastNameField = () => screen.getByLabelText('Last name'); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -describe('UserProfileEditNameView', () => { +describe('UserProfileEditNameDialog', () => { it('renders nothing until the caller opens it', () => { renderView({ open: false }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx similarity index 90% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index 1ea123854d3..19d94dc953a 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileEditUsernameViewProps } from '../user-profile-account-section/user-profile-edit-username.view'; -import { UserProfileEditUsernameView } from '../user-profile-account-section/user-profile-edit-username.view'; +import type { UserProfileEditUsernameDialogProps } from '../user-profile-account-section/user-profile-edit-username.dialog'; +import { UserProfileEditUsernameDialog } from '../user-profile-account-section/user-profile-edit-username.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditUsernameViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileEditUsernameDialogProps = { open: true, onOpenChange: vi.fn(), username: 'prestonxyz', @@ -20,7 +20,7 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; @@ -29,7 +29,7 @@ function renderView(overrides: Partial = {}) { const usernameField = () => screen.getByLabelText('Username'); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -describe('UserProfileEditUsernameView', () => { +describe('UserProfileEditUsernameDialog', () => { it('renders nothing until the caller opens it', () => { renderView({ open: false }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx new file mode 100644 index 00000000000..22ecff91d45 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -0,0 +1,83 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view'; + +function renderEmail(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('email actions', () => { + it('returns focus to the email menu after opening with the keyboard and canceling with Escape', async () => { + const user = userEvent.setup(); + const onRemoveEmail = vi.fn(); + renderEmail({ onRemoveEmail }); + const trigger = screen.getByRole('button', { name: 'Manage test@example.com' }); + + trigger.focus(); + await user.keyboard('{Enter}'); + await user.keyboard('{Enter}'); + expect(screen.getByRole('alertdialog', { name: 'Remove email address?' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemoveEmail).not.toHaveBeenCalled(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('returns focus to Add email when the removed email disappears', async () => { + const user = userEvent.setup(); + function Example() { + const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]); + return ( + + Promise.resolve()} + onVerifyEmailCode={() => Promise.resolve()} + onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Manage test@example.com' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add email' })).toHaveFocus()); + }); + + it('shows a primary update error without opening a dialog', async () => { + const user = userEvent.setup(); + const onSetPrimaryEmail = vi.fn().mockRejectedValue(new Error('Unable to update primary email.')); + renderEmail({ onSetPrimaryEmail }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledExactlyOnceWith('email_1'); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary email.'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx new file mode 100644 index 00000000000..1f2c4a7deff --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -0,0 +1,248 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view'; + +function renderPhone(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('phone actions', () => { + it('ignores backdrop clicks and allows Escape to cancel removal', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + const backdrop = document.querySelector('.cl-dialog-backdrop'); + if (!backdrop) { + throw new Error('Expected a dialog backdrop'); + } + await user.click(backdrop); + expect(dialog).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + }); + + it('closes confirmation before deletion finishes and prevents duplicate requests', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onRemovePhone = vi.fn(() => pending); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog'); + const remove = within(dialog).getByRole('button', { name: 'Remove' }); + await user.click(remove); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(onRemovePhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('hides set primary while an update is pending', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onSetPrimaryPhone = vi.fn(() => pending); + renderPhone({ onSetPrimaryPhone, onRemovePhone: vi.fn() }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + expect(onSetPrimaryPhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument()); + }); + it.each([{ isDefault: true, isVerified: true }, { isDefault: false, isVerified: false }, { isDefault: false }])( + 'hides set primary for an ineligible phone: %j', + async flags => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + }, + ); + + it('updates the primary badge immediately without confirmation', async () => { + const user = userEvent.setup(); + function Example() { + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+18015550100', isVerified: true, isDefault: false }, + ]); + return ( + + + setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))) + } + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(screen.getByText('Primary')).toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); + }); + + it('cancels removal without calling the mutation', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })).toHaveFocus(); + }); + + it('returns focus to the phone menu after opening with the keyboard and canceling with Escape', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + const trigger = screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' }); + + trigger.focus(); + await user.keyboard('{Enter}'); + await user.keyboard('{Enter}'); + expect(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('returns focus to Add phone number when the removed phone disappears', async () => { + const user = userEvent.setup(); + function Example() { + const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]); + return ( + + Promise.resolve()} + onVerifyPhoneCode={() => Promise.resolve()} + onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add phone number' })).toHaveFocus()); + }); + + it('shows a failed removal in the account section and allows retry from the menu', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi + .fn() + .mockRejectedValueOnce(new Error('Cannot remove this phone.')) + .mockResolvedValue(undefined); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Cannot remove this phone.'); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).toHaveBeenCalledTimes(2); + }); + + it('does not offer removal when it is forbidden', async () => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isVerified: true, canRemove: false }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); + }); + it('shows a primary update error without opening a dialog', async () => { + const user = userEvent.setup(); + const onSetPrimaryPhone = vi.fn().mockRejectedValue(new Error('Unable to update primary phone.')); + renderPhone({ onSetPrimaryPhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledExactlyOnceWith('phone_1'); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + it('requires confirmation before removing a phone number', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).not.toHaveBeenCalled(); + const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + expect(dialog).toHaveTextContent('+1 (801) 555-0100'); + expect(within(dialog).queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove' })); + expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1'); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx new file mode 100644 index 00000000000..6fac24fcc7b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.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 { UserProfilePictureRowViewProps } from '../user-profile-account-section/user-profile-picture-row.view'; +import { UserProfilePictureRowView } from '../user-profile-account-section/user-profile-picture-row.view'; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfilePictureRowView', () => { + it('renders and clears the error supplied by the section', () => { + const { rerender } = renderView({ errorMessage: 'File size exceeds the maximum limit of 10MB.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + + rerender( + + + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('offers Upload while the avatar is only a generated default', () => { + renderView({ + hasImage: false, + imageUrl: 'https://img.clerk.com/generated-default.png', + onChange: vi.fn(), + onRemove: vi.fn(), + }); + + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); + }); + + it('offers change and remove in a menu once a profile picture is set', async () => { + const onChange = vi.fn(); + const onRemove = vi.fn(); + const user = userEvent.setup(); + renderView({ + hasImage: true, + imageUrl: 'https://example.com/avatar.png', + onChange, + onRemove, + }); + + expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + + expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + expect(onRemove).toHaveBeenCalledOnce(); + }); +}); 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 391198f30e5..79045328620 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -29,6 +29,19 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { + renderView({ + allowMultipleAccounts, + phones: [{ id: 'phone_added', value: '+18015558181' }], + onManagePhone: vi.fn(), + }); + + expect(screen.getByText('+1 (801) 555-8181')).toBeInTheDocument(); + if (allowMultipleAccounts) { + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-8181' })).toBeInTheDocument(); + } + }); + it('composes the profile content without profile navigation', () => { renderView({ onProfilePictureChange: vi.fn(), @@ -49,7 +62,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); - expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('+1 (801) 888-8181')).toBeInTheDocument(); expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); expect(screen.getByText('Email')).toHaveClass('cl-section-label'); @@ -71,9 +84,11 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); const input = container.querySelector('input[type="file"]'); - expect(input).not.toBeNull(); + if (!input) { + throw new Error('File picker not found'); + } const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); - await user.upload(input as HTMLInputElement, file); + await user.upload(input, file); expect(onProfilePictureChange).toHaveBeenCalledWith(file); }); @@ -85,7 +100,11 @@ describe('UserProfileProfilePanelView', () => { const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); const oversized = new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' }); - await user.upload(container.querySelector('input[type="file"]') as HTMLInputElement, oversized); + const input = container.querySelector('input[type="file"]'); + if (!input) { + throw new Error('File picker not found'); + } + await user.upload(input, oversized); expect(onProfilePictureChange).not.toHaveBeenCalled(); expect(onProfilePictureReject).toHaveBeenCalledWith([{ file: oversized, reason: 'size' }]); @@ -96,7 +115,10 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange: vi.fn() }); - const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const input = container.querySelector('input[type="file"]'); + if (!input) { + throw new Error('File picker not found'); + } await user.upload(input, new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' })); expect(screen.getByRole('alert')).toBeInTheDocument(); @@ -105,43 +127,12 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('alert')).toBeNull(); }); - it('offers Upload while the avatar is only a generated default', () => { - renderView({ - hasImage: false, - imageUrl: 'https://img.clerk.com/generated-default.png', - onProfilePictureChange: vi.fn(), - onRemoveProfilePicture: vi.fn(), - }); - - expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); - }); - - it('offers change and remove in a menu once a profile picture is set', async () => { - const onProfilePictureChange = vi.fn(); - const onRemoveProfilePicture = vi.fn(); - const user = userEvent.setup(); - renderView({ - hasImage: true, - imageUrl: 'https://example.com/avatar.png', - onProfilePictureChange, - onRemoveProfilePicture, - }); - - expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); - - expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); - await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - - expect(onRemoveProfilePicture).toHaveBeenCalledOnce(); - }); - it('breaks out both contact types when multiple accounts are allowed', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], onAddEmail: vi.fn(), - onAddPhone: vi.fn(), + onSendPhoneCode: () => Promise.resolve(), + onVerifyPhoneCode: () => Promise.resolve(), }); const accountSection = screen.getByRole('region', { name: 'Account' }); @@ -151,7 +142,7 @@ describe('UserProfileProfilePanelView', () => { expect(accountSection).not.toContainElement(emailSection); expect(accountSection).not.toContainElement(phoneSection); expect(emailSection).toHaveTextContent('item1@clerk.dev'); - expect(phoneSection).toHaveTextContent('+1 801-888-8181'); + expect(phoneSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); }); @@ -167,7 +158,7 @@ describe('UserProfileProfilePanelView', () => { const accountSection = screen.getByRole('region', { name: 'Account' }); expect(accountSection).toHaveTextContent('item1@clerk.dev'); - expect(accountSection).toHaveTextContent('+1 801-888-8181'); + expect(accountSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(accountSection).getByRole('button', { name: 'Update email' })).toBeInTheDocument(); expect(within(accountSection).getByRole('button', { name: 'Update phone number' })).toBeInTheDocument(); expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); @@ -194,7 +185,7 @@ describe('UserProfileProfilePanelView', () => { }); it('renders an actionable empty state when no phone number exists', () => { - renderView({ phones: [], onAddPhone: vi.fn() }); + renderView({ phones: [], onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve() }); const phoneSection = screen.getByRole('region', { name: 'Phone' }); const emptyState = within(phoneSection).getByText('No phone numbers added'); @@ -431,21 +422,33 @@ 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' })); await user.click(screen.getByRole('menuitem', { name: 'Verify' })); expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0101' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx new file mode 100644 index 00000000000..05a353e750d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.dialog.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 { UserProfileVerifyEmailLinkDialogProps } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; +import { UserProfileVerifyEmailLinkDialog } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailLinkDialogProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailLinkDialog', () => { + 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/__tests__/user-profile-verify-email-sso.dialog.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx new file mode 100644 index 00000000000..dfc5be099e6 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.dialog.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 { UserProfileVerifyEmailSsoDialogProps } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; +import { UserProfileVerifyEmailSsoDialog } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailSsoDialogProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + connection: { provider: 'Okta SSO', domain: 'acme.co' }, + onConnect: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailSsoDialog', () => { + 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-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index e3bd883524b..6d9ceab55dc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -60,6 +60,14 @@ export const userProfileAccountSectionBase = { add: 'Add email', verify: 'Verify', remove: 'Remove email', + 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', @@ -68,6 +76,14 @@ export const userProfileAccountSectionBase = { add: 'Add phone number', verify: 'Verify phone number', remove: 'Remove phone number', + primaryError: 'Unable to set the primary phone number. Try again.', + removeError: 'Unable to remove this phone number. Try again.', + removeDialog: { + title: 'Remove phone number?', + description: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', + confirm: 'Remove', + cancel: 'Cancel', + }, }, }; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts new file mode 100644 index 00000000000..e171b342c3a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts @@ -0,0 +1,12 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts index ba5d37b4a05..16f56db158d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -24,3 +24,19 @@ export class UserProfileSaveError extends Error this.fields = fields; } } + +export interface UserProfilePhone { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export interface UserProfileEmail { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 2a239bb7e55..c19e81b21d5 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,43 +1,22 @@ -import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; -import { FileUpload } from '@clerk/headless/file-upload'; +import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; -import { Avatar } from '../../components/avatar'; -import { Badge } from '../../components/badge'; -import { Button } from '../../components/button'; -import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; -import type { UserProfileMenuAction } from '../user-profile-action-menu'; -import { UserProfileActionMenu } from '../user-profile-action-menu'; -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'; -import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; -import { UserProfileEditNameView } from './user-profile-edit-name.view'; -import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; -import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; - -const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; -/** Matches the limit the row's own description advertises. */ -const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; - -export interface UserProfileEmail { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} - -export interface UserProfilePhone { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { styles } from './user-profile-account-section.styles'; +import type { + UserProfileEmail, + UserProfileNameAttribute, + UserProfilePhone, +} from './user-profile-account-section.types'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { UserProfileEmailRowView } from './user-profile-email-row.view'; +import { UserProfileNameRowView } from './user-profile-name-row.view'; +import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; +import { UserProfilePictureRowView } from './user-profile-picture-row.view'; +import { UserProfileUsernameRowView } from './user-profile-username-row.view'; + +export type { UserProfileEmail, UserProfilePhone } from './user-profile-account-section.types'; export interface UserProfileAccountSectionViewProps { allowMultipleAccounts?: boolean; @@ -63,15 +42,18 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; + onSendEmailCode?: (emailAddress: string) => Promise; + onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void; - onRemoveEmail?: (id: string) => void; - onAddPhone?: () => void; + onSetPrimaryEmail?: (id: string) => void | Promise; + onRemoveEmail?: (id: string) => void | Promise; + onSendPhoneCode?: (phoneNumber: string) => Promise; + onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void; - onRemovePhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void | Promise; + onRemovePhone?: (id: string) => void | Promise; } export function UserProfileAccountSectionView({ @@ -92,396 +74,84 @@ export function UserProfileAccountSectionView({ onSubmitName, onSubmitUsername, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onAddPhone, + onSendPhoneCode, + onVerifyPhoneCode, onManagePhone, onVerifyPhone, onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { - const initials = name - .split(/\s+/) - .map(part => part[0]) - .join('') - .slice(0, 2) - .toUpperCase(); - const [rejection, setRejection] = useState(null); + const phoneRow = ( + + ); + const emailRow = ( + + ); return ( - } - onReject={rejections => { - setRejection(rejections[0]?.reason ?? null); - onProfilePictureReject?.(rejections); - }} - onValueChange={files => { - const file = files[0]; - if (file) { - setRejection(null); - onProfilePictureChange?.(file); - } - }} - > +
{m.sectionTitle} - - - - - - {initials} - - - - {m.picture.label} - {m.picture.description} - - - - {rejection ? {m.picture.errors[rejection]} : null} - - - - - {m.name.label} - {name} - - {onSubmitName ? ( - - - - ) : null} - - - - - - {m.username.label} - {username} - - {onSubmitUsername ? ( - - - - ) : null} - - - {!allowMultipleAccounts ? ( - - ) : null} - {!allowMultipleAccounts ? ( - - ) : null} + + + + {!allowMultipleAccounts ? emailRow : null} + {!allowMultipleAccounts ? phoneRow : null} {allowMultipleAccounts ? ( - + + {emailRow} + ) : null} {allowMultipleAccounts ? ( - + + {phoneRow} + ) : null} - - ); -} - -function ProfilePictureActions({ - hasImage, - canChange, - onRemove, -}: { - hasImage: boolean; - canChange: boolean; - onRemove?: () => void; -}) { - const { openFilePicker } = FileUpload.useFileUpload(); - const actions: UserProfileMenuAction[] = []; - - if (hasImage && canChange) { - actions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); - } - - if (hasImage && onRemove) { - actions.push({ label: m.picture.remove, icon: 'close', onClick: onRemove }); - } - - if (actions.length > 0) { - return ( - - - - ); - } - - if (!hasImage && canChange) { - return ( - - - } - > - {m.picture.upload} - - - ); - } - - return null; -} - -function EditName({ - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - onSubmit, -}: { - firstName?: string; - lastName?: string; - firstNameAttribute?: UserProfileNameAttribute; - lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise; -}) { - const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); - - return ( - - {m.name.edit} - - } - /> - ); -} - -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { - const controller = useUserProfileEditUsernameController({ username, onSubmit }); - - return ( - - {m.username.edit} - - } - /> - ); -} - -interface ContactSectionProps { - kind: 'email' | 'phone'; - label: string; - items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; - onAdd?: () => void; - onManage?: (id: string) => void; - onVerify?: (id: string) => void; - onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; -} - -function ContactSection(props: ContactSectionProps) { - return ( - - - - - - ); -} - -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { - const item = items[0]; - const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; - const emptyDescription = m[kind].empty; - const actionLabel = item ? m[kind].update : m[kind].add; - - return ( - - - - {label} - {item ? ( - - {item.value} - {item.isDefault ? {m.primary} : null} - - ) : ( - {emptyDescription} - )} - - {onClick ? ( - - - - ) : null} - - - ); -} - -function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimary, onRemove }: ContactSectionProps) { - const emptyDescription = m[kind].empty; - - return ( - - - - {label} - - {onAdd ? ( - - - - ) : null} - - - {items.length === 0 ? ( - - - {emptyDescription} - - - ) : ( - items.map(item => { - const actions: UserProfileMenuAction[] = []; - const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); - - if (item.isVerified === false && onVerify) { - actions.push({ - label: item.isDefault ? m.completeVerification : m[kind].verify, - onClick: () => onVerify(item.id), - }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { - actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); - } - - if (onRemove && item.canRemove !== false) { - actions.push({ - label: m[kind].remove, - color: 'negative', - onClick: () => onRemove(item.id), - }); - } - - if (!hasExplicitActions && onManage) { - actions.push({ label: m.manage, onClick: () => onManage(item.id) }); - } - - return ( - - - - {item.value} - {item.isDefault ? {m.primary} : null} - - - {actions.length > 0 ? ( - - - - ) : null} - - ); - }) - )} - - +
); } diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts new file mode 100644 index 00000000000..7e27db78e7a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; + +describe('useUserProfileAddEmailController', () => { + afterEach(() => vi.useRealTimers()); + + it('keeps the resend countdown running while verification is pending', async () => { + vi.useFakeTimers(); + const verification = Promise.withResolvers(); + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onSend: () => Promise.resolve(), + onVerify: () => verification.promise, + }), + ); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + act(() => result.current.onSubmit('123456')); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + await act(async () => { + verification.reject(new Error('Incorrect code')); + await Promise.resolve(); + }); + expect(result.current.errorMessage).toBe('Incorrect code'); + expect(result.current.resendSeconds).toBe(0); + }); + + it('starts with the supplied email address', () => { + const { result } = renderHook(() => + useUserProfileAddEmailController({ + initialEmailAddress: 'saved@example.com', + onSend: () => Promise.resolve(), + onVerify: () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + expect(result.current.emailAddress).toBe('saved@example.com'); + }); + + it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { + const request = Promise.withResolvers(); + const onSend = vi.fn(() => request.promise); + const { result } = renderHook(() => + useUserProfileAddEmailController({ onSend, onVerify: () => Promise.resolve() }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => { + result.current.onSubmit(); + result.current.onSubmit(); + result.current.onEmailAddressChange('other@example.com'); + result.current.onOpenChange(false); + }); + expect(result.current.open).toBe(true); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + await act(async () => { + request.resolve(); + await request.promise; + }); + act(() => result.current.onCodeChange('123')); + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + expect(result.current.step).toBe('email'); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(0); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { + vi.useFakeTimers(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + expect(result.current.resendSeconds).toBe(12); + act(() => result.current.onResend()); + expect(onSend).toHaveBeenCalledTimes(1); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + act(() => result.current.onCodeChange('123')); + await act(async () => { + result.current.onResend(); + result.current.onResend(); + result.current.onSubmit('123456'); + result.current.onOpenChange(false); + await Promise.resolve(); + }); + expect(onSend).toHaveBeenCalledTimes(2); + expect(onVerify).not.toHaveBeenCalled(); + expect(result.current.open).toBe(true); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(12); + }); + it.each(['email', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { + const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onSend: step === 'email' ? operation : () => Promise.resolve(), + onVerify: step === 'verify' ? operation : () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + if (step === 'verify') { + await waitFor(() => expect(result.current.step).toBe('verify')); + act(() => result.current.onSubmit('000000')); + } + await waitFor(() => expect(result.current.errorMessage).toBe('Try again')); + expect(result.current.isPending).toBe(false); + expect(result.current.step).toBe(step); + expect(result.current.emailAddress).toBe('new@example.com'); + if (step === 'verify') { + expect(result.current.code).toBe('000000'); + } + act(() => result.current.onSubmit()); + await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); + }); + it('sends a code, verifies the submitted code, and closes on success', async () => { + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + expect(result.current.isPending).toBe(true); + expect(result.current.open).toBe(true); + await waitFor(() => expect(result.current.step).toBe('verify')); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts new file mode 100644 index 00000000000..88fbb94be19 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.controller.ts @@ -0,0 +1,159 @@ +import { useEffect } from 'react'; + +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; +import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; +import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; + +export interface UserProfileAddEmailControllerOptions { + initialEmailAddress?: string; + onSend: (emailAddress: string) => Promise; + onVerify: (emailAddress: string, code: string) => Promise; +} + +interface Context extends UserProfileAddEmailControllerOptions { + emailAddress: string; + code: string; + errorMessage: string | undefined; + resendSeconds: number; +} + +type Event = + | { type: 'OPEN' } + | { type: 'CANCEL' } + | { type: 'RESEND' } + | { type: 'TICK' } + | { type: 'TYPE_EMAIL'; value: string } + | { type: 'TYPE_CODE'; value: string } + | { type: 'SUBMIT'; code?: string }; + +const { createMachine, assign, fromPromise } = setup(); + +function missingDependency(): Promise { + return Promise.reject(new Error('Add email callbacks are missing')); +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : m.error; +} + +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; + +const machine = createMachine({ + id: 'addEmail', + initial: 'idle', + context: { + onSend: missingDependency, + onVerify: missingDependency, + emailAddress: '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + }, + states: { + idle: { + on: { + OPEN: { + target: 'email', + actions: assign(context => ({ + emailAddress: context.initialEmailAddress ?? '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + })), + }, + }, + }, + email: { + on: { + CANCEL: 'idle', + TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, errorMessage: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + }, + }, + sending: { + invoke: fromPromise(context => context.onSend(context.emailAddress), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'email', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verify: { + on: { + CANCEL: 'idle', + TICK: tick, + RESEND: { + target: 'resending', + guard: context => context.resendSeconds === 0, + actions: assign(() => ({ errorMessage: undefined })), + }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + SUBMIT: { + target: 'verifying', + actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + }, + }, + }, + resending: { + invoke: fromPromise(context => context.onSend(context.emailAddress), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verifying: { + on: { TICK: tick }, + invoke: fromPromise(context => context.onVerify(context.emailAddress, context.code), { + onDone: 'idle', + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + }, +}); + +export function useUserProfileAddEmailController( + options: UserProfileAddEmailControllerOptions, +): UserProfileAddEmailDialogProps { + const [snapshot, send] = useMachine(machine, { context: options }); + const { resendSeconds } = snapshot.context; + const open = snapshot.value !== 'idle'; + useEffect(() => { + if (!open || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => send({ type: 'TICK' }), 1000); + return () => clearTimeout(timer); + }, [open, resendSeconds, send]); + + return { + resendSeconds, + isResending: snapshot.value === 'resending', + open, + step: + snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' + ? 'verify' + : 'email', + emailAddress: snapshot.context.emailAddress, + code: snapshot.context.code, + errorMessage: snapshot.context.errorMessage, + isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + onEmailAddressChange: value => send({ type: 'TYPE_EMAIL', value }), + onCodeChange: value => send({ type: 'TYPE_CODE', value }), + onSubmit: code => send({ type: 'SUBMIT', code }), + onResend: () => send({ type: 'RESEND' }), + }; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx new file mode 100644 index 00000000000..1fea0bfd249 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx @@ -0,0 +1,190 @@ +import type { FormEvent } from 'react'; +import { useId, useRef } from 'react'; + +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Flow } from '../../components/flow'; +import { Input } from '../../components/input'; +import { Otp } from '../../components/otp'; +import { fill } from './user-profile-account-section.messages'; +import { userProfileAddEmailMessages as m } from './user-profile-add-email.messages'; + +export interface UserProfileAddEmailDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + step: 'email' | 'verify'; + emailAddress: string; + onEmailAddressChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { + const emailFormId = useId(); + const verifyFormId = useId(); + const emailRef = useRef(null); + const verifyRef = useRef(null); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!props.isPending && !props.isResending) { + props.onSubmit(); + } + }; + + return ( + + {props.trigger ? : null} + + emailRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true + } + > + + + {current => ( + <> + + + {m.email.title} + {m.email.description} + + + } + > + + {m.email.label} + current.onEmailAddressChange(event.target.value)} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + {m.email.submit} + + + + + + {m.verify.title} + + {fill(m.verify.description, { emailAddress: current.emailAddress })} + + + + } + > + + {m.verify.label} + { + if (!current.isPending && !current.isResending) { + current.onSubmit(code); + } + }} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.messages.ts new file mode 100644 index 00000000000..cfcad0728a0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-email.messages.ts @@ -0,0 +1,21 @@ +export const userProfileAddEmailMessages = { + error: 'Something went wrong. Please try again.', + email: { + title: 'Add email', + description: 'We’ll send you a code to verify this email address.', + label: 'Email', + submit: 'Send code', + pending: 'Sending code', + }, + verify: { + title: 'Verify your email', + description: 'Enter the code sent to {emailAddress}', + label: 'Verification code', + submit: 'Verify', + pending: 'Verifying', + cancel: 'Cancel', + resend: 'Didn’t receive a code? Resend', + resending: 'Sending a new code…', + resendCountdown: 'Didn’t receive a code? Resend ({seconds})', + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts new file mode 100644 index 00000000000..571fb00a042 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.test.ts @@ -0,0 +1,153 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; + +describe('useUserProfileAddPhoneController', () => { + afterEach(() => vi.useRealTimers()); + + it('keeps the resend countdown running while verification is pending', async () => { + vi.useFakeTimers(); + const verification = Promise.withResolvers(); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + onSend: () => Promise.resolve(), + onVerify: () => verification.promise, + }), + ); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + act(() => result.current.onSubmit('123456')); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + await act(async () => { + verification.reject(new Error('Incorrect code')); + await Promise.resolve(); + }); + expect(result.current.errorMessage).toBe('Incorrect code'); + expect(result.current.resendSeconds).toBe(0); + }); + + it('starts with the supplied phone number', () => { + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + initialPhoneNumber: '+18015558181', + onSend: () => Promise.resolve(), + onVerify: () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + expect(result.current.phoneNumber).toBe('+18015558181'); + }); + + it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { + const request = Promise.withResolvers(); + const onSend = vi.fn(() => request.promise); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ onSend, onVerify: () => Promise.resolve() }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => { + result.current.onSubmit(); + result.current.onSubmit(); + result.current.onPhoneNumberChange('+18015550200'); + result.current.onOpenChange(false); + }); + expect(result.current.open).toBe(true); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + await act(async () => { + request.resolve(); + await request.promise; + }); + act(() => result.current.onCodeChange('123')); + act(() => result.current.onOpenChange(false)); + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + expect(result.current.step).toBe('phone'); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(0); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { + vi.useFakeTimers(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); + act(() => result.current.onOpenChange(true)); + await act(async () => { + result.current.onSubmit(); + await Promise.resolve(); + }); + expect(result.current.resendSeconds).toBe(12); + act(() => result.current.onResend()); + expect(onSend).toHaveBeenCalledTimes(1); + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } + expect(result.current.resendSeconds).toBe(0); + act(() => result.current.onCodeChange('123')); + await act(async () => { + result.current.onResend(); + result.current.onResend(); + result.current.onSubmit('123456'); + result.current.onOpenChange(false); + await Promise.resolve(); + }); + expect(onSend).toHaveBeenCalledTimes(2); + expect(onVerify).not.toHaveBeenCalled(); + expect(result.current.open).toBe(true); + expect(result.current.code).toBe(''); + expect(result.current.resendSeconds).toBe(12); + }); + it.each(['phone', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { + const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); + const { result } = renderHook(() => + useUserProfileAddPhoneController({ + onSend: step === 'phone' ? operation : () => Promise.resolve(), + onVerify: step === 'verify' ? operation : () => Promise.resolve(), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => result.current.onSubmit()); + if (step === 'verify') { + await waitFor(() => expect(result.current.step).toBe('verify')); + act(() => result.current.onSubmit('000000')); + } + await waitFor(() => expect(result.current.errorMessage).toBe('Try again')); + expect(result.current.isPending).toBe(false); + expect(result.current.step).toBe(step); + expect(result.current.phoneNumber).toBe('+18015550100'); + if (step === 'verify') { + expect(result.current.code).toBe('000000'); + } + act(() => result.current.onSubmit()); + await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); + }); + it('sends a code, verifies the submitted code, and closes on success', async () => { + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); + + expect(result.current.open).toBe(false); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + act(() => result.current.onSubmit()); + expect(result.current.isPending).toBe(true); + expect(result.current.open).toBe(true); + await waitFor(() => expect(result.current.step).toBe('verify')); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts new file mode 100644 index 00000000000..dc1eb1b31c8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts @@ -0,0 +1,159 @@ +import { useEffect } from 'react'; + +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/useMachine'; +import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog'; +import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; + +export interface UserProfileAddPhoneControllerOptions { + initialPhoneNumber?: string; + onSend: (phoneNumber: string) => Promise; + onVerify: (phoneNumber: string, code: string) => Promise; +} + +interface Context extends UserProfileAddPhoneControllerOptions { + phoneNumber: string; + code: string; + errorMessage: string | undefined; + resendSeconds: number; +} + +type Event = + | { type: 'OPEN' } + | { type: 'CANCEL' } + | { type: 'RESEND' } + | { type: 'TICK' } + | { type: 'TYPE_PHONE'; value: string } + | { type: 'TYPE_CODE'; value: string } + | { type: 'SUBMIT'; code?: string }; + +const { createMachine, assign, fromPromise } = setup(); + +function missingDependency(): Promise { + return Promise.reject(new Error('Add phone callbacks are missing')); +} + +function errorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : m.error; +} + +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; + +const machine = createMachine({ + id: 'addPhone', + initial: 'idle', + context: { + onSend: missingDependency, + onVerify: missingDependency, + phoneNumber: '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + }, + states: { + idle: { + on: { + OPEN: { + target: 'phone', + actions: assign(context => ({ + phoneNumber: context.initialPhoneNumber ?? '', + code: '', + errorMessage: undefined, + resendSeconds: 0, + })), + }, + }, + }, + phone: { + on: { + CANCEL: 'idle', + TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, errorMessage: undefined })) }, + SUBMIT: { target: 'sending', actions: assign(() => ({ errorMessage: undefined })) }, + }, + }, + sending: { + invoke: fromPromise(context => context.onSend(context.phoneNumber), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'phone', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verify: { + on: { + CANCEL: 'idle', + TICK: tick, + RESEND: { + target: 'resending', + guard: context => context.resendSeconds === 0, + actions: assign(() => ({ errorMessage: undefined })), + }, + TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, errorMessage: undefined })) }, + SUBMIT: { + target: 'verifying', + actions: assign((context, event) => ({ code: event.code ?? context.code, errorMessage: undefined })), + }, + }, + }, + resending: { + invoke: fromPromise(context => context.onSend(context.phoneNumber), { + onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + verifying: { + on: { TICK: tick }, + invoke: fromPromise(context => context.onVerify(context.phoneNumber, context.code), { + onDone: 'idle', + onError: { + target: 'verify', + actions: assign((_, event) => ({ + errorMessage: errorMessage(event.error), + })), + }, + }), + }, + }, +}); + +export function useUserProfileAddPhoneController( + options: UserProfileAddPhoneControllerOptions, +): UserProfileAddPhoneDialogProps { + const [snapshot, send] = useMachine(machine, { context: options }); + const { resendSeconds } = snapshot.context; + const open = snapshot.value !== 'idle'; + useEffect(() => { + if (!open || resendSeconds === 0) { + return; + } + const timer = setTimeout(() => send({ type: 'TICK' }), 1000); + return () => clearTimeout(timer); + }, [open, resendSeconds, send]); + + return { + resendSeconds, + isResending: snapshot.value === 'resending', + open, + step: + snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' + ? 'verify' + : 'phone', + phoneNumber: snapshot.context.phoneNumber, + code: snapshot.context.code, + errorMessage: snapshot.context.errorMessage, + isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }), + onCodeChange: value => send({ type: 'TYPE_CODE', value }), + onSubmit: code => send({ type: 'SUBMIT', code }), + onResend: () => send({ type: 'RESEND' }), + }; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx new file mode 100644 index 00000000000..0f420924e03 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx @@ -0,0 +1,189 @@ +import type { FormEvent } from 'react'; +import { useId, useRef } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Flow } from '../../components/flow'; +import { Otp } from '../../components/otp'; +import { PhoneInput } from '../../components/phone-input'; +import { fill } from './user-profile-account-section.messages'; +import { userProfileAddPhoneMessages as m } from './user-profile-add-phone.messages'; + +export interface UserProfileAddPhoneDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + step: 'phone' | 'verify'; + phoneNumber: string; + onPhoneNumberChange: (value: string) => void; + code: string; + onCodeChange: (value: string) => void; + onSubmit: (code?: string) => void; + onResend: () => void; + isPending?: boolean; + errorMessage?: string; + isResending?: boolean; + resendSeconds?: number; +} + +export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) { + const phoneFormId = useId(); + const verifyFormId = useId(); + const phoneRef = useRef(null); + const verifyRef = useRef(null); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (!props.isPending && !props.isResending) { + props.onSubmit(); + } + }; + + return ( + + {props.trigger ? : null} + + phoneRef.current ?? verifyRef.current?.querySelector('input:not([type="hidden"])') ?? true + } + > + + + {current => ( + <> + + + {m.phone.title} + {m.phone.description} + + + } + > + + {m.phone.label} + + {current.errorMessage ? {current.errorMessage} : null} + + + + + {m.phone.submit} + + + + + + {m.verify.title} + + {fill(m.verify.description, { phoneNumber: stringToFormattedPhoneString(current.phoneNumber) })} + + + + } + > + + {m.verify.label} + { + if (!current.isPending && !current.isResending) { + current.onSubmit(code); + } + }} + /> + {current.errorMessage ? {current.errorMessage} : null} + + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts new file mode 100644 index 00000000000..d9883904dff --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts @@ -0,0 +1,21 @@ +export const userProfileAddPhoneMessages = { + error: 'Something went wrong. Please try again.', + phone: { + title: 'Add phone number', + description: 'We’ll send you a text to verify this phone number. Message and data rates may apply.', + label: 'Phone', + submit: 'Send code', + pending: 'Sending code', + }, + verify: { + title: 'Verify your phone number', + description: 'Enter the code sent to {phoneNumber}', + label: 'Verification code', + submit: 'Verify', + pending: 'Verifying', + cancel: 'Cancel', + resend: 'Didn’t receive a code? Resend', + resending: 'Sending a new code…', + resendCountdown: 'Didn’t receive a code? Resend ({seconds})', + }, +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx new file mode 100644 index 00000000000..574eb406385 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx @@ -0,0 +1,125 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from '../user-profile-action-menu'; +import { UserProfileActionMenu } from '../user-profile-action-menu'; +import { styles } from '../user-profile-profile-panel.styles'; +import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileContactListRowViewProps { + addAction?: ReactNode; + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + onVerify?: (id: string) => void; + onSetPrimary?: (id: string) => void; + onRemove?: (id: string) => void; + renderActionDialog?: (item: { id: string; value: string }) => ReactNode; +} + +export function UserProfileContactListRowView({ + kind, + label, + items, + onAdd, + onManage, + onVerify, + onSetPrimary, + onRemove, + addAction, + renderActionDialog, +}: UserProfileContactListRowViewProps) { + const emptyDescription = m[kind].empty; + + return ( + + + + {label} + + {addAction ? ( + {addAction} + ) : onAdd ? ( + + + + ) : null} + + + {items.length === 0 ? ( + + + {emptyDescription} + + + ) : ( + items.map(item => { + const actions: UserProfileMenuAction[] = []; + const hasExplicitActions = Boolean(onVerify || onSetPrimary || onRemove); + + if (item.isVerified === false && onVerify) { + actions.push({ + label: item.isDefault ? m.completeVerification : m[kind].verify, + onClick: () => onVerify(item.id), + }); + } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); + } + + if (onRemove && item.canRemove !== false) { + actions.push({ + label: m[kind].remove, + color: 'negative', + onClick: () => onRemove(item.id), + }); + } + + if (!hasExplicitActions && onManage) { + actions.push({ label: m.manage, onClick: () => onManage(item.id) }); + } + + return ( + + + + {item.value} + {item.isDefault ? {m.primary} : null} + + + {actions.length > 0 ? ( + + + {renderActionDialog?.(item)} + + + ) : null} + + ); + }) + )} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx new file mode 100644 index 00000000000..6ab1783ae3d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx @@ -0,0 +1,63 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; + +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileContactRowViewProps { + kind: 'email' | 'phone'; + label: string; + items: Array<{ id: string; value: string; isDefault?: boolean }>; + onAdd?: () => void; + onManage?: (id: string) => void; + addAction?: ReactNode; +} + +export function UserProfileContactRowView({ + kind, + label, + items, + onAdd, + onManage, + addAction, +}: UserProfileContactRowViewProps) { + const item = items[0]; + const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; + const emptyDescription = m[kind].empty; + const actionLabel = item ? m[kind].update : m[kind].add; + + return ( + + + + {label} + {item ? ( + + {item.value} + {item.isDefault ? {m.primary} : null} + + ) : ( + {emptyDescription} + )} + + {!item && addAction ? ( + {addAction} + ) : onClick ? ( + + + + ) : null} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 7b521029cf6..3a2af00f2e6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -2,7 +2,7 @@ import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/useMachine'; import type { UserProfileFormError } from './user-profile-account-section.types'; import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.view'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { saveName: (value: UserProfileEditNameValue) => Promise; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx similarity index 97% rename from packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 3c917184f57..46f17bfd989 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -18,7 +18,7 @@ export interface UserProfileEditNameValue { lastName: string; } -export interface UserProfileEditNameViewProps { +export interface UserProfileEditNameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Rendering the opener here is what returns focus to it on close. */ @@ -40,7 +40,7 @@ export interface UserProfileEditNameViewProps { * `required` the instance asks for: the name the API will take is the API's to decide, so the action * stays live and a rejection comes back as `error`. */ -export function UserProfileEditNameView({ +export function UserProfileEditNameDialog({ open, onOpenChange, trigger, @@ -53,7 +53,7 @@ export function UserProfileEditNameView({ isSaving = false, error, onSubmit, -}: UserProfileEditNameViewProps) { +}: UserProfileEditNameDialogProps) { const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index a9cd97a4797..e17d1b74a9b 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -2,7 +2,7 @@ import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/useMachine'; import type { UserProfileFormError } from './user-profile-account-section.types'; import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.view'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { saveUsername: (username: string) => Promise; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx similarity index 95% rename from packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 28382a0dc9c..ea3372e4082 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -13,7 +13,7 @@ import type { UserProfileFormError } from './user-profile-account-section.types' export type UserProfileEditUsernameField = 'username'; -export interface UserProfileEditUsernameViewProps { +export interface UserProfileEditUsernameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -25,7 +25,7 @@ export interface UserProfileEditUsernameViewProps { onSubmit: () => void; } -export function UserProfileEditUsernameView({ +export function UserProfileEditUsernameDialog({ open, onOpenChange, trigger, @@ -35,7 +35,7 @@ export function UserProfileEditUsernameView({ isSaving = false, error, onSubmit, -}: UserProfileEditUsernameViewProps) { +}: UserProfileEditUsernameDialogProps) { const formId = useId(); const usernameRef = useRef(null); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx new file mode 100644 index 00000000000..6733b910cf1 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-email-row.view.tsx @@ -0,0 +1,212 @@ +import type { Ref } from 'react'; +import { useRef, useState } from 'react'; + +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfileEmail } from './user-profile-account-section.types'; +import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; +import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; +import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; +import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { UserProfileRemoveEmailDialog } from './user-profile-remove-email.dialog'; + +export interface UserProfileEmailRowViewProps { + emails: UserProfileEmail[]; + allowMultipleAccounts?: boolean; + onAddEmail?: () => void; + onSendEmailCode?: (emailAddress: string) => Promise; + onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onManageEmail?: (id: string) => void; + onVerifyEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void | Promise; + onRemoveEmail?: (id: string) => void | Promise; +} + +export function UserProfileEmailRowView({ + emails, + allowMultipleAccounts = false, + onAddEmail, + onSendEmailCode, + onVerifyEmailCode, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, +}: UserProfileEmailRowViewProps) { + const addEmailTriggerRef = useRef(null); + const addEmailAction = + onSendEmailCode && onVerifyEmailCode ? ( + + ) : onAddEmail ? ( + + ) : undefined; + const confirmedRemoval = useRef(false); + const [emailToRemove, setEmailToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimaryEmail = async (id: string) => { + const email = emails.find(email => email.id === id); + if (!onSetPrimaryEmail || !email?.isVerified || email.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimaryEmail(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : m.email.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removeEmail = (id: string) => { + const email = emails.find(email => email.id === id); + if (!email || email.canRemove === false || !onRemoveEmail || removing.current) { + return; + } + confirmedRemoval.current = false; + setEmailToRemove(email); + setRemoveError(undefined); + }; + + const confirmRemoveEmail = async () => { + if (!emailToRemove || !onRemoveEmail || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setEmailToRemove(undefined); + try { + await onRemoveEmail(emailToRemove.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.email.removeError); + } finally { + removing.current = false; + } + }; + + if (!allowMultipleAccounts) { + return ( + + ); + } + + return ( + <> + void setPrimaryEmail(id) : undefined} + onVerify={onVerifyEmail} + renderActionDialog={ + onRemoveEmail + ? email => ( + { + if (!open) { + setEmailToRemove(undefined); + } + }} + onConfirm={() => void confirmRemoveEmail()} + finalFocus={() => (confirmedRemoval.current ? addEmailTriggerRef.current : undefined)} + /> + ) + : undefined + } + /> + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + + ); +} + +function AddEmail({ + options, + compact, + triggerRef, +}: { + options: UserProfileAddEmailControllerOptions; + compact: boolean; + triggerRef?: Ref; +}) { + const controller = useUserProfileAddEmailController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.email.add} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx new file mode 100644 index 00000000000..bb94d3b00f2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -0,0 +1,81 @@ +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; + +export interface UserProfileNameRowViewProps { + name: string; + firstName?: string; + lastName?: string; + firstNameAttribute?: UserProfileNameAttribute; + lastNameAttribute?: UserProfileNameAttribute; + onSubmit?: (value: UserProfileEditNameValue) => Promise; +} + +export function UserProfileNameRowView({ + name, + firstName, + lastName, + firstNameAttribute, + lastNameAttribute, + onSubmit, +}: UserProfileNameRowViewProps) { + return ( + + + + {m.name.label} + {name} + + {onSubmit ? ( + + + + ) : null} + + + ); +} + +function EditName({ + firstName, + lastName, + firstNameAttribute, + lastNameAttribute, + onSubmit, +}: { + firstName?: string; + lastName?: string; + firstNameAttribute?: UserProfileNameAttribute; + lastNameAttribute?: UserProfileNameAttribute; + onSubmit: (value: UserProfileEditNameValue) => Promise; +}) { + const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); + + return ( + + {m.name.edit} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx new file mode 100644 index 00000000000..0137c60b092 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx @@ -0,0 +1,197 @@ +import type { Ref } from 'react'; +import { useRef, useState } from 'react'; + +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Text } from '../../components/text'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfilePhone } from './user-profile-account-section.types'; +import type { UserProfileAddPhoneControllerOptions } from './user-profile-add-phone.controller'; +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; +import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog'; +import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; +import { UserProfileContactRowView } from './user-profile-contact-row.view'; +import { UserProfileRemovePhoneDialog } from './user-profile-remove-phone.dialog'; + +export interface UserProfilePhoneRowViewProps { + phones: UserProfilePhone[]; + allowMultipleAccounts?: boolean; + onSendPhoneCode?: (phoneNumber: string) => Promise; + onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; + onManagePhone?: (id: string) => void; + onVerifyPhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void | Promise; + onRemovePhone?: (id: string) => void | Promise; +} + +export function UserProfilePhoneRowView({ + phones, + allowMultipleAccounts = false, + onSendPhoneCode, + onVerifyPhoneCode, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, +}: UserProfilePhoneRowViewProps) { + const addPhoneTriggerRef = useRef(null); + const addPhoneAction = + onSendPhoneCode && onVerifyPhoneCode ? ( + + ) : undefined; + const confirmedRemoval = useRef(false); + const [phoneToRemove, setPhoneToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimaryPhone = async (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimaryPhone(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removePhone = (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { + return; + } + confirmedRemoval.current = false; + setPhoneToRemove(phone); + setRemoveError(undefined); + }; + + const confirmRemovePhone = async () => { + if (!phoneToRemove || !onRemovePhone || removing.current) { + return; + } + removing.current = true; + confirmedRemoval.current = true; + setPhoneToRemove(undefined); + try { + await onRemovePhone(phoneToRemove.id); + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.phone.removeError); + } finally { + removing.current = false; + } + }; + const formattedPhones = phones.map(phone => ({ + ...phone, + value: stringToFormattedPhoneString(phone.value), + })); + + if (!allowMultipleAccounts) { + return ( + + ); + } + + return ( + <> + void setPrimaryPhone(id) : undefined} + onVerify={onVerifyPhone} + renderActionDialog={ + onRemovePhone + ? phone => ( + { + if (!open) { + setPhoneToRemove(undefined); + } + }} + onConfirm={() => void confirmRemovePhone()} + finalFocus={() => (confirmedRemoval.current ? addPhoneTriggerRef.current : undefined)} + /> + ) + : undefined + } + /> + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + + ); +} + +function AddPhone({ + options, + compact, + triggerRef, +}: { + options: UserProfileAddPhoneControllerOptions; + compact: boolean; + triggerRef?: Ref; +}) { + const controller = useUserProfileAddPhoneController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.phone.add} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx new file mode 100644 index 00000000000..87dad81fe40 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -0,0 +1,137 @@ +import type { FileRejection } from '@clerk/headless/file-upload'; +import { FileUpload } from '@clerk/headless/file-upload'; +import { useState } from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from '../user-profile-action-menu'; +import { UserProfileActionMenu } from '../user-profile-action-menu'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; +/** Matches the limit the row's own description advertises. */ +const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; + +export interface UserProfilePictureRowViewProps { + name: string; + imageUrl?: string; + hasImage?: boolean; + errorMessage?: string; + onChange?: (file: File) => void; + onReject?: (rejections: FileRejection[]) => void; + onRemove?: () => void; +} + +export function UserProfilePictureRowView({ + name, + imageUrl, + hasImage = false, + errorMessage, + onChange, + onReject, + onRemove, +}: UserProfilePictureRowViewProps) { + const [rejectionError, setRejectionError] = useState(); + const displayedError = errorMessage ?? rejectionError; + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( + } + onReject={rejections => { + const rejection = rejections[0]; + setRejectionError(rejection ? m.picture.errors[rejection.reason] : undefined); + onReject?.(rejections); + }} + onValueChange={files => { + const file = files[0]; + if (file) { + setRejectionError(undefined); + onChange?.(file); + } + }} + > + + + + + {initials} + + + + {m.picture.label} + {m.picture.description} + + + + {displayedError ? {displayedError} : null} + + ); +} + +function ProfilePictureActions({ + hasImage, + canChange, + onRemove, +}: { + hasImage: boolean; + canChange: boolean; + onRemove?: () => void; +}) { + const { openFilePicker } = FileUpload.useFileUpload(); + const actions: UserProfileMenuAction[] = []; + + if (hasImage && canChange) { + actions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); + } + + if (hasImage && onRemove) { + actions.push({ label: m.picture.remove, icon: 'close', onClick: onRemove }); + } + + if (actions.length > 0) { + return ( + + + + ); + } + + if (!hasImage && canChange) { + return ( + + + } + > + {m.picture.upload} + + + ); + } + + return null; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx new file mode 100644 index 00000000000..6fd6f8d302a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-email.dialog.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../components/button'; +import type { DialogFocusTarget } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileRemoveEmailDialogProps { + emailAddress: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + finalFocus?: DialogFocusTarget; +} + +export function UserProfileRemoveEmailDialog({ + emailAddress, + open, + onOpenChange, + onConfirm, + finalFocus, +}: UserProfileRemoveEmailDialogProps) { + const [beforeEmail, afterEmail] = m.email.removeDialog.description.split('{emailAddress}'); + + return ( + + + }>{m.email.removeDialog.title} + }> + {beforeEmail} + {emailAddress} + {afterEmail} + + + }>{m.email.removeDialog.cancel} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx new file mode 100644 index 00000000000..809555a88e8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-remove-phone.dialog.tsx @@ -0,0 +1,53 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Button } from '../../components/button'; +import type { DialogFocusTarget } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Heading } from '../../components/heading'; +import { Text } from '../../components/text'; +import { styles } from '../user-profile-profile-panel.styles'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileRemovePhoneDialogProps { + phoneNumber: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + finalFocus?: DialogFocusTarget; +} + +export function UserProfileRemovePhoneDialog({ + phoneNumber, + open, + onOpenChange, + onConfirm, + finalFocus, +}: UserProfileRemovePhoneDialogProps) { + const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); + + return ( + + + }>{m.phone.removeDialog.title} + }> + {beforePhone} + {phoneNumber} + {afterPhone} + + + }>{m.phone.removeDialog.cancel} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx new file mode 100644 index 00000000000..b5adeb23984 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -0,0 +1,51 @@ +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; +import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; + +export interface UserProfileUsernameRowViewProps { + username: string; + onSubmit?: (username: string) => Promise; +} + +export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + return ( + + + + {m.username.label} + {username} + + {onSubmit ? ( + + + + ) : null} + + + ); +} + +function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { + const controller = useUserProfileEditUsernameController({ username, onSubmit }); + + return ( + + {m.username.edit} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx new file mode 100644 index 00000000000..d9432d89fa3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.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.messages'; +import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; +import { styles } from './user-profile-verify-email-link.styles'; + +export interface UserProfileVerifyEmailLinkDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + emailAddress: string; + onResend: () => void; + isResending?: boolean; + resendSeconds?: number; + errorMessage?: string; +} + +export function UserProfileVerifyEmailLinkDialog({ + open, + onOpenChange, + trigger, + emailAddress, + onResend, + isResending = false, + resendSeconds = 0, + errorMessage, +}: UserProfileVerifyEmailLinkDialogProps) { + const [beforeEmail, afterEmail] = m.description.split('{emailAddress}'); + + return ( + + {trigger ? : null} + + + + {m.title} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ + {m.waiting} +
+
+ + {beforeEmail} + {emailAddress} + {afterEmail} + + +
+
+ + + } + > + {m.cancel} + + +
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts new file mode 100644 index 00000000000..208233909e2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/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-account-section/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts new file mode 100644 index 00000000000..40f0164da89 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts @@ -0,0 +1,26 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, fontWeightVars, space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + content: { + gap: space['2.5'], + }, + status: { + gap: space['2.5'], + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + }, + details: { + display: 'grid', + justifyItems: 'start', + overflowWrap: 'anywhere', + }, + emphasis: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, + emailAddress: { + color: colorVars['--cl-color-primary'], + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx new file mode 100644 index 00000000000..05695c4c10c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.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 { UserProfileProviderIcon } from '../user-profile-provider-icon'; +import { fill } from './user-profile-account-section.messages'; +import { userProfileVerifyEmailSsoMessages as m } from './user-profile-verify-email-sso.messages'; + +export interface UserProfileVerifyEmailSsoDialogProps { + 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 UserProfileVerifyEmailSsoDialog({ + open, + onOpenChange, + trigger, + emailAddress, + connection, + onConnect, + isConnecting = false, + errorMessage, +}: UserProfileVerifyEmailSsoDialogProps) { + 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} + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/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-account-section/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-action-menu.tsx b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx index 288bd38c2ba..c2f4041c6b6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-action-menu.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react'; + import { Icon } from '../components/icon'; import { Menu } from '../components/menu'; import type { IconName } from '../icons/registry'; @@ -9,7 +11,15 @@ export interface UserProfileMenuAction { onClick: () => void; } -export function UserProfileActionMenu({ label, actions }: { label: string; actions: UserProfileMenuAction[] }) { +export function UserProfileActionMenu({ + label, + actions, + children, +}: { + label: string; + actions: UserProfileMenuAction[]; + children?: ReactNode; +}) { if (actions.length === 0) { return null; } @@ -34,6 +44,7 @@ export function UserProfileActionMenu({ label, actions }: { label: string; actio ))} + {children} ); } 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..d796c9e5d4f 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,8 +1,14 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + confirmPhoneNumber: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, + confirmationContactValue: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', 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 e77f736336e..01d9ee3b82e 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -21,7 +21,7 @@ export type { UserProfileFormError, UserProfileNameAttribute, } from './user-profile-account-section/user-profile-account-section.types'; -export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.view'; +export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.dialog'; export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[]; @@ -57,11 +57,14 @@ export function UserProfileProfilePanelView({ onSubmitName, onSubmitUsername, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onAddPhone, + onSendPhoneCode, + onVerifyPhoneCode, onManagePhone, onVerifyPhone, onSetPrimaryPhone, @@ -92,7 +95,10 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} - onAddPhone={onAddPhone} + onSendPhoneCode={onSendPhoneCode} + onVerifyPhoneCode={onVerifyPhoneCode} + onSendEmailCode={onSendEmailCode} + onVerifyEmailCode={onVerifyEmailCode} onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange}