diff --git a/.changeset/tidy-emails-confirm.md b/.changeset/tidy-emails-confirm.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/tidy-emails-confirm.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..6df3b7f0ece 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,7 +188,12 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { + AddEmailFails as UserProfileAccountSectionAddEmailFails, 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 +472,11 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + 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..39677383f55 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -0,0 +1,28 @@ +import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddEmailViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-email.view'; + +interface FixtureOptions { + failAt?: UserProfileAddEmailViewProps['step']; + onVerified?: (emailAddress: string) => void; +} + +export function createUserProfileAddEmailFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< + UserProfileAccountSectionViewProps, + 'onSendEmailCode' | 'onVerifyEmailCode' +> { + return { + onSendEmailCode: async () => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'email') { + throw new Error('We couldn’t send a code. Try again.'); + } + }, + onVerifyEmailCode: async (emailAddress, code) => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(emailAddress); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts b/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts new file mode 100644 index 00000000000..974a36b16a4 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +export function useUserProfileVerifyEmailLinkFixture({ failResend = false } = {}) { + const [open, setOpen] = useState(false); + const [resendSeconds, setResendSeconds] = useState(12); + const [isResending, setIsResending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + useEffect(() => { + if (!open) { + return; + } + if (isResending) { + const timer = setTimeout(() => { + setIsResending(false); + if (failResend) { + setErrorMessage('Unable to send the verification link. Try again.'); + } else { + setResendSeconds(12); + } + }, 700); + return () => clearTimeout(timer); + } + if (resendSeconds > 0) { + const timer = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); + return () => clearTimeout(timer); + } + }, [open, isResending, resendSeconds, failResend]); + + return { + open, + emailAddress: 'example@email.com', + resendSeconds, + isResending, + errorMessage, + onOpenChange: (value: boolean) => { + setOpen(value); + setResendSeconds(12); + setIsResending(false); + setErrorMessage(undefined); + }, + onResend: () => { + setErrorMessage(undefined); + setIsResending(true); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-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 9b8a9ce3eb8..cf97a9cade3 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,10 +13,11 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { createUserProfileAddEmailFixture } from './user-profile-add-email'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; export interface UserProfileFixtureOptions { - /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ + /** Replaces the default OTP flow, e.g. for a custom dialog example. */ onAddEmail?: () => void; } @@ -108,6 +109,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const addEmail = (value: string) => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); const pages: UserProfileViewProps['pages'] = { account: { @@ -118,7 +122,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions username: 'prestonxyz', emails, phones, - onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), + onAddEmail, + onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, + onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, onAddPhone: () => setPhones(current => [ ...current, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index e7a319005b4..0d30214e052 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -18,6 +18,10 @@ Account details, profile image, email addresses, and phone numbers composed with ## Multiple accounts +**Add email** opens email entry followed by a six-digit verification code. Resend is available after +the countdown. In this preview, `000000` shows an incorrect-code error; another six-digit code adds +the verified address. + + +## Email verification error + +This example rejects every verification attempt so the error remains visible and the user can retry. + + + +## Email-link verification + +The profile uses OTP. This separate view displays an email-link verification in progress, with a +resend countdown and Cancel. Its caller supplies the address, pending state, errors, and callbacks. + + + +### Resend error + +After the countdown, resend to see the supplied error message. + + + +## 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 47d47cc67cb..156b65c0575 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,15 +1,21 @@ +import { Button } from '@clerk/ui/mosaic/components/button'; import type { UserProfileFormError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEmail, UserProfilePhone, } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import { UserProfileVerifyEmailLinkView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-link.view'; +import { UserProfileVerifyEmailSsoView } from '@clerk/ui/mosaic/user-profile/user-profile-verify-email-sso.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; +import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; +import { useUserProfileVerifyEmailSsoFixture } from './fixtures/user-profile-verify-email-sso'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -25,9 +31,11 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, failWith, + failEmailVerification = false, }: { allowMultipleAccounts: boolean; failWith?: UserProfileFormError; + failEmailVerification?: boolean; }) { const editName = useUserProfileEditNameFixture({ failWith }); const [emails, setEmails] = useState( @@ -42,22 +50,21 @@ function AccountSection({ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); + const emailFlow = createUserProfileAddEmailFixture({ + failAt: failEmailVerification ? 'verify' : undefined, + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } onAddPhone={() => setPhones(current => [ ...current, @@ -72,6 +79,7 @@ function AccountSection({ 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} onUsernameChange={() => undefined} @@ -87,6 +95,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 ( 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 ec593488fac..f9016443cfe 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; @@ -31,10 +32,14 @@ export function Default(_args: Record) { ]); const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const editName = useUserProfileEditNameFixture(); + const emailFlow = createUserProfileAddEmailFixture({ + onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + }); return ( ) { imageUrl={imageUrl} phones={phones} username='prestonxyz' - onAddEmail={() => - setEmails(current => [ - ...current, - { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, - ]) - } onAddPhone={() => setPhones(current => [ ...current, @@ -95,7 +94,7 @@ export function Default(_args: Record) { onConnectWeb3Wallet={() => undefined} onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} - onSetPrimaryEmail={() => undefined} + onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} onSetPrimaryPhone={() => undefined} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx new file mode 100644 index 00000000000..78f4f176d77 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +describe('profile add email', () => { + it.each([false, true])( + 'owns the dialog and returns focus with multiple accounts = %s', + async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add email' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx new file mode 100644 index 00000000000..c29a6e7f605 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-email.view.test.tsx @@ -0,0 +1,187 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAddEmailViewProps } from '../user-profile-add-email.view'; +import { UserProfileAddEmailView } from '../user-profile-add-email.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddEmailViewProps = { + open: true, + onOpenChange: vi.fn(), + step: 'email', + emailAddress: 'person@example.com', + onEmailAddressChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + + return ( + + undefined} + step='verify' + emailAddress='person@example.com' + onEmailAddressChange={() => undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + onResend={() => undefined} + /> + + ); +} + +describe('UserProfileAddEmailView', () => { + it.each(['', 'invalid-address'])('uses native email validation for %j', async emailAddress => { + const user = userEvent.setup(); + const { props } = renderView({ emailAddress }); + await user.click(screen.getByRole('button', { name: 'Send code' })); + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox', { name: 'Email' })).toBeInvalid(); + }); + + it.each(['typing', 'pasting'] as const)('automatically submits a complete code after %s', async method => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + + if (method === 'typing') { + await user.keyboard('12345'); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard('6'); + } else { + await user.paste('123456'); + } + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('focuses the email field and submits through the form or Send code', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); + const email = screen.getByRole('textbox', { name: 'Email' }); + await waitFor(() => expect(email).toHaveFocus()); + const emailForm = email.closest('form'); + if (!emailForm) { + throw new Error('Email form missing'); + } + expect(emailForm).toHaveClass('cl-card-content'); + emailForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Send code' })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + }); + + it('moves to verification inside the same dialog and submits the code', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView(); + const dialog = screen.getByRole('dialog'); + + rerender( + + + , + ); + + expect(screen.getByRole('dialog', { name: 'Verify your email' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to person@example.com')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Email' })).not.toBeInTheDocument(); + const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(firstSlot).toHaveFocus()); + const verifyForm = firstSlot.closest('form'); + if (!verifyForm) { + throw new Error('Verification form missing'); + } + expect(verifyForm).toHaveClass('cl-card-content'); + verifyForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('blocks submission and resend while verification is pending', async () => { + const user = userEvent.setup(); + const { props } = renderView({ step: 'verify', code: '123456', isPending: true }); + + for (const slot of screen.getAllByRole('textbox')) { + expect(slot).toBeDisabled(); + } + const verify = screen.getByRole('button', { name: 'Verify', exact: true }); + expect(verify).toHaveAttribute('aria-busy', 'true'); + await user.click(verify); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.onResend).not.toHaveBeenCalled(); + }); + + it.each(['email', 'verify'] as const)('associates a %s error with its input', step => { + renderView({ step, errorMessage: 'Please try again.' }); + + const field = screen.getByRole('textbox', { name: step === 'email' ? 'Email' : 'Verification code' }); + expect(field).toHaveAttribute('aria-invalid', 'true'); + const describedControl = step === 'verify' ? screen.getByRole('group', { name: 'Verification code' }) : field; + expect(describedControl).toHaveAccessibleDescription('Please try again.'); + }); + + it('allows resending only after the countdown and the current request finish', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'verify', resendSeconds: 12 }); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })); + expect(props.onResend).not.toHaveBeenCalled(); + + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a3627213e59..5a099cf29af 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 @@ -414,6 +414,12 @@ describe('UserProfileProfilePanelView', () => { const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); expect(removeEmail).toHaveAttribute('data-color', 'negative'); await user.click(removeEmail); + expect(onRemoveEmail).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove email address?' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx new file mode 100644 index 00000000000..12eca6df898 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-link.view.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileVerifyEmailLinkViewProps } from '../user-profile-verify-email-link.view'; +import { UserProfileVerifyEmailLinkView } from '../user-profile-verify-email-link.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailLinkViewProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailLinkView', () => { + it('shows the address awaiting verification and lets the user resend the link', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( + 'A verification link was sent to example@email.com', + ); + expect(screen.getByRole('status')).toHaveTextContent('Check your email'); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + }); + + it.each([ + { resendSeconds: 12, isResending: false, label: 'Didn’t receive a link? Resend (12)' }, + { resendSeconds: 0, isResending: true, label: 'Sending a new link…' }, + ])('prevents resending while $label', async ({ resendSeconds, isResending, label }) => { + const user = userEvent.setup(); + const { props } = renderView({ resendSeconds, isResending }); + const resend = screen.getByRole('button', { name: label }); + + expect(resend).toBeDisabled(); + await user.click(resend); + expect(props.onResend).not.toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces a supplied resend error and allows retry', async () => { + const user = userEvent.setup(); + const { props } = renderView({ errorMessage: 'Unable to send the verification link. Try again.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to send the verification link. Try again.'); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx new file mode 100644 index 00000000000..ba8c4f69b24 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-verify-email-sso.view.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileVerifyEmailSsoViewProps } from '../user-profile-verify-email-sso.view'; +import { UserProfileVerifyEmailSsoView } from '../user-profile-verify-email-sso.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileVerifyEmailSsoViewProps = { + open: true, + onOpenChange: vi.fn(), + emailAddress: 'example@email.com', + connection: { provider: 'Okta SSO', domain: 'acme.co' }, + onConnect: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +describe('UserProfileVerifyEmailSsoView', () => { + it('shows the matching connection and lets the user connect to verify their email', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( + 'Connect below to verify example@email.com', + ); + expect(screen.getByText('Okta SSO')).toBeInTheDocument(); + expect(screen.getByText('acme.co · Enterprise SSO')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(props.onConnect).toHaveBeenCalledOnce(); + expect(props.onOpenChange).not.toHaveBeenCalled(); + }); + + it('prevents another connection attempt while connecting and still allows cancellation', async () => { + const user = userEvent.setup(); + const { props } = renderView({ isConnecting: true }); + const connect = screen.getByRole('button', { name: 'Connect' }); + + expect(connect).toBeDisabled(); + expect(connect).toHaveAttribute('aria-busy', 'true'); + await user.click(connect); + expect(props.onConnect).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces a supplied connection error and allows retry', async () => { + const user = userEvent.setup(); + const { props } = renderView({ errorMessage: 'Unable to connect to Okta. Try again.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect to Okta. Try again.'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(props.onConnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-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 fe6b9ccc4b2..02d827b0a67 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 @@ -55,6 +55,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', 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 be6fe79a0ee..bbea9a9a41b 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,15 +1,21 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; +import { createConfirmHandle, Dialog } from '../../components/dialog'; import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; +import { Text } from '../../components/text'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; +import type { UserProfileAddEmailControllerOptions } from '../user-profile-add-email.controller'; +import { useUserProfileAddEmailController } from '../user-profile-add-email.controller'; +import { UserProfileAddEmailView } from '../user-profile-add-email.view'; import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -68,10 +74,12 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onUsernameChange?: (value: string) => void; onAddEmail?: () => void; + onSendEmailCode?: (emailAddress: string) => Promise; + onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void; - onRemoveEmail?: (id: string) => void; + onSetPrimaryEmail?: (id: string) => void | Promise; + onRemoveEmail?: (id: string) => void | Promise; onAddPhone?: () => void; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; @@ -97,6 +105,8 @@ export function UserProfileAccountSectionView({ onSubmitName, onUsernameChange, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -107,6 +117,13 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addEmailAction = + onSendEmailCode && onVerifyEmailCode ? ( + + ) : undefined; const initials = name .split(/\s+/) .map(part => part[0]) @@ -203,6 +220,7 @@ export function UserProfileAccountSectionView({ items={emails} kind='email' label={m.email.label} + addAction={addEmailAction} onAdd={onAddEmail} onManage={onManageEmail} /> @@ -219,10 +237,11 @@ export function UserProfileAccountSectionView({ {allowMultipleAccounts ? ( - + {compact ? ( + + ) : null} + {compact ? m.add : m.email.add} + + } + /> + ); +} + interface ContactSectionProps { + addAction?: ReactNode; kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; onAdd?: () => void; onManage?: (id: string) => void; onVerify?: (id: string) => void; - onSetPrimary?: (id: string) => void; - onRemove?: (id: string) => void; + onSetPrimary?: (id: string) => void | Promise; + onRemove?: (id: string) => void | Promise; } function ContactSection(props: ContactSectionProps) { @@ -357,7 +403,116 @@ function ContactSection(props: ContactSectionProps) { ); } -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { +function EmailContactSection(props: ContactSectionProps) { + const { items, onSetPrimary, onRemove } = props; + const messages = m.email; + const sectionRef = useRef(null); + const removeConfirm = useMemo(() => createConfirmHandle(), []); + const [contactToRemove, setContactToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimary = async (id: string) => { + const contact = items.find(item => item.id === id); + if (!onSetPrimary || !contact?.isVerified || contact.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimary(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : messages.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removeContact = async (id: string) => { + const contact = items.find(item => item.id === id); + if (!contact || contact.canRemove === false || !onRemove || removing.current) { + return; + } + removing.current = true; + setContactToRemove(contact); + setRemoveError(undefined); + try { + const [beforeEmail, afterEmail] = messages.removeDialog.description.split('{emailAddress}'); + const confirmed = await removeConfirm.show({ + title: messages.removeDialog.title, + description: ( + <> + {beforeEmail} + {contact.value} + {afterEmail} + + ), + actionLabel: messages.removeDialog.confirm, + cancelLabel: messages.removeDialog.cancel, + destructive: true, + }); + if (confirmed) { + await onRemove(id); + } + } catch (error) { + setRemoveError(error instanceof Error ? error.message : messages.removeError); + } finally { + removing.current = false; + } + }; + + return ( + + + void setPrimary(id) : undefined} + onRemove={onRemove ? id => void removeContact(id) : undefined} + /> + + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + { + const buttons = Array.from(sectionRef.current?.querySelectorAll('button') ?? []); + const label = contactToRemove ? fill(m.manageValue, { value: contactToRemove.value }) : ''; + return ( + buttons.find(button => button.getAttribute('aria-label') === label) ?? + buttons.find(button => button.getAttribute('aria-label') === messages.add) ?? + buttons[0] ?? + false + ); + }} + /> + + ); +} + +function SingleContactRow({ kind, label, items, onAdd, onManage, addAction }: ContactSectionProps) { const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; @@ -377,7 +532,9 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {emptyDescription} )} - {onClick ? ( + {!item && addAction ? ( + {addAction} + ) : onClick ? ( + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 8a3cc17a27c..6fe2aadd747 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,8 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + confirmationContactValue: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index ffd3b4bf349..204e16eefb2 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -57,6 +57,8 @@ export function UserProfileProfilePanelView({ onSubmitName, onUsernameChange, onAddEmail, + onSendEmailCode, + onVerifyEmailCode, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -92,6 +94,8 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} + onSendEmailCode={onSendEmailCode} + onVerifyEmailCode={onVerifyEmailCode} onAddPhone={onAddPhone} onManageEmail={onManageEmail} onManagePhone={onManagePhone} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts new file mode 100644 index 00000000000..208233909e2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.messages.ts @@ -0,0 +1,9 @@ +export const userProfileVerifyEmailLinkMessages = { + title: 'Verify email address', + waiting: 'Check your email', + description: 'A verification link was sent to {emailAddress}', + resend: 'Didn’t receive a link? Resend', + resendCountdown: 'Didn’t receive a link? Resend ({seconds})', + resending: 'Sending a new link…', + cancel: 'Cancel', +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.styles.ts new file mode 100644 index 00000000000..3f91864cc70 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/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-verify-email-link.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx new file mode 100644 index 00000000000..b00c17de3d9 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-link.view.tsx @@ -0,0 +1,107 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Banner } from '../components/banner'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import type { DialogTriggerProps } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Spinner } from '../components/spinner'; +import { Text } from '../components/text'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { userProfileVerifyEmailLinkMessages as m } from './user-profile-verify-email-link.messages'; +import { styles } from './user-profile-verify-email-link.styles'; + +export interface UserProfileVerifyEmailLinkViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + emailAddress: string; + onResend: () => void; + isResending?: boolean; + resendSeconds?: number; + errorMessage?: string; +} + +export function UserProfileVerifyEmailLinkView({ + open, + onOpenChange, + trigger, + emailAddress, + onResend, + isResending = false, + resendSeconds = 0, + errorMessage, +}: UserProfileVerifyEmailLinkViewProps) { + const [beforeEmail, afterEmail] = m.description.split('{emailAddress}'); + + return ( + + {trigger ? : null} + + + + {m.title} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ + {m.waiting} +
+
+ + {beforeEmail} + {emailAddress} + {afterEmail} + + +
+
+ + + } + > + {m.cancel} + + +
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts new file mode 100644 index 00000000000..6cc7771087b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.messages.ts @@ -0,0 +1,7 @@ +export const userProfileVerifyEmailSsoMessages = { + title: 'Verify email address', + description: 'Connect below to verify {emailAddress}', + connectionDescription: '{domain} · Enterprise SSO', + connect: 'Connect', + cancel: 'Cancel', +}; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx new file mode 100644 index 00000000000..6d43d6f958b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-verify-email-sso.view.tsx @@ -0,0 +1,108 @@ +import { Banner } from '../components/banner'; +import { Button } from '../components/button'; +import { Card } from '../components/card'; +import type { DialogTriggerProps } from '../components/dialog'; +import { Dialog } from '../components/dialog'; +import { Icon } from '../components/icon'; +import { Item } from '../components/item'; +import { Spinner } from '../components/spinner'; +import { fill } from './user-profile-account-section/user-profile-account-section.messages'; +import { UserProfileProviderIcon } from './user-profile-provider-icon'; +import { userProfileVerifyEmailSsoMessages as m } from './user-profile-verify-email-sso.messages'; + +export interface UserProfileVerifyEmailSsoViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + emailAddress: string; + connection: { + provider: string; + domain: string; + iconUrl?: string; + }; + onConnect: () => void; + isConnecting?: boolean; + errorMessage?: string; +} + +export function UserProfileVerifyEmailSsoView({ + open, + onOpenChange, + trigger, + emailAddress, + connection, + onConnect, + isConnecting = false, + errorMessage, +}: UserProfileVerifyEmailSsoViewProps) { + return ( + + {trigger ? : null} + + + + {m.title} + {fill(m.description, { emailAddress })} + + + {errorMessage ? ( + + {errorMessage} + + ) : null} + + {connection.iconUrl ? : null} + + {connection.provider} + {fill(m.connectionDescription, { domain: connection.domain })} + + + + + + + + + } + > + {m.cancel} + + + + + + ); +}