From 37de0d695b4fc71fd777d2d68bba22abd0cdad71 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 20 Aug 2026 14:50:05 -0600 Subject: [PATCH 01/14] feat(ui): rough in the user-profile account section flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the view layer for every action on the account section that opens a dialog: adding and verifying an email address or phone number, removing one, promoting one to primary, and editing the name, username and profile picture. That is the whole of `UserProfileAccountSection`, which is what the flow is named for — the intent is one flow per section, each named after the section component it drives. The views are pure. Each branches on a snapshot and sends events back, holding no flow state of its own, so the layer that decides when a step changes can be swapped without touching them. Today that layer is a simulated backend in swingset, with configurable latency, injected failures and a reverification gate; later it is a state machine and its Clerk controller. Every delay in the harness maps to an invoke, every branch to a guard. Legacy `packages/ui/src/components/UserProfile` is the spec. All three email verification strategies are covered, since the instance picks between them rather than the user: a code, an email link with its own waiting screen and throttled resend, and enterprise SSO. Set-as-primary gains a confirmation it does not have today, where its pending and failed states can live. The dialog chrome, the reverification challenge and the shared flow types sit apart from the account-specific dialogs, since the next section's flow will reuse them. Inputs Mosaic does not have yet — the code field, the country picker, the file picker, the unattributed-error banner, and the dialog header/body/footer — are hand-rolled and marked with TODOs naming their replacements. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-profile-contact-flow-rough-in.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 13 + ...er-profile-account-section-flow.harness.ts | 1016 ++++++++++++++++ .../user-profile-account-section-flow.mdx | 71 ++ ...r-profile-account-section-flow.stories.tsx | 1022 +++++++++++++++++ .../add-contact-dialog.view.test.tsx | 221 ++++ .../confirm-contact-dialog.view.test.tsx | 163 +++ .../edit-profile-dialog.view.test.tsx | 243 ++++ .../dialogs/add-contact-dialog.view.tsx | 398 +++++++ .../dialogs/confirm-contact-dialog.view.tsx | 119 ++ .../dialogs/edit-profile-dialog.view.tsx | 259 +++++ .../dialogs/flow-dialog-chrome.tsx | 264 +++++ .../dialogs/flow-dialogs.styles.ts | 153 +++ .../mosaic/user-profile/dialogs/flow.types.ts | 201 ++++ .../dialogs/reverification-dialog.view.tsx | 122 ++ 16 files changed, 4268 insertions(+) create mode 100644 .changeset/user-profile-contact-flow-rough-in.md create mode 100644 packages/swingset/src/stories/user-profile-account-section-flow.harness.ts create mode 100644 packages/swingset/src/stories/user-profile-account-section-flow.mdx create mode 100644 packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx diff --git a/.changeset/user-profile-contact-flow-rough-in.md b/.changeset/user-profile-contact-flow-rough-in.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-contact-flow-rough-in.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3a52fbae376..9a7677a7f1f 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -15,6 +15,7 @@ const docModules: Record> = { }, 'user-profile': { 'user-page': dynamic(() => import('../stories/user-page.mdx')), + 'user-profile-account-section-flow': dynamic(() => import('../stories/user-profile-account-section-flow.mdx')), 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index dbf534ce294..170bcfd431e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -107,6 +107,12 @@ import { meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, } from '../stories/user-profile-account-section.stories'; +import { + Default as UserProfileAccountSectionFlowDefault, + meta as userProfileAccountSectionFlowMeta, + ProfileStates as UserProfileAccountSectionFlowProfileStates, + States as UserProfileAccountSectionFlowStates, +} from '../stories/user-profile-account-section-flow.stories'; import { Default as UserProfileActiveDevicesSectionDefault, meta as userProfileActiveDevicesSectionMeta, @@ -291,6 +297,12 @@ const userPageModule: StoryModule = { meta: userPageMeta, Default: UserPageDefault, }; +const userProfileAccountSectionFlowModule: StoryModule = { + meta: userProfileAccountSectionFlowMeta, + Default: UserProfileAccountSectionFlowDefault, + States: UserProfileAccountSectionFlowStates, + ProfileStates: UserProfileAccountSectionFlowProfileStates, +}; const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, @@ -359,6 +371,7 @@ export const registry: StoryModule[] = [ userButtonModule, // User Profile userPageModule, + userProfileAccountSectionFlowModule, // User Profile · Panels userProfileProfilePanelModule, userProfileSecurityPanelModule, diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts new file mode 100644 index 00000000000..21fd709ea7e --- /dev/null +++ b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts @@ -0,0 +1,1016 @@ +import type { + AddContactFlowState, + ConfirmContactActionState, + ContactKind, + ContactVerificationStrategy, + EditAvatarState, + EditNameState, + EditUsernameState, + ProfileField, + ReverificationChallengeState, +} from '@clerk/ui/mosaic/user-profile/dialogs/flow.types'; +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; + +/** + * A simulated backend for the user-profile account section's flows. + * + * This is the layer a state machine and its Clerk controller will replace. It exists so the views + * can be exercised against real latency, real failure and real step transitions before any of that + * is built — every delay here becomes a machine `invoke`, and every branch a guard. + * + * Nothing in this file ships; it lives in swingset on purpose. + */ + +/** Anything the flow needs the "server" to decide, exposed as story controls. */ +export interface AccountSectionFlowConfig { + /** Round-trip latency, in ms, for every simulated call. */ + latencyMs: number; + /** + * How the instance verifies email. Mirrors the legacy strategy pick: an address matching an + * enterprise connection wins, otherwise instance-wide email links, otherwise a code. + */ + emailStrategy: Extract; + /** Domains routed to enterprise SSO, overriding `emailStrategy`. */ + ssoDomains: string[]; + ssoProviderName: string; + /** Identifiers the server rejects as already taken. */ + takenIdentifiers: string[]; + /** The only code the simulated server accepts. */ + validCode: string; + /** Raise a reverification challenge before each mutation. */ + requireReverification: boolean; + reverificationStrategy: ReverificationChallengeState['strategy']; + /** The only password the simulated reverification accepts. */ + validPassword: string; + /** Fail the next call with an unattributed (no `paramName`) error. */ + failWithFormError: boolean; + /** How an email link resolves once the simulated user "clicks" it. */ + emailLinkOutcome: 'verified' | 'verified_other_tab' | 'expired' | 'failed'; + /** ms before the simulated email link resolves. `0` waits forever. */ + emailLinkResolveMs: number; + /** Fail the enterprise SSO popup instead of returning verified. */ + ssoFails: boolean; + /** + * An account with an active enterprise connection: legacy renders the name form read-only. + * Username and avatar stay editable. + */ + enterpriseManaged: boolean; + /** Usernames the simulated server rejects as already taken. */ + takenUsernames: string[]; +} + +export const DEFAULT_ACCOUNT_SECTION_FLOW_CONFIG: AccountSectionFlowConfig = { + latencyMs: 900, + emailStrategy: 'email_code', + ssoDomains: ['acmecorp.com'], + ssoProviderName: 'Okta', + takenIdentifiers: ['taken@clerk.dev'], + validCode: '424242', + requireReverification: false, + reverificationStrategy: 'password', + validPassword: 'clerk', + failWithFormError: false, + emailLinkOutcome: 'verified', + emailLinkResolveMs: 6000, + ssoFails: false, + enterpriseManaged: false, + takenUsernames: ['prestonxyz'], +}; + +/** Mirrors the legacy uploader, which enforces both before calling the server. */ +const AVATAR_MAX_BYTES = 10 * 1000 * 1000; +const AVATAR_MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; + +/** Cooldown the link step's resend runs, matching the legacy 60s throttled TimerButton. */ +const LINK_RESEND_COOLDOWN_S = 60; + +const IDLE_RESEND = { isResending: false, secondsRemaining: 0 }; + +export interface ContactRecord { + id: string; + value: string; + isDefault?: boolean; + isVerified?: boolean; + canRemove?: boolean; +} + +export type PendingConfirm = + | { action: 'remove'; kind: ContactKind; id: string } + | { action: 'set-primary'; kind: ContactKind; id: string }; + +export interface ProfileIdentity { + firstName: string; + lastName: string; + username: string; + imageUrl?: string; +} + +type EditState = + | { field: 'name'; state: EditNameState } + | { field: 'username'; state: EditUsernameState } + | { field: 'avatar'; state: EditAvatarState }; + +interface FlowState { + add: { kind: ContactKind; state: AddContactFlowState } | null; + edit: EditState | null; + identity: ProfileIdentity; + confirm: { pending: PendingConfirm; state: ConfirmContactActionState } | null; + /** Stacked over whichever surface is open. Null when no challenge is outstanding. */ + reverification: ReverificationChallengeState | null; + emails: ContactRecord[]; + phones: ContactRecord[]; +} + +type Action = + | { type: 'add.open'; kind: ContactKind } + | { type: 'add.value'; value: string } + | { type: 'add.submitting' } + | { type: 'add.error'; errors: { field?: string; form?: string } } + | { type: 'add.preparing'; identifier: string; strategy: ContactVerificationStrategy } + | { type: 'add.verify'; identifier: string; strategy: ContactVerificationStrategy; providerName: string } + | { type: 'add.code'; code: string } + | { type: 'add.codeStatus'; status: 'idle' | 'verifying' | 'error' | 'success'; message?: string } + | { type: 'add.resend'; isResending: boolean; cooldown?: number } + | { type: 'add.tick' } + | { type: 'add.linkOutcome'; outcome: 'verified_other_tab' | 'expired' | 'failed' } + | { type: 'add.ssoStatus'; status: 'idle' | 'awaiting_popup' | 'error'; message?: string } + | { type: 'add.success'; identifier: string } + | { type: 'add.close' } + | { type: 'confirm.open'; pending: PendingConfirm; identifier: string } + | { type: 'confirm.submitting' } + | { type: 'confirm.error'; message: string } + | { type: 'confirm.close' } + | { type: 'reverification.open'; state: ReverificationChallengeState } + | { type: 'reverification.value'; value: string } + | { type: 'reverification.status'; status: 'idle' | 'verifying' | 'error'; message?: string } + | { type: 'reverification.resend'; isResending: boolean } + | { type: 'reverification.close' } + | { type: 'edit.open'; field: ProfileField; identity: ProfileIdentity; hasUsername: boolean; readOnly: boolean } + | { type: 'edit.name'; key: 'firstName' | 'lastName'; value: string } + | { type: 'edit.username'; value: string } + | { type: 'edit.avatarFile'; fileName: string; previewUrl: string } + | { type: 'edit.busy'; busy: boolean; status?: EditAvatarState['status'] } + | { type: 'edit.error'; errors: { field?: string; form?: string; firstName?: string; lastName?: string } } + | { type: 'edit.close' } + | { type: 'identity.set'; identity: Partial } + | { type: 'contacts.add'; kind: ContactKind; record: ContactRecord } + | { type: 'contacts.remove'; kind: ContactKind; id: string } + | { type: 'contacts.setPrimary'; kind: ContactKind; id: string }; + +function updateContacts( + state: FlowState, + kind: ContactKind, + update: (records: ContactRecord[]) => ContactRecord[], +): FlowState { + return kind === 'email' ? { ...state, emails: update(state.emails) } : { ...state, phones: update(state.phones) }; +} + +function reducer(state: FlowState, action: Action): FlowState { + switch (action.type) { + case 'add.open': + return { + ...state, + add: { + kind: action.kind, + state: { step: 'identifier', value: action.kind === 'phone' ? '+1' : '', isSubmitting: false, errors: {} }, + }, + }; + case 'add.value': + if (state.add?.state.step !== 'identifier') { + return state; + } + return { ...state, add: { ...state.add, state: { ...state.add.state, value: action.value, errors: {} } } }; + case 'add.submitting': + if (state.add?.state.step !== 'identifier') { + return state; + } + return { ...state, add: { ...state.add, state: { ...state.add.state, isSubmitting: true, errors: {} } } }; + case 'add.error': + if (state.add?.state.step !== 'identifier') { + return state; + } + return { + ...state, + add: { ...state.add, state: { ...state.add.state, isSubmitting: false, errors: action.errors } }, + }; + case 'add.preparing': + if (!state.add) { + return state; + } + return { + ...state, + add: { + ...state.add, + state: { step: 'preparing', identifier: action.identifier, strategy: action.strategy }, + }, + }; + case 'add.verify': { + if (!state.add) { + return state; + } + if (action.strategy === 'email_link') { + return { + ...state, + add: { + ...state.add, + state: { + step: 'link', + identifier: action.identifier, + // The legacy link card starts its resend disabled and throttled. + resend: { isResending: false, secondsRemaining: LINK_RESEND_COOLDOWN_S }, + errors: {}, + }, + }, + }; + } + if (action.strategy === 'enterprise_sso') { + return { + ...state, + add: { + ...state.add, + state: { + step: 'sso', + identifier: action.identifier, + providerName: action.providerName, + status: 'idle', + errors: {}, + }, + }, + }; + } + return { + ...state, + add: { + ...state.add, + state: { + step: 'code', + identifier: action.identifier, + strategy: action.strategy, + code: '', + status: 'idle', + errors: {}, + resend: IDLE_RESEND, + }, + }, + }; + } + case 'add.code': + if (state.add?.state.step !== 'code') { + return state; + } + return { + ...state, + add: { ...state.add, state: { ...state.add.state, code: action.code, status: 'idle', errors: {} } }, + }; + case 'add.codeStatus': + if (state.add?.state.step !== 'code') { + return state; + } + return { + ...state, + add: { + ...state.add, + state: { + ...state.add.state, + status: action.status, + code: action.status === 'error' ? '' : state.add.state.code, + errors: action.message ? { field: action.message } : {}, + }, + }, + }; + case 'add.resend': { + if (state.add?.state.step !== 'code' && state.add?.state.step !== 'link') { + return state; + } + const next = { isResending: action.isResending, secondsRemaining: action.cooldown ?? 0 }; + return { ...state, add: { ...state.add, state: { ...state.add.state, resend: next } } }; + } + case 'add.tick': { + if (state.add?.state.step !== 'code' && state.add?.state.step !== 'link') { + return state; + } + const { resend } = state.add.state; + if (resend.secondsRemaining <= 0) { + return state; + } + return { + ...state, + add: { + ...state.add, + state: { ...state.add.state, resend: { ...resend, secondsRemaining: resend.secondsRemaining - 1 } }, + }, + }; + } + case 'add.linkOutcome': + if (state.add?.state.step !== 'link') { + return state; + } + return { ...state, add: { ...state.add, state: { ...state.add.state, outcome: action.outcome } } }; + case 'add.ssoStatus': + if (state.add?.state.step !== 'sso') { + return state; + } + return { + ...state, + add: { + ...state.add, + state: { + ...state.add.state, + status: action.status, + errors: action.message ? { form: action.message } : {}, + }, + }, + }; + case 'add.success': + if (!state.add) { + return state; + } + return { ...state, add: { ...state.add, state: { step: 'success', identifier: action.identifier } } }; + case 'add.close': + return { ...state, add: null }; + case 'confirm.open': + return { + ...state, + confirm: { + pending: action.pending, + state: { identifier: action.identifier, isSubmitting: false, errors: {} }, + }, + }; + case 'confirm.submitting': + if (!state.confirm) { + return state; + } + return { + ...state, + confirm: { ...state.confirm, state: { ...state.confirm.state, isSubmitting: true, errors: {} } }, + }; + case 'confirm.error': + if (!state.confirm) { + return state; + } + return { + ...state, + confirm: { + ...state.confirm, + state: { ...state.confirm.state, isSubmitting: false, errors: { form: action.message } }, + }, + }; + case 'confirm.close': + return { ...state, confirm: null }; + case 'reverification.open': + return { ...state, reverification: action.state }; + case 'reverification.value': + if (!state.reverification) { + return state; + } + return { ...state, reverification: { ...state.reverification, value: action.value, errors: {} } }; + case 'reverification.status': + if (!state.reverification) { + return state; + } + return { + ...state, + reverification: { + ...state.reverification, + status: action.status, + value: action.status === 'error' ? '' : state.reverification.value, + errors: action.message ? { field: action.message } : {}, + }, + }; + case 'reverification.resend': + if (!state.reverification) { + return state; + } + return { + ...state, + reverification: { + ...state.reverification, + resend: { isResending: action.isResending, secondsRemaining: 0 }, + }, + }; + case 'reverification.close': + return { ...state, reverification: null }; + case 'edit.open': { + const { identity } = action; + if (action.field === 'name') { + return { + ...state, + edit: { + field: 'name', + state: { + firstName: identity.firstName, + lastName: identity.lastName, + isSubmitting: false, + isReadOnly: action.readOnly, + errors: {}, + }, + }, + }; + } + if (action.field === 'username') { + return { + ...state, + edit: { + field: 'username', + state: { + value: identity.username, + hasUsername: action.hasUsername, + isSubmitting: false, + errors: {}, + }, + }, + }; + } + return { + ...state, + edit: { + field: 'avatar', + state: { + previewUrl: identity.imageUrl, + canRemove: Boolean(identity.imageUrl), + status: 'idle', + errors: {}, + }, + }, + }; + } + case 'edit.name': + if (state.edit?.field !== 'name') { + return state; + } + return { + ...state, + edit: { field: 'name', state: { ...state.edit.state, [action.key]: action.value, errors: {} } }, + }; + case 'edit.username': + if (state.edit?.field !== 'username') { + return state; + } + return { ...state, edit: { field: 'username', state: { ...state.edit.state, value: action.value, errors: {} } } }; + case 'edit.avatarFile': + if (state.edit?.field !== 'avatar') { + return state; + } + return { + ...state, + edit: { + field: 'avatar', + state: { + ...state.edit.state, + fileName: action.fileName, + previewUrl: action.previewUrl, + errors: {}, + }, + }, + }; + case 'edit.busy': { + if (!state.edit) { + return state; + } + if (state.edit.field === 'avatar') { + return { + ...state, + edit: { + field: 'avatar', + state: { ...state.edit.state, status: action.busy ? (action.status ?? 'uploading') : 'idle', errors: {} }, + }, + }; + } + if (state.edit.field === 'name') { + return { + ...state, + edit: { field: 'name', state: { ...state.edit.state, isSubmitting: action.busy, errors: {} } }, + }; + } + return { + ...state, + edit: { field: 'username', state: { ...state.edit.state, isSubmitting: action.busy, errors: {} } }, + }; + } + case 'edit.error': { + if (!state.edit) { + return state; + } + if (state.edit.field === 'avatar') { + return { + ...state, + edit: { field: 'avatar', state: { ...state.edit.state, status: 'idle', errors: action.errors } }, + }; + } + if (state.edit.field === 'name') { + return { + ...state, + edit: { field: 'name', state: { ...state.edit.state, isSubmitting: false, errors: action.errors } }, + }; + } + return { + ...state, + edit: { field: 'username', state: { ...state.edit.state, isSubmitting: false, errors: action.errors } }, + }; + } + case 'edit.close': + return { ...state, edit: null }; + case 'identity.set': + return { ...state, identity: { ...state.identity, ...action.identity } }; + case 'contacts.add': + return updateContacts(state, action.kind, records => [...records, action.record]); + case 'contacts.remove': + return updateContacts(state, action.kind, records => records.filter(record => record.id !== action.id)); + case 'contacts.setPrimary': + return updateContacts(state, action.kind, records => + records.map(record => ({ ...record, isDefault: record.id === action.id })), + ); + } +} + +export interface UseAccountSectionFlowOptions { + config?: Partial; + initialEmails?: ContactRecord[]; + initialPhones?: ContactRecord[]; + initialIdentity?: ProfileIdentity; +} + +const EMPTY_IDENTITY: ProfileIdentity = { firstName: '', lastName: '', username: '' }; + +export function useAccountSectionFlow({ + config, + initialEmails = [], + initialPhones = [], + initialIdentity = EMPTY_IDENTITY, +}: UseAccountSectionFlowOptions = {}) { + const settings = useMemo(() => ({ ...DEFAULT_ACCOUNT_SECTION_FLOW_CONFIG, ...config }), [config]); + const settingsRef = useRef(settings); + settingsRef.current = settings; + + const initialState: FlowState = { + add: null, + edit: null, + identity: initialIdentity, + confirm: null, + reverification: null, + emails: initialEmails, + phones: initialPhones, + }; + const [state, reactDispatch] = useReducer(reducer, initialState); + + /** + * A shadow copy of the reducer state, advanced synchronously on every dispatch. + * + * An actor processes `send` before the next `send` is handled, so a machine reading its own + * context always sees the latest one. `useReducer` does not: the state a callback closes over is + * the last COMMITTED one. That gap is not theoretical here — `CodeInput` calls `onChange` and + * then `onComplete` in the same event handler, so a submit triggered by the final digit would + * read the code as it stood five digits ago and never match. Reading `stateRef.current` instead + * of `state` gives the callbacks the semantics the machine will have. + */ + const stateRef = useRef(initialState); + const dispatch = useCallback((action: Action) => { + stateRef.current = reducer(stateRef.current, action); + reactDispatch(action); + }, []); + + /** + * Resolves once the caller's reverification challenge is answered. Held in a ref so the async + * mutation that raised the challenge can await it and then carry on where it left off — which is + * exactly what `useReverification` does to every mutation it wraps. + */ + const reverificationGate = useRef<{ resolve: (ok: boolean) => void } | null>(null); + + /** + * The element that opened the current flow, so focus can be returned to it. + * + * These dialogs are opened from state rather than from a `Dialog.Trigger`, and with no trigger + * the primitive has nothing to return focus to — it lands on the body and the row you were on is + * lost. Capturing `document.activeElement` at open time is the same thing a trigger would have + * given us, and it is what a controller will do once the machine owns this. + */ + const triggerRef = useRef(null); + const captureTrigger = useCallback(() => { + const active = document.activeElement; + triggerRef.current = active instanceof HTMLElement ? active : null; + }, []); + + const sleep = useCallback((ms: number) => new Promise(resolve => setTimeout(resolve, ms)), []); + + /** Runs the reverification gate, if configured, and reports whether the caller may proceed. */ + const gate = useCallback(async () => { + if (!settingsRef.current.requireReverification) { + return true; + } + const strategy = settingsRef.current.reverificationStrategy; + dispatch({ + type: 'reverification.open', + state: { + strategy, + identifier: strategy === 'password' ? undefined : 'i••••@clerk.dev', + value: '', + status: 'idle', + errors: {}, + resend: IDLE_RESEND, + }, + }); + return new Promise(resolve => { + reverificationGate.current = { resolve }; + }); + }, [dispatch]); + + const resolveStrategy = useCallback((kind: ContactKind, identifier: string): ContactVerificationStrategy => { + if (kind === 'phone') { + return 'phone_code'; + } + const domain = identifier.split('@')[1]?.toLowerCase() ?? ''; + if (settingsRef.current.ssoDomains.includes(domain)) { + return 'enterprise_sso'; + } + return settingsRef.current.emailStrategy; + }, []); + + // Drives both resend cooldowns. One interval for the whole hook; `add.tick` is a no-op when + // nothing is counting down. + useEffect(() => { + const id = setInterval(() => dispatch({ type: 'add.tick' }), 1000); + return () => clearInterval(id); + }, [dispatch]); + + // Resolves a pending email link after its configured delay, standing in for the poll the machine + // will own. `emailLinkResolveMs: 0` waits forever, so the waiting screen can be inspected. + const addStep = state.add?.state.step; + const addIdentifier = state.add?.state.step === 'link' ? state.add.state.identifier : undefined; + const linkOutcomeSettled = state.add?.state.step === 'link' ? Boolean(state.add.state.outcome) : false; + useEffect(() => { + if (addStep !== 'link' || linkOutcomeSettled || !addIdentifier || settings.emailLinkResolveMs <= 0) { + return; + } + const id = setTimeout(() => { + if (settingsRef.current.emailLinkOutcome === 'verified') { + dispatch({ type: 'contacts.add', kind: 'email', record: makeRecord(addIdentifier, true) }); + dispatch({ type: 'add.success', identifier: addIdentifier }); + } else { + dispatch({ type: 'add.linkOutcome', outcome: settingsRef.current.emailLinkOutcome }); + } + }, settings.emailLinkResolveMs); + return () => clearTimeout(id); + }, [addStep, addIdentifier, dispatch, linkOutcomeSettled, settings.emailLinkResolveMs]); + + const openAdd = useCallback( + (kind: ContactKind) => { + captureTrigger(); + dispatch({ type: 'add.open', kind }); + }, + [captureTrigger, dispatch], + ); + const closeAdd = useCallback(() => { + reverificationGate.current?.resolve(false); + reverificationGate.current = null; + dispatch({ type: 'reverification.close' }); + dispatch({ type: 'add.close' }); + }, [dispatch]); + + const submitIdentifier = useCallback(async () => { + const current = stateRef.current.add; + if (current?.state.step !== 'identifier') { + return; + } + const identifier = current.state.value.trim(); + const { kind } = current; + + dispatch({ type: 'add.submitting' }); + if (!(await gate())) { + dispatch({ type: 'add.error', errors: {} }); + return; + } + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'add.error', errors: { form: 'Something went wrong. Please try again.' } }); + return; + } + if (kind === 'email' && !/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(identifier)) { + dispatch({ type: 'add.error', errors: { field: 'Enter a valid email address.' } }); + return; + } + if (kind === 'phone' && identifier.replace(/\D/g, '').length < 8) { + dispatch({ type: 'add.error', errors: { field: 'Enter a valid phone number.' } }); + return; + } + if (settingsRef.current.takenIdentifiers.includes(identifier.toLowerCase())) { + dispatch({ + type: 'add.error', + errors: { field: `That ${kind === 'email' ? 'email address' : 'phone number'} is taken. Please try another.` }, + }); + return; + } + + const strategy = resolveStrategy(kind, identifier); + dispatch({ type: 'add.preparing', identifier, strategy }); + // `prepareVerification`. Legacy fires this beneath an already-rendered code screen; here it + // gets its own step so the latency is visible rather than invisible-until-it-fails. + await sleep(settingsRef.current.latencyMs); + dispatch({ type: 'add.verify', identifier, strategy, providerName: settingsRef.current.ssoProviderName }); + }, [dispatch, gate, resolveStrategy, sleep]); + + const submitCode = useCallback(async () => { + const current = stateRef.current.add; + if (current?.state.step !== 'code' || current.state.status === 'verifying') { + return; + } + const { code, identifier } = current.state; + const { kind } = current; + + dispatch({ type: 'add.codeStatus', status: 'verifying' }); + await sleep(settingsRef.current.latencyMs); + + if (code !== settingsRef.current.validCode) { + dispatch({ type: 'add.codeStatus', status: 'error', message: 'Incorrect code. Please try again.' }); + return; + } + dispatch({ type: 'add.codeStatus', status: 'success' }); + dispatch({ type: 'contacts.add', kind, record: makeRecord(identifier, true) }); + // Hold on the check mark before moving on, as the legacy OTP control does. + await sleep(600); + dispatch({ type: 'add.success', identifier }); + }, [dispatch, sleep]); + + const resend = useCallback(async () => { + const current = stateRef.current.add; + if (current?.state.step !== 'code' && current?.state.step !== 'link') { + return; + } + const isLink = current.state.step === 'link'; + dispatch({ type: 'add.resend', isResending: true }); + await sleep(settingsRef.current.latencyMs); + dispatch({ type: 'add.resend', isResending: false, cooldown: isLink ? LINK_RESEND_COOLDOWN_S : 0 }); + }, [dispatch, sleep]); + + const openSsoPopup = useCallback(async () => { + const current = stateRef.current.add; + if (current?.state.step !== 'sso') { + return; + } + const { identifier } = current.state; + dispatch({ type: 'add.ssoStatus', status: 'awaiting_popup' }); + await sleep(settingsRef.current.latencyMs * 2); + + if (settingsRef.current.ssoFails) { + dispatch({ type: 'add.ssoStatus', status: 'error', message: 'Verification was cancelled or failed.' }); + return; + } + dispatch({ type: 'contacts.add', kind: 'email', record: makeRecord(identifier, true) }); + dispatch({ type: 'add.success', identifier }); + }, [dispatch, sleep]); + + const openEdit = useCallback( + (field: ProfileField) => { + captureTrigger(); + const { identity } = stateRef.current; + dispatch({ + type: 'edit.open', + field, + identity, + hasUsername: Boolean(identity.username), + // Only the name is locked by an enterprise connection; username and avatar stay editable. + readOnly: field === 'name' && settingsRef.current.enterpriseManaged, + }); + }, + [captureTrigger, dispatch], + ); + + const closeEdit = useCallback(() => { + reverificationGate.current?.resolve(false); + reverificationGate.current = null; + dispatch({ type: 'reverification.close' }); + dispatch({ type: 'edit.close' }); + }, [dispatch]); + + const submitEdit = useCallback(async () => { + const current = stateRef.current.edit; + if (!current) { + return; + } + + if (current.field === 'name') { + if (current.state.isReadOnly || current.state.isSubmitting) { + return; + } + const { firstName, lastName } = current.state; + dispatch({ type: 'edit.busy', busy: true }); + // `user.update({ firstName, lastName })` is NOT wrapped in `useReverification` in the legacy + // profile page, so no gate here — unlike the username below. + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); + return; + } + if (!firstName.trim()) { + dispatch({ type: 'edit.error', errors: { firstName: 'Enter a first name.' } }); + return; + } + dispatch({ type: 'identity.set', identity: { firstName: firstName.trim(), lastName: lastName.trim() } }); + dispatch({ type: 'edit.close' }); + return; + } + + if (current.field === 'username') { + if (current.state.isSubmitting) { + return; + } + const { value } = current.state; + dispatch({ type: 'edit.busy', busy: true }); + // The legacy `UsernameForm` wraps its update in `useReverification`. + if (!(await gate())) { + dispatch({ type: 'edit.error', errors: { form: 'Verification was cancelled.' } }); + return; + } + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); + return; + } + const trimmed = value.trim(); + if (trimmed.length < 4) { + dispatch({ type: 'edit.error', errors: { field: 'Username must be at least 4 characters.' } }); + return; + } + if ( + trimmed.toLowerCase() !== stateRef.current.identity.username.toLowerCase() && + settingsRef.current.takenUsernames.includes(trimmed.toLowerCase()) + ) { + dispatch({ type: 'edit.error', errors: { field: 'That username is taken. Please try another.' } }); + return; + } + dispatch({ type: 'identity.set', identity: { username: trimmed } }); + dispatch({ type: 'edit.close' }); + return; + } + + if (current.state.status !== 'idle' || !current.state.fileName) { + return; + } + const nextUrl = current.state.previewUrl; + dispatch({ type: 'edit.busy', busy: true, status: 'uploading' }); + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); + return; + } + dispatch({ type: 'identity.set', identity: { imageUrl: nextUrl } }); + dispatch({ type: 'edit.close' }); + }, [dispatch, gate, sleep]); + + /** + * Type and size are checked before anything is sent, matching the legacy uploader — a rejected + * file never reaches the server, so the error is local and instant regardless of latency. + */ + const selectAvatarFile = useCallback( + (file: File) => { + if (!AVATAR_MIME_TYPES.includes(file.type)) { + dispatch({ type: 'edit.error', errors: { field: 'Use a PNG, JPEG, GIF or WebP image.' } }); + return; + } + if (file.size > AVATAR_MAX_BYTES) { + dispatch({ type: 'edit.error', errors: { field: 'That image is larger than 10MB.' } }); + return; + } + dispatch({ type: 'edit.avatarFile', fileName: file.name, previewUrl: URL.createObjectURL(file) }); + }, + [dispatch], + ); + + const removeAvatar = useCallback(async () => { + const current = stateRef.current.edit; + if (current?.field !== 'avatar' || current.state.status !== 'idle') { + return; + } + dispatch({ type: 'edit.busy', busy: true, status: 'removing' }); + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); + return; + } + dispatch({ type: 'identity.set', identity: { imageUrl: undefined } }); + dispatch({ type: 'edit.close' }); + }, [dispatch, sleep]); + + const openConfirm = useCallback( + (pending: PendingConfirm, identifier: string) => { + captureTrigger(); + dispatch({ type: 'confirm.open', pending, identifier }); + }, + [captureTrigger, dispatch], + ); + const closeConfirm = useCallback(() => { + reverificationGate.current?.resolve(false); + reverificationGate.current = null; + dispatch({ type: 'reverification.close' }); + dispatch({ type: 'confirm.close' }); + }, [dispatch]); + + const submitConfirm = useCallback(async () => { + const current = stateRef.current.confirm; + if (!current) { + return; + } + dispatch({ type: 'confirm.submitting' }); + if (!(await gate())) { + dispatch({ type: 'confirm.error', message: 'Verification was cancelled.' }); + return; + } + await sleep(settingsRef.current.latencyMs); + + if (settingsRef.current.failWithFormError) { + dispatch({ type: 'confirm.error', message: 'Something went wrong. Please try again.' }); + return; + } + const { pending } = current; + if (pending.action === 'remove') { + dispatch({ type: 'contacts.remove', kind: pending.kind, id: pending.id }); + } else { + dispatch({ type: 'contacts.setPrimary', kind: pending.kind, id: pending.id }); + } + dispatch({ type: 'confirm.close' }); + }, [dispatch, gate, sleep]); + + const submitReverification = useCallback(async () => { + const current = stateRef.current.reverification; + if (!current || current.status === 'verifying') { + return; + } + dispatch({ type: 'reverification.status', status: 'verifying' }); + await sleep(settingsRef.current.latencyMs); + + const expected = + current.strategy === 'password' ? settingsRef.current.validPassword : settingsRef.current.validCode; + if (current.value !== expected) { + dispatch({ + type: 'reverification.status', + status: 'error', + message: current.strategy === 'password' ? 'Incorrect password.' : 'Incorrect code. Please try again.', + }); + return; + } + dispatch({ type: 'reverification.close' }); + reverificationGate.current?.resolve(true); + reverificationGate.current = null; + }, [dispatch, sleep]); + + const cancelReverification = useCallback(() => { + dispatch({ type: 'reverification.close' }); + reverificationGate.current?.resolve(false); + reverificationGate.current = null; + }, [dispatch]); + + const resendReverification = useCallback(async () => { + dispatch({ type: 'reverification.resend', isResending: true }); + await sleep(settingsRef.current.latencyMs); + dispatch({ type: 'reverification.resend', isResending: false }); + }, [dispatch, sleep]); + + return { + emails: state.emails, + phones: state.phones, + add: state.add, + edit: state.edit, + triggerRef, + identity: state.identity, + confirm: state.confirm, + reverification: state.reverification, + openAdd, + closeAdd, + setIdentifier: useCallback((value: string) => dispatch({ type: 'add.value', value }), [dispatch]), + setCode: useCallback((code: string) => dispatch({ type: 'add.code', code }), [dispatch]), + submitIdentifier, + submitCode, + resend, + openSsoPopup, + openEdit, + closeEdit, + submitEdit, + selectAvatarFile, + removeAvatar, + setName: useCallback( + (key: 'firstName' | 'lastName', value: string) => dispatch({ type: 'edit.name', key, value }), + [dispatch], + ), + setUsername: useCallback((value: string) => dispatch({ type: 'edit.username', value }), [dispatch]), + openConfirm, + closeConfirm, + submitConfirm, + setReverificationValue: useCallback( + (value: string) => dispatch({ type: 'reverification.value', value }), + [dispatch], + ), + submitReverification, + cancelReverification, + resendReverification, + }; +} + +let recordCounter = 0; +function makeRecord(value: string, isVerified: boolean): ContactRecord { + recordCounter += 1; + return { id: `contact_${recordCounter}`, value, isVerified }; +} diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.mdx b/packages/swingset/src/stories/user-profile-account-section-flow.mdx new file mode 100644 index 00000000000..edff565e840 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-account-section-flow.mdx @@ -0,0 +1,71 @@ +import * as Stories from './user-profile-account-section-flow.stories'; + +# Account section flow + +Every action on the account section that opens a dialog — adding, verifying, removing and promoting +an email address or a phone number, plus editing the name, username and profile picture — driven by +a simulated backend that takes time and can fail. + +The views are pure: `AddContactDialogView` branches on `state.step` and sends events back, and +holds no flow state of its own. Everything that decides _when_ a step changes lives in +`user-profile-account-section-flow.harness.ts` in swingset, which is what a state machine and its +Clerk controller will replace. Each simulated delay there becomes a machine `invoke`; each branch, a +guard. + +Three verification strategies are covered, because the legacy `EmailForm` picks between them from +the environment rather than from anything the user did: a code, an email link (its own waiting +screen, a 60-second throttled resend, and four terminal outcomes), and enterprise SSO. Turning on +**Require reverification** stacks a challenge over whichever surface raised it — the interrupted +dialog stays mounted and inert underneath, and the flow resumes there once the challenge clears. + +Set-as-primary gets a confirmation it does not have today. Legacy fires it straight from the +three-dots menu, which leaves an async call that can fail with nowhere to report the failure. + +The three profile fields are single-step forms rather than flows, but they are not interchangeable: +only the **username** update is wrapped in `useReverification` upstream, so it is the one a +challenge interrupts; only the **name** goes read-only under an enterprise connection (toggle +**Enterprise-managed**); and the **avatar** validates type and size locally, so those two errors +appear instantly no matter what the latency is set to. + + + +## Every contact state + +The same view with no backend behind it — one snapshot in, one surface out. This is the contract +from the machine's side, and the list a parity audit checks against the legacy flows. + + + +## Every profile-field state + +The single-step forms, on the same terms. + + + +## Standing in for components Mosaic does not have yet + +The inputs here are deliberately rough and marked with `TODO` at their definitions. The code field +is a single input styled to look segmented, the country picker is a native `` with no drag-and-drop, and the unattributed-error banner +is a hand-rolled `role="alert"` box. Each is a placeholder for a real +Mosaic component — the OTP and Select ones already have unstyled `@clerk/headless` primitives +waiting behind them. + +`DialogHeader` / `DialogBody` / `DialogFooter` are the same kind of placeholder, but `DialogBody` +is load bearing rather than cosmetic: a `prompt` clips its overflow under 48rem, so without a +scroll region a tall form loses its submit button off the bottom of the phone sheet. diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx new file mode 100644 index 00000000000..86749c6fc19 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx @@ -0,0 +1,1022 @@ +import type { DialogOpenChangeDetails } from '@clerk/headless/dialog'; +import { Freeze } from '@clerk/headless/utils'; +import { AlertDialog, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog'; +import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { AddContactDialogView } from '@clerk/ui/mosaic/user-profile/dialogs/add-contact-dialog.view'; +import { + RemoveContactDialogView, + SetPrimaryContactDialogView, +} from '@clerk/ui/mosaic/user-profile/dialogs/confirm-contact-dialog.view'; +import { + EditAvatarDialogView, + EditNameDialogView, + EditUsernameDialogView, +} from '@clerk/ui/mosaic/user-profile/dialogs/edit-profile-dialog.view'; +import type { + AddContactFlowState, + EditAvatarState, + EditNameState, + EditUsernameState, +} from '@clerk/ui/mosaic/user-profile/dialogs/flow.types'; +import { ReverificationDialogView } from '@clerk/ui/mosaic/user-profile/dialogs/reverification-dialog.view'; +import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; +import { useId, useMemo, useRef, useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +import type { AccountSectionFlowConfig } from './user-profile-account-section-flow.harness'; +import { + DEFAULT_ACCOUNT_SECTION_FLOW_CONFIG, + useAccountSectionFlow, +} from './user-profile-account-section-flow.harness'; + +export { default as __source } from './user-profile-account-section-flow.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + title: 'UserProfileAccountSectionFlow', + label: 'Account', + layout: 'wide', + navigation: { category: 'Flows' }, + source: 'packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx', +}; + +const INITIAL_EMAILS = [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isVerified: true }, +]; + +const INITIAL_PHONES = [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }]; + +const INITIAL_IDENTITY = { + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', +}; + +/** + * The dialogs a contact flow mounts, wired to the simulated backend. + * + * Each surface is wrapped in `Freeze`, held while its state is null. A dialog stays mounted for the + * length of its exit transition, but the state driving it is cleared the moment it closes — so + * without this the contents blank out and the surface visibly collapses on the way out. Freezing + * holds the last committed frame until the dialog is actually gone. + * + * `AddContactDialogView` is the only surface that changes shape as the flow runs; the two + * confirmations and the reverification challenge are each one state. The challenge is stacked over + * whatever raised it, so its dialog is a sibling rather than a step — the interrupted surface stays + * mounted and inert underneath, and the flow resumes there when the challenge clears. + */ +function ContactFlowDialogs({ flow }: { flow: ReturnType }) { + const { add, edit, confirm, reverification } = flow; + const initials = `${flow.identity.firstName.at(0) ?? ''}${flow.identity.lastName.at(0) ?? ''}`.toUpperCase(); + + // One handle per mounted dialog, not module scope: two of these sharing single-flight state + // would let one dialog's question answer the other's. + const discardConfirm = useMemo(() => AlertDialog.createConfirmHandle(), []); + + /** + * The control that had focus inside the edit dialog when the discard question was raised, so + * "Keep editing" puts the caret back where it was rather than on the dialog itself. + * + * Recorded as focus moves rather than read at close time: the primitive's focus machinery runs + * synchronously on the close request, so by the time the question is asked the answer would + * already be wrong. + */ + const lastEditFocus = useRef(null); + const rememberEditFocus = (event: React.FocusEvent) => { + const target = event.target; + // React portals bubble through the React tree, so the confirmation's own buttons reach this + // handler too. Recording those would return focus to a button that no longer exists. + if (!target.closest('[role="alertdialog"]')) { + lastEditFocus.current = target; + } + }; + + // All three profile forms share one dialog, so one guard covers them — each field just reports + // dirtiness its own way. + const isEditDirty = () => { + if (!edit) { + return false; + } + if (edit.field === 'name') { + return edit.state.firstName !== flow.identity.firstName || edit.state.lastName !== flow.identity.lastName; + } + if (edit.field === 'username') { + return edit.state.value !== flow.identity.username; + } + return Boolean(edit.state.fileName); + }; + + const onEditOpenChange: (open: boolean, details: DialogOpenChangeDetails) => void = useConfirmedClose({ + handle: discardConfirm, + when: isEditDirty, + onOpenChange: open => { + if (!open) { + flow.closeEdit(); + } + }, + confirm: { + title: 'Discard changes?', + description: 'Your edits will be lost.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); + + const cancelEdit = () => onEditOpenChange(false, PROGRAMMATIC_CLOSE); + const confirmKind = confirm?.pending.kind ?? 'email'; + const confirmRecord = + confirm && (confirmKind === 'email' ? flow.emails : flow.phones).find(item => item.id === confirm.pending.id); + + return ( + <> + { + if (!open) { + flow.closeAdd(); + } + }} + > + + {add ? ( + void flow.openSsoPopup()} + onResend={() => void flow.resend()} + onSubmitCode={() => void flow.submitCode()} + onSubmitIdentifier={() => void flow.submitIdentifier()} + onValueChange={flow.setIdentifier} + /> + ) : null} + + + + + + {edit?.field === 'name' ? ( + flow.setName('firstName', value)} + onLastNameChange={value => flow.setName('lastName', value)} + onSubmit={() => void flow.submitEdit()} + /> + ) : null} + {edit?.field === 'username' ? ( + void flow.submitEdit()} + onValueChange={flow.setUsername} + /> + ) : null} + {edit?.field === 'avatar' ? ( + void flow.removeAvatar()} + onSelectFile={flow.selectAvatarFile} + onSubmit={() => void flow.submitEdit()} + /> + ) : null} + + {/* Belongs INSIDE the dialog it guards, so the two share a floating tree, escape ordering, + the stacking treatment and the refcounted scroll lock. */} + + + + { + if (!open) { + flow.closeConfirm(); + } + }} + > + + {confirm ? ( + confirm.pending.action === 'remove' ? ( + void flow.submitConfirm()} + /> + ) : ( + void flow.submitConfirm()} + /> + ) + ) : null} + + + + { + if (!open) { + flow.cancelReverification(); + } + }} + > + + {reverification ? ( + void flow.resendReverification()} + onSubmit={() => void flow.submitReverification()} + onValueChange={flow.setReverificationValue} + /> + ) : null} + + + + ); +} + +// `StoryEmbed` centres a story inside `flex items-center justify-center`, so a fragment's children +// become flex items in a row. Stories here own a column wrapper rather than leaving the controls +// sitting beside the component. +const storyColumn = { display: 'flex', flexDirection: 'column', width: '100%' } as const; + +// One control per line: these are independent conditions rather than a related set, and a wrapped +// row made it ambiguous which options belonged to which label. +/** A close this code issues rather than one the user gestured, for routing Cancel through a guard. */ +const PROGRAMMATIC_CLOSE: DialogOpenChangeDetails = { trigger: null, triggerId: null, event: undefined }; + +/** + * A `Dialog` assembled from the compound parts, so it can take `finalFocus`. + * + * The `Dialog` wrapper deliberately does not forward focus props — purpose-built chrome is meant + * to talk to `Dialog.Root` / `Dialog.Popup` directly instead of widening the generic wrapper. This + * is that chrome, in the smallest form the story needs. + */ +function FlowDialog({ + open, + finalFocus, + closedBy, + onOpenChange, + onFocusCapture, + children, +}: { + open: boolean; + finalFocus?: React.RefObject; + closedBy?: 'any' | 'closerequest' | 'none'; + onOpenChange: (open: boolean, details: DialogOpenChangeDetails) => void; + onFocusCapture?: React.FocusEventHandler; + children: React.ReactNode; +}) { + return ( + + + + + + {children} + + + + + ); +} + +const controlsBar = { + alignItems: 'flex-start', + border: '1px solid var(--cl-color-border)', + borderRadius: '0.5rem', + display: 'flex', + flexDirection: 'column', + fontSize: '0.8125rem', + gap: '0.5rem', + marginBottom: '1.5rem', + padding: '0.75rem 1rem', +} as const; + +const controlLabel = { alignItems: 'center', display: 'flex', gap: '0.375rem' } as const; + +/** The name of a control, so the eye can find it before reading its options. */ +const controlName = { fontWeight: 600 } as const; + +const radioGroup = { + alignItems: 'center', + display: 'flex', + flexWrap: 'wrap', + gap: '0.375rem 0.75rem', +} as const; + +const radioOption = { alignItems: 'center', display: 'flex', gap: '0.25rem' } as const; + +/** + * A set of mutually exclusive options, laid out so every branch is visible without opening + * anything — a story control exists to show what the flow can do, which a collapsed ` onChange(option)} + /> + {option} + + ))} + + ); +} + +const snapshotPicker = { display: 'flex', flexDirection: 'column', gap: '0.25rem', width: '100%' } as const; + +const snapshotRow = { alignItems: 'center', display: 'flex', gap: '0.5rem' } as const; + +const snapshotStep = { + color: 'var(--cl-color-neutral-faded)', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: '0.75rem', + minWidth: '5.5rem', + textAlign: 'end', +} as const; + +const snapshotOptions = { display: 'flex', flexWrap: 'wrap', gap: '0.25rem' } as const; + +const snapshotChip = { + alignItems: 'center', + border: '1px solid var(--cl-color-border)', + borderRadius: '0.375rem', + color: 'inherit', + cursor: 'pointer', + display: 'flex', + font: 'inherit', + gap: '0.25rem', + backgroundColor: 'transparent', + padding: '0.125rem 0.5rem', +} as const; + +// Marks the snapshot the dialog is showing RIGHT NOW. Once it closes nothing is highlighted: +// a chip left lit after the dialog is gone reads as a persistent selection rather than as the +// state currently on screen. +const snapshotChipSelected = { + ...snapshotChip, + borderColor: 'var(--cl-color-primary)', + backgroundColor: 'var(--cl-color-primary-faded)', + fontWeight: 600, +} as const; + +export interface Snapshot { + /** The flow step, which groups the options. */ + step: string; + /** What distinguishes this snapshot from the others on the same step. */ + variant: string; + state: State; +} + +/** + * The snapshot list, grouped by step. Each entry is a button that selects the snapshot AND opens + * the dialog on it, so there is no separate trigger to press afterwards. + * + * Flat `step · variant` chips in one wrapped row were unreadable: half the variants repeat across + * steps (three separate `idle`s), so the eye had to re-read the prefix on every chip to place it. + * One row per step with the step named once turns that into a shape you can scan — and it happens + * to be the shape of the machine, which is the thing the story is documenting. + */ +function SnapshotPicker({ + snapshots, + selected, + onSelect, +}: { + snapshots: readonly Snapshot[]; + /** Index currently on screen, or `null` when the dialog is closed and nothing is showing. */ + selected: number | null; + onSelect: (index: number) => void; +}) { + const steps: string[] = []; + for (const snapshot of snapshots) { + if (!steps.includes(snapshot.step)) { + steps.push(snapshot.step); + } + } + + return ( +
+ Snapshot + {steps.map(step => ( +
+ {step} +
+ {snapshots.map((snapshot, index) => + snapshot.step === step ? ( + + ) : null, + )} +
+
+ ))} +
+ ); +} + +const EMAIL_STRATEGIES = ['email_code', 'email_link'] as const; +const LINK_OUTCOMES = ['verified', 'verified_other_tab', 'expired', 'failed'] as const; +const REVERIFICATION_STRATEGIES = ['password', 'email_code'] as const; + +/** + * Stands in for swingset's variant knobs, which only describe StyleX variants. These are backend + * conditions rather than visual ones, so the story owns them. + */ +function Controls({ + config, + onChange, +}: { + config: AccountSectionFlowConfig; + onChange: (next: Partial) => void; +}) { + return ( +
+ + onChange({ emailStrategy })} + /> + onChange({ emailLinkOutcome })} + /> + + onChange({ reverificationStrategy })} + /> + + + + + code {config.validCode} · password {config.validPassword} · taken{' '} + {config.takenIdentifiers[0]} · SSO domain @{config.ssoDomains[0]} + +
+ ); +} + +/** + * The account panel with every contact action wired to a flow that takes time, fails, and asks for + * things — the shape a state machine will drive once one exists. + */ +export function Default() { + const [config, setConfig] = useState(DEFAULT_ACCOUNT_SECTION_FLOW_CONFIG); + const flow = useAccountSectionFlow({ + config, + initialEmails: INITIAL_EMAILS, + initialIdentity: INITIAL_IDENTITY, + initialPhones: INITIAL_PHONES, + }); + + return ( +
+ setConfig(current => ({ ...current, ...next }))} + /> + flow.openAdd('email')} + onAddPhone={() => flow.openAdd('phone')} + onEditProfilePicture={() => flow.openEdit('avatar')} + onNameChange={() => flow.openEdit('name')} + onUsernameChange={() => flow.openEdit('username')} + onRemoveEmail={id => { + const record = flow.emails.find(email => email.id === id); + if (record) { + flow.openConfirm({ action: 'remove', kind: 'email', id }, record.value); + } + }} + onRemovePhone={id => { + const record = flow.phones.find(phone => phone.id === id); + if (record) { + flow.openConfirm({ action: 'remove', kind: 'phone', id }, record.value); + } + }} + onSetPrimaryEmail={id => { + const record = flow.emails.find(email => email.id === id); + if (record) { + flow.openConfirm({ action: 'set-primary', kind: 'email', id }, record.value); + } + }} + onSetPrimaryPhone={id => { + const record = flow.phones.find(phone => phone.id === id); + if (record) { + flow.openConfirm({ action: 'set-primary', kind: 'phone', id }, record.value); + } + }} + onVerifyEmail={() => flow.openAdd('email')} + onVerifyPhone={() => flow.openAdd('phone')} + /> + +
+ ); +} + +/** + * Every rendered state of the add-email flow, side by side, with no backend behind them. This is + * what the view contract looks like from the machine's side: one snapshot in, one surface out. + */ +export function States() { + const [index, setIndex] = useState(0); + const [open, setOpen] = useState(false); + + // Advancing the flow is inert on purpose — the rendered step is whichever snapshot is selected, + // so a working Verify would contradict the radio. Cancel is the exception: it dismisses rather + // than advances, and leaving it dead made it look broken next to the close button and Escape, + // which do work because they run through the dialog primitive rather than through these props. + const noop = () => undefined; + const actions = { + onCancel: () => setOpen(false), + onCodeChange: noop, + onOpenSsoPopup: noop, + onResend: noop, + onSubmitCode: noop, + onSubmitIdentifier: noop, + onValueChange: noop, + }; + const resend = { isResending: false, secondsRemaining: 0 }; + + const snapshots: readonly Snapshot[] = [ + { step: 'identifier', variant: 'idle', state: { step: 'identifier', value: '', isSubmitting: false, errors: {} } }, + { + step: 'identifier', + variant: 'field error', + state: { + step: 'identifier', + value: 'taken@clerk.dev', + isSubmitting: false, + errors: { field: 'That email address is taken. Please try another.' }, + }, + }, + { + step: 'identifier', + variant: 'unattributed error', + state: { + step: 'identifier', + value: 'new@clerk.dev', + isSubmitting: false, + errors: { form: 'Something went wrong. Please try again.' }, + }, + }, + { + step: 'identifier', + variant: 'submitting', + state: { step: 'identifier', value: 'new@clerk.dev', isSubmitting: true, errors: {} }, + }, + { + step: 'preparing', + variant: 'sending', + state: { step: 'preparing', identifier: 'new@clerk.dev', strategy: 'email_code' }, + }, + { + step: 'code', + variant: 'idle', + state: { + step: 'code', + identifier: 'new@clerk.dev', + strategy: 'email_code', + code: '', + status: 'idle', + errors: {}, + resend, + }, + }, + { + step: 'code', + variant: 'verifying', + state: { + step: 'code', + identifier: 'new@clerk.dev', + strategy: 'email_code', + code: '424242', + status: 'verifying', + errors: {}, + resend, + }, + }, + { + step: 'code', + variant: 'wrong code', + state: { + step: 'code', + identifier: 'new@clerk.dev', + strategy: 'email_code', + code: '', + status: 'error', + errors: { field: 'Incorrect code. Please try again.' }, + resend, + }, + }, + { + step: 'code', + variant: 'verified', + state: { + step: 'code', + identifier: 'new@clerk.dev', + strategy: 'email_code', + code: '424242', + status: 'success', + errors: {}, + resend, + }, + }, + { + step: 'link', + variant: 'waiting', + state: { + step: 'link', + identifier: 'new@clerk.dev', + resend: { isResending: false, secondsRemaining: 42 }, + errors: {}, + }, + }, + { + step: 'link', + variant: 'expired', + state: { step: 'link', identifier: 'new@clerk.dev', resend, outcome: 'expired', errors: {} }, + }, + { + step: 'link', + variant: 'invalid', + state: { step: 'link', identifier: 'new@clerk.dev', resend, outcome: 'failed', errors: {} }, + }, + { + step: 'link', + variant: 'verified in another tab', + state: { step: 'link', identifier: 'new@clerk.dev', resend, outcome: 'verified_other_tab', errors: {} }, + }, + { + step: 'sso', + variant: 'idle', + state: { step: 'sso', identifier: 'dev@acmecorp.com', providerName: 'Okta', status: 'idle', errors: {} }, + }, + { + step: 'sso', + variant: 'awaiting popup', + state: { + step: 'sso', + identifier: 'dev@acmecorp.com', + providerName: 'Okta', + status: 'awaiting_popup', + errors: {}, + }, + }, + { + step: 'sso', + variant: 'failed', + state: { + step: 'sso', + identifier: 'dev@acmecorp.com', + providerName: 'Okta', + status: 'error', + errors: { form: 'Verification was cancelled or failed.' }, + }, + }, + { step: 'success', variant: 'added', state: { step: 'success', identifier: 'new@clerk.dev' } }, + ]; + + return ( +
+
+ { + setIndex(pick); + setOpen(true); + }} + /> + + + +
+
+ ); +} + +/** What a profile snapshot renders, since the three forms take different props. */ +type ProfileSnapshotState = + | { field: 'name'; state: EditNameState } + | { field: 'username'; state: EditUsernameState } + | { field: 'avatar'; state: EditAvatarState }; + +/** + * Every rendered state of the three profile-field forms. Same contract as `States`, for the + * single-step surfaces: no backend, one snapshot in, one surface out. + */ +export function ProfileStates() { + const [index, setIndex] = useState(0); + const [open, setOpen] = useState(false); + + const noop = () => undefined; + const close = () => setOpen(false); + + const snapshots: readonly Snapshot[] = [ + { + step: 'name', + variant: 'idle', + state: { + field: 'name', + state: { firstName: 'Preston', lastName: 'Booth', isSubmitting: false, isReadOnly: false, errors: {} }, + }, + }, + { + step: 'name', + variant: 'field error', + state: { + field: 'name', + state: { + firstName: '', + lastName: 'Booth', + isSubmitting: false, + isReadOnly: false, + errors: { firstName: 'Enter a first name.' }, + }, + }, + }, + { + step: 'name', + variant: 'saving', + state: { + field: 'name', + state: { firstName: 'Preston', lastName: 'Booth', isSubmitting: true, isReadOnly: false, errors: {} }, + }, + }, + { + step: 'name', + variant: 'read-only', + state: { + field: 'name', + state: { firstName: 'Preston', lastName: 'Booth', isSubmitting: false, isReadOnly: true, errors: {} }, + }, + }, + { + step: 'username', + variant: 'set', + state: { field: 'username', state: { value: '', hasUsername: false, isSubmitting: false, errors: {} } }, + }, + { + step: 'username', + variant: 'update', + state: { + field: 'username', + state: { value: 'prestonxyz', hasUsername: true, isSubmitting: false, errors: {} }, + }, + }, + { + step: 'username', + variant: 'taken', + state: { + field: 'username', + state: { + value: 'prestonxyz', + hasUsername: true, + isSubmitting: false, + errors: { field: 'That username is taken. Please try another.' }, + }, + }, + }, + { + step: 'username', + variant: 'saving', + state: { field: 'username', state: { value: 'preston', hasUsername: true, isSubmitting: true, errors: {} } }, + }, + { + step: 'avatar', + variant: 'idle', + state: { field: 'avatar', state: { canRemove: false, status: 'idle', errors: {} } }, + }, + { + step: 'avatar', + variant: 'staged', + state: { + field: 'avatar', + state: { fileName: 'headshot.png', canRemove: true, status: 'idle', errors: {} }, + }, + }, + { + step: 'avatar', + variant: 'rejected type', + state: { + field: 'avatar', + state: { canRemove: true, status: 'idle', errors: { field: 'Use a PNG, JPEG, GIF or WebP image.' } }, + }, + }, + { + step: 'avatar', + variant: 'too large', + state: { + field: 'avatar', + state: { canRemove: true, status: 'idle', errors: { field: 'That image is larger than 10MB.' } }, + }, + }, + { + step: 'avatar', + variant: 'uploading', + state: { + field: 'avatar', + state: { fileName: 'headshot.png', canRemove: true, status: 'uploading', errors: {} }, + }, + }, + { + step: 'avatar', + variant: 'removing', + state: { field: 'avatar', state: { canRemove: true, status: 'removing', errors: {} } }, + }, + ]; + + const current = snapshots[index].state; + + return ( +
+
+ { + setIndex(pick); + setOpen(true); + }} + /> +
+ + {current.field === 'name' ? ( + + ) : null} + {current.field === 'username' ? ( + + ) : null} + {current.field === 'avatar' ? ( + + ) : null} + +
+ ); +} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx new file mode 100644 index 00000000000..e6ae7548c28 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx @@ -0,0 +1,221 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Dialog } from '../../components/dialog'; +import { MosaicProvider } from '../../MosaicProvider'; +import type { AddContactDialogViewProps } from '../dialogs/add-contact-dialog.view'; +import { AddContactDialogView } from '../dialogs/add-contact-dialog.view'; +import type { AddContactFlowState } from '../dialogs/flow.types'; + +const RESEND = { isResending: false, secondsRemaining: 0 }; + +function actions() { + return { + onValueChange: vi.fn(), + onSubmitIdentifier: vi.fn(), + onCodeChange: vi.fn(), + onSubmitCode: vi.fn(), + onResend: vi.fn(), + onOpenSsoPopup: vi.fn(), + onCancel: vi.fn(), + }; +} + +function renderView(state: AddContactFlowState, overrides: Partial = {}) { + const handlers = actions(); + render( + + + + + , + ); + return handlers; +} + +describe('AddContactDialogView', () => { + describe('identifier step', () => { + it('keeps submit disabled until the value is longer than a single character', async () => { + renderView({ step: 'identifier', value: 'a', isSubmitting: false, errors: {} }); + + expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('submits the identifier', async () => { + const handlers = renderView({ step: 'identifier', value: 'new@clerk.dev', isSubmitting: false, errors: {} }); + + await userEvent.click(screen.getByRole('button', { name: 'Add' })); + + expect(handlers.onSubmitIdentifier).toHaveBeenCalledOnce(); + }); + + it('renders a field error in the field and an unattributed error above the form', () => { + renderView({ + step: 'identifier', + value: 'taken@clerk.dev', + isSubmitting: false, + errors: { field: 'That email address is taken.', form: 'Something went wrong.' }, + }); + + expect(screen.getByText('That email address is taken.')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong.'); + }); + + it('locks the field and shows progress while submitting', () => { + renderView({ step: 'identifier', value: 'new@clerk.dev', isSubmitting: true, errors: {} }); + + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Adding…' })).toBeInTheDocument(); + }); + + it('goes inert behind a stacked reverification challenge', () => { + renderView( + { step: 'identifier', value: 'new@clerk.dev', isSubmitting: false, errors: {} }, + { isInterrupted: true }, + ); + + expect(screen.getByRole('textbox')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('renders a country picker alongside the number for a phone', () => { + renderView({ step: 'identifier', value: '+1', isSubmitting: false, errors: {} }, { kind: 'phone' }); + + expect(screen.getByRole('combobox', { name: 'Country' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Add phone number' })).toBeInTheDocument(); + }); + }); + + describe('preparing step', () => { + it('names the artefact being sent', () => { + renderView({ step: 'preparing', identifier: 'new@clerk.dev', strategy: 'email_link' }); + + expect(screen.getByText(/Sending a verification link to/)).toBeInTheDocument(); + }); + }); + + describe('code step', () => { + const codeState = { + step: 'code', + identifier: 'new@clerk.dev', + strategy: 'email_code', + code: '', + status: 'idle', + errors: {}, + resend: RESEND, + } as const; + + it('auto-submits once the final digit lands', async () => { + const handlers = renderView(codeState); + + // Pasted rather than typed: the view is controlled, so a spy `onCodeChange` never feeds the + // digits back and typing would never reach a sixth character. + await userEvent.click(screen.getByRole('textbox', { name: 'Verification code' })); + await userEvent.paste('424242'); + + expect(handlers.onCodeChange).toHaveBeenCalledWith('424242'); + expect(handlers.onSubmitCode).toHaveBeenCalledOnce(); + }); + + it('ignores non-digits and stops at the code length', async () => { + const handlers = renderView(codeState); + + await userEvent.click(screen.getByRole('textbox', { name: 'Verification code' })); + await userEvent.paste('42-4242-99'); + + expect(handlers.onCodeChange).toHaveBeenCalledWith('424242'); + }); + + it('reports a wrong code and keeps the field editable', () => { + renderView({ ...codeState, status: 'error', errors: { field: 'Incorrect code. Please try again.' } }); + + expect(screen.getByText('Incorrect code. Please try again.')).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Verification code' })).not.toBeDisabled(); + }); + + it('locks the field while verifying', () => { + renderView({ ...codeState, code: '424242', status: 'verifying' }); + + expect(screen.getByRole('textbox', { name: 'Verification code' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Verifying…' })).toBeInTheDocument(); + }); + + it('resends on request', async () => { + const handlers = renderView(codeState); + + await userEvent.click(screen.getByRole('button', { name: 'Resend' })); + + expect(handlers.onResend).toHaveBeenCalledOnce(); + }); + }); + + describe('link step', () => { + it('waits for the click and counts the resend cooldown down', () => { + renderView({ + step: 'link', + identifier: 'new@clerk.dev', + resend: { isResending: false, secondsRemaining: 42 }, + errors: {}, + }); + + expect(screen.getByText('Check your email')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Resend link (42s)' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it.each([ + ['expired', 'This verification link has expired'], + ['failed', 'This verification link is invalid'], + ['verified_other_tab', 'Successfully verified email address'], + ] as const)('renders the %s outcome', (outcome, heading) => { + renderView({ step: 'link', identifier: 'new@clerk.dev', resend: RESEND, outcome, errors: {} }); + + expect(screen.getByText(heading)).toBeInTheDocument(); + expect(screen.queryByText('Check your email')).not.toBeInTheDocument(); + }); + }); + + describe('sso step', () => { + it('offers the provider and then waits on it', async () => { + const handlers = renderView({ + step: 'sso', + identifier: 'dev@acmecorp.com', + providerName: 'Okta', + status: 'idle', + errors: {}, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Continue with Okta' })); + + expect(handlers.onOpenSsoPopup).toHaveBeenCalledOnce(); + }); + + it('offers a retry after a failed popup', () => { + renderView({ + step: 'sso', + identifier: 'dev@acmecorp.com', + providerName: 'Okta', + status: 'error', + errors: { form: 'Verification was cancelled or failed.' }, + }); + + expect(screen.getByRole('alert')).toHaveTextContent('Verification was cancelled or failed.'); + expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument(); + }); + }); + + describe('success step', () => { + it('confirms the contact was added', async () => { + const handlers = renderView({ step: 'success', identifier: 'new@clerk.dev' }); + + expect(screen.getByText(/was added to your account/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Done' })); + expect(handlers.onCancel).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx new file mode 100644 index 00000000000..dbbcdb57cea --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { AlertDialog } from '../../components/alert-dialog'; +import { Dialog } from '../../components/dialog'; +import { MosaicProvider } from '../../MosaicProvider'; +import { RemoveContactDialogView, SetPrimaryContactDialogView } from '../dialogs/confirm-contact-dialog.view'; +import type { ConfirmContactActionState } from '../dialogs/flow.types'; +import { ReverificationDialogView } from '../dialogs/reverification-dialog.view'; + +const IDLE: ConfirmContactActionState = { identifier: 'item2@clerk.dev', isSubmitting: false, errors: {} }; + +function renderAlert(children: React.ReactNode) { + render( + + {children} + , + ); +} + +describe('RemoveContactDialogView', () => { + it('warns about losing sign-in only for a verified contact', () => { + renderAlert( + , + ); + + expect(screen.getByText(/no longer be able to sign in/)).toBeInTheDocument(); + }); + + it('omits the sign-in warning for an unverified contact', () => { + renderAlert( + , + ); + + expect(screen.queryByText(/no longer be able to sign in/)).not.toBeInTheDocument(); + expect(screen.getByText(/will be removed from this account/)).toBeInTheDocument(); + }); + + it('confirms, then locks both actions while the removal is in flight', async () => { + const onConfirm = vi.fn(); + renderAlert( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Remove' })); + expect(onConfirm).toHaveBeenCalledOnce(); + }); + + it('reports a failure that has no field to land in', () => { + renderAlert( + , + ); + + expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong.'); + }); +}); + +describe('SetPrimaryContactDialogView', () => { + it('gives the promotion a surface of its own, with a pending state', () => { + renderAlert( + , + ); + + expect(screen.getByRole('heading', { name: 'Set as primary email address' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +describe('ReverificationDialogView', () => { + function renderChallenge(state: React.ComponentProps['state']) { + const handlers = { + onValueChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + onCancel: vi.fn(), + }; + render( + + + + + , + ); + return handlers; + } + + const resend = { isResending: false, secondsRemaining: 0 }; + + it('asks for a password, with no resend affordance', () => { + renderChallenge({ strategy: 'password', value: '', status: 'idle', errors: {}, resend }); + + expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password'); + expect(screen.queryByRole('button', { name: 'Resend' })).not.toBeInTheDocument(); + }); + + it('asks for a code, and offers a resend', async () => { + const handlers = renderChallenge({ + strategy: 'email_code', + identifier: 'i••••@clerk.dev', + value: '', + status: 'idle', + errors: {}, + resend, + }); + + expect(screen.getByRole('textbox', { name: 'Verification code' })).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Resend' })); + expect(handlers.onResend).toHaveBeenCalledOnce(); + }); + + it('reports a wrong answer', () => { + renderChallenge({ + strategy: 'password', + value: '', + status: 'error', + errors: { field: 'Incorrect password.' }, + resend, + }); + + expect(screen.getByText('Incorrect password.')).toBeInTheDocument(); + }); + + it('cancels the challenge, which is what declines the mutation underneath', async () => { + const handlers = renderChallenge({ strategy: 'password', value: 'x', status: 'idle', errors: {}, resend }); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(handlers.onCancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx new file mode 100644 index 00000000000..2f5f1b780c9 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx @@ -0,0 +1,243 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { Dialog } from '../../components/dialog'; +import { MosaicProvider } from '../../MosaicProvider'; +import { EditAvatarDialogView, EditNameDialogView, EditUsernameDialogView } from '../dialogs/edit-profile-dialog.view'; +import type { EditAvatarState, EditNameState, EditUsernameState } from '../dialogs/flow.types'; + +function renderDialog(children: React.ReactNode) { + render( + + {children} + , + ); +} + +const NAME: EditNameState = { + firstName: 'Preston', + lastName: 'Booth', + isSubmitting: false, + isReadOnly: false, + errors: {}, +}; + +function nameHandlers() { + return { + onFirstNameChange: vi.fn(), + onLastNameChange: vi.fn(), + onSubmit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe('EditNameDialogView', () => { + it('submits both names', async () => { + const handlers = nameHandlers(); + renderDialog( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(handlers.onSubmit).toHaveBeenCalledOnce(); + }); + + it('reports a per-field error against the field it belongs to', () => { + renderDialog( + , + ); + + expect(screen.getByText('Enter a first name.')).toBeInTheDocument(); + }); + + it('locks the fields and drops Save entirely when the account is enterprise-managed', () => { + renderDialog( + , + ); + + expect(screen.getByLabelText('First name')).toBeDisabled(); + expect(screen.getByLabelText('Last name')).toBeDisabled(); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + expect(screen.getByText(/managed by your organization/)).toBeInTheDocument(); + }); + + it('shows progress while saving', () => { + renderDialog( + , + ); + + expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +const USERNAME: EditUsernameState = { value: 'prestonxyz', hasUsername: true, isSubmitting: false, errors: {} }; + +function usernameHandlers() { + return { onValueChange: vi.fn(), onSubmit: vi.fn(), onCancel: vi.fn() }; +} + +describe('EditUsernameDialogView', () => { + it('titles itself for setting versus updating', () => { + const { unmount } = render( + + + + + , + ); + expect(screen.getByRole('heading', { name: 'Set username' })).toBeInTheDocument(); + unmount(); + + renderDialog( + , + ); + expect(screen.getByRole('heading', { name: 'Update username' })).toBeInTheDocument(); + }); + + it('reports a taken username in the field', () => { + renderDialog( + , + ); + + expect(screen.getByText('That username is taken. Please try another.')).toBeInTheDocument(); + }); + + it('goes inert behind a stacked reverification challenge', () => { + renderDialog( + , + ); + + expect(screen.getByLabelText('Username')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true'); + }); +}); + +const AVATAR: EditAvatarState = { canRemove: false, status: 'idle', errors: {} }; + +function avatarHandlers() { + return { onSelectFile: vi.fn(), onSubmit: vi.fn(), onRemove: vi.fn(), onCancel: vi.fn() }; +} + +describe('EditAvatarDialogView', () => { + it('keeps Upload disabled until a file is staged', () => { + renderDialog( + , + ); + + expect(screen.getByRole('button', { name: 'Upload' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('names the staged file and enables Upload', () => { + renderDialog( + , + ); + + expect(screen.getByText('headshot.png')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload' })).not.toHaveAttribute('aria-disabled', 'true'); + }); + + it('passes a chosen file up rather than validating it itself', async () => { + const handlers = avatarHandlers(); + renderDialog( + , + ); + + const file = new File(['x'], 'headshot.png', { type: 'image/png' }); + await userEvent.upload(screen.getByLabelText('Image file'), file); + + expect(handlers.onSelectFile).toHaveBeenCalledWith(file); + }); + + it('offers Remove only when there is an image to remove', () => { + const { unmount } = render( + + + + + , + ); + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument(); + unmount(); + + renderDialog( + , + ); + expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument(); + }); + + it('reports a locally rejected file in the field', () => { + renderDialog( + , + ); + + expect(screen.getByText('That image is larger than 10MB.')).toBeInTheDocument(); + }); + + it.each([ + ['uploading', 'Uploading…'], + ['removing', 'Removing…'], + ] as const)('shows progress while %s', (status, label) => { + renderDialog( + , + ); + + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + expect(screen.getByLabelText('Image file')).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx new file mode 100644 index 00000000000..ee3a563cb74 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx @@ -0,0 +1,398 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { Button } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import type { AddContactFlowActions, AddContactFlowState, ContactKind } from './flow.types'; +import { + CodeInput, + DialogBody, + DialogFooter, + DialogForm, + DialogHeader, + FormAlert, + Identifier, + MutedText, + PhoneInput, + ResendButton, + StatusPanel, +} from './flow-dialog-chrome'; +import { styles } from './flow-dialogs.styles'; + +export interface AddContactDialogViewProps extends AddContactFlowActions { + kind: ContactKind; + state: AddContactFlowState; + /** + * True while a reverification challenge is stacked over this dialog. The step underneath stays + * rendered but goes inert, so the flow visibly resumes where it left off once the challenge + * clears. + */ + isInterrupted?: boolean; +} + +const copy = { + email: { + title: 'Add email address', + hint: 'An email address must be verified before it can be added to your account.', + label: 'Email address', + placeholder: 'you@example.com', + submit: 'Add', + verifyTitle: 'Verify email address', + }, + phone: { + title: 'Add phone number', + hint: 'A text message containing a verification code will be sent to this phone number. Message and data rates may apply.', + label: 'Phone number', + placeholder: '201 555 0123', + submit: 'Add', + verifyTitle: 'Verify phone number', + }, +} as const satisfies Record>; + +/** + * Every rendered state of adding an email address or a phone number, from identifier entry through + * whichever verification strategy the flow selected. The strategy choice itself is not rendered — + * it is decided from the environment and the resource — but each of its three destinations is. + * + * The view holds no flow state of its own; it branches on `state.step` and sends events back. + */ +export function AddContactDialogView(props: AddContactDialogViewProps) { + const { kind, state, isInterrupted = false, onCancel } = props; + const text = copy[kind]; + + switch (state.step) { + case 'identifier': + return ( + + ); + case 'preparing': + return ( + <> + + + + + + Sending a verification {state.strategy === 'email_link' ? 'link' : 'code'} to{' '} + {state.identifier}… + + + + + ); + case 'code': + return ( + + ); + case 'link': + return ( + + ); + case 'sso': + return ( + + ); + case 'success': + return ( + <> + + + + + + {state.identifier} was added to your account. + + + + + + + + ); + } +} + +type StepProps = Omit & { + state: Extract; +}; + +function IdentifierStep({ + kind, + state, + isInterrupted = false, + onValueChange, + onSubmitIdentifier, + onCancel, +}: StepProps<'identifier'>) { + const text = copy[kind]; + const fieldId = React.useId(); + // Matches the legacy guard: anything past a single character, and never the current username. + const canSubmit = state.value.trim().length > 1 && !state.isSubmitting && !isInterrupted; + + return ( + <> + + + + + {state.errors.form} +
+ + {text.label} + {kind === 'phone' ? ( + + ) : ( + onValueChange(event.target.value)} + /> + )} + {state.errors.field ? {state.errors.field} : null} + +
+
+ + + + +
+ + ); +} + +function CodeStep({ + kind, + state, + isInterrupted = false, + onCodeChange, + onSubmitCode, + onResend, + onCancel, +}: StepProps<'code'>) { + const text = copy[kind]; + const fieldId = React.useId(); + const inert = isInterrupted || state.status === 'verifying' || state.status === 'success'; + + return ( + <> + + + Enter the verification code sent to {state.identifier} + + } + title={text.verifyTitle} + /> + + + {state.errors.form} + + Verification code + + {state.status === 'error' && state.errors.field ? {state.errors.field} : null} + +
+ Didn't receive a code? + +
+
+ + + + +
+ + ); +} + +const LINK_OUTCOME_COPY = { + verified_other_tab: { + tone: 'positive', + icon: 'check', + title: 'Successfully verified email address', + body: 'You may close this tab; verification completed in another window.', + }, + expired: { + tone: 'negative', + icon: 'alert-circle', + title: 'This verification link has expired', + body: 'Send yourself another link to finish adding this address.', + }, + failed: { + tone: 'negative', + icon: 'alert-circle', + title: 'This verification link is invalid', + body: 'Send yourself another link to finish adding this address.', + }, +} as const; + +function LinkStep({ kind, state, isInterrupted = false, onResend, onCancel }: StepProps<'link'>) { + const text = copy[kind]; + const outcome = state.outcome ? LINK_OUTCOME_COPY[state.outcome] : undefined; + + return ( + <> + + + + {state.errors.form} + {outcome ? ( + +

{outcome.title}

+ {outcome.body} +
+ ) : ( + +

Check your email

+ + A verification link was sent to {state.identifier}. Open it on this device to + finish. + +
+ )} +
+ + {/* Throttled to 60s and starting disabled, matching the legacy link card's TimerButton. */} + + + + + ); +} + +function SsoStep({ kind, state, isInterrupted = false, onOpenSsoPopup, onCancel }: StepProps<'sso'>) { + const text = copy[kind]; + + return ( + <> + + + {state.identifier} belongs to a domain managed by {state.providerName}. Verify with + them to add it. + + } + title={text.verifyTitle} + /> + + {state.errors.form} + {state.status === 'awaiting_popup' ? ( + + Waiting for {state.providerName}… + + ) : null} + + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx new file mode 100644 index 00000000000..4af292a3725 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx @@ -0,0 +1,119 @@ +import { AlertDialog } from '../../components/alert-dialog'; +import { Button } from '../../components/button'; +import type { ConfirmContactActionState, ContactKind } from './flow.types'; +import { FormAlert, Identifier } from './flow-dialog-chrome'; + +export interface RemoveContactDialogViewProps { + kind: ContactKind; + state: ConfirmContactActionState; + /** + * Legacy suppresses the "you will no longer be able to sign in" line on an unverified contact, + * because an unverified one was never usable for sign-in in the first place. + */ + isVerified: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const REMOVE_COPY = { + email: { + title: 'Remove email address', + line1: (identifier: string) => <>{identifier} will be removed from this account., + line2: 'You will no longer be able to sign in using this email address.', + action: 'Remove', + pending: 'Removing…', + }, + phone: { + title: 'Remove phone number', + line1: (identifier: string) => <>{identifier} will be removed from this account., + line2: 'You will no longer be able to sign in using this phone number.', + action: 'Remove', + pending: 'Removing…', + }, +} as const; + +/** Destructive confirmation for removing a contact. */ +export function RemoveContactDialogView({ + kind, + state, + isVerified, + onConfirm, + onCancel, +}: RemoveContactDialogViewProps) { + const text = REMOVE_COPY[kind]; + + return ( + <> + {text.title} + + {text.line1(state.identifier)} + {isVerified ? ` ${text.line2}` : null} + + {state.errors.form} + + + + + + ); +} + +export interface SetPrimaryContactDialogViewProps { + kind: ContactKind; + state: ConfirmContactActionState; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Confirmation for promoting a contact to primary. + * + * Legacy fires this straight from the three-dots menu with no surface of its own, which leaves an + * async call that can fail — it is wrapped in `useReverification` — with nowhere to report the + * failure. A dialog gives the pending and error states somewhere to live. + */ +export function SetPrimaryContactDialogView({ kind, state, onConfirm, onCancel }: SetPrimaryContactDialogViewProps) { + const noun = kind === 'email' ? 'email address' : 'phone number'; + + return ( + <> + Set as primary {noun} + + {state.identifier} will become the primary {noun} for this account and will receive + account notifications. + + {state.errors.form} + + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx new file mode 100644 index 00000000000..087224187a8 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx @@ -0,0 +1,259 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import type { + EditAvatarActions, + EditAvatarState, + EditNameActions, + EditNameState, + EditUsernameActions, + EditUsernameState, +} from './flow.types'; +import { DialogBody, DialogFooter, DialogForm, DialogHeader, FormAlert, MutedText } from './flow-dialog-chrome'; +import { styles } from './flow-dialogs.styles'; + +/** + * The three single-step profile forms. Unlike the contact flows there is nothing to verify, so + * each is one surface with a pending state and an error slot — but they are not all the same: + * the name form can be read-only, the username form is the one a reverification challenge is most + * likely to interrupt, and the avatar form validates locally before it ever calls the server. + */ + +export interface EditNameDialogViewProps extends EditNameActions { + state: EditNameState; + /** True while a reverification challenge is stacked over this dialog. */ + isInterrupted?: boolean; +} + +export function EditNameDialogView({ + state, + isInterrupted = false, + onFirstNameChange, + onLastNameChange, + onSubmit, + onCancel, +}: EditNameDialogViewProps) { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + const locked = state.isReadOnly || state.isSubmitting || isInterrupted; + + return ( + <> + + + + + {state.isReadOnly ? ( + Your profile information is managed by your organization and cannot be edited here. + ) : null} + {state.errors.form} +
+ + First name + onFirstNameChange(event.target.value)} + /> + {state.errors.firstName ? {state.errors.firstName} : null} + + + Last name + onLastNameChange(event.target.value)} + /> + {state.errors.lastName ? {state.errors.lastName} : null} + +
+
+ + + {/* A read-only form keeps Cancel as its only action, as the legacy page does. */} + {state.isReadOnly ? null : ( + + )} + +
+ + ); +} + +export interface EditUsernameDialogViewProps extends EditUsernameActions { + state: EditUsernameState; + isInterrupted?: boolean; +} + +export function EditUsernameDialogView({ + state, + isInterrupted = false, + onValueChange, + onSubmit, + onCancel, +}: EditUsernameDialogViewProps) { + const fieldId = React.useId(); + + return ( + <> + + + + + {state.errors.form} + + Username + onValueChange(event.target.value)} + /> + {state.errors.field ? {state.errors.field} : null} + + + + + + + + + ); +} + +export interface EditAvatarDialogViewProps extends EditAvatarActions { + state: EditAvatarState; + /** Initials shown when there is no image to preview. */ + fallback: string; + isInterrupted?: boolean; +} + +/** Mirrors the legacy uploader's constraints, which it enforces before calling the server. */ +const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; + +export function EditAvatarDialogView({ + state, + fallback, + isInterrupted = false, + onSelectFile, + onSubmit, + onRemove, + onCancel, +}: EditAvatarDialogViewProps) { + const fieldId = React.useId(); + const busy = state.status !== 'idle' || isInterrupted; + + return ( + <> + + + + + {state.errors.form} +
+ + + {fallback} + + + Image file + {/* TODO: Replace with a Mosaic FileUpload, built on `@clerk/headless/file-upload`. + A bare file input stands in — no drag-and-drop, which the legacy uploader has. */} + { + const file = event.target.files?.[0]; + if (file) { + onSelectFile(file); + } + }} + /> + {state.errors.field ? {state.errors.field} : null} + +
+ {state.fileName ? {state.fileName} : null} +
+ + {state.canRemove ? ( + + ) : null} +
+ + +
+
+
+ + ); +} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx new file mode 100644 index 00000000000..dd6cb03c0fd --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx @@ -0,0 +1,264 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import { Button } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Icon } from '../../components/icon'; +import { Input } from '../../components/input'; +import { Spinner } from '../../components/spinner'; +import { styles } from './flow-dialogs.styles'; + +/** + * Chrome shared by every contact dialog. Mosaic has no `Dialog.Header` / `Body` / `Footer` yet, so + * these stand in — see the TODOs on `flow-dialogs.styles.ts`. `Body` in particular is load + * bearing rather than cosmetic: a `prompt` clips its overflow under 48rem, so a form without a + * scroll region loses its submit button off the bottom of the phone sheet. + */ + +export function DialogHeader({ title, description }: { title: ReactNode; description?: ReactNode }) { + return ( +
+ {title} + {description ? {description} : null} +
+ ); +} + +export function DialogBody({ children }: { children: ReactNode }) { + return
{children}
; +} + +export function DialogFooter({ children, spread = false }: { children: ReactNode; spread?: boolean }) { + return
{children}
; +} + +/** + * Unattributed errors. A Clerk error carrying a `meta.paramName` belongs in the matching field's + * error slot; one without has nowhere else to go. + * + * TODO: Replace with the Mosaic Alert component. + */ +export function FormAlert({ children }: { children?: ReactNode }) { + if (!children) { + return null; + } + return ( +

+ {children} +

+ ); +} + +export interface DialogFormProps { + onSubmit: () => void; + children: ReactNode; +} + +/** Wraps the steps that submit, so Enter works and the browser treats it as a form. */ +export function DialogForm({ onSubmit, children }: DialogFormProps) { + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + > + {children} +
+ ); +} + +/** + * The resend affordance. Disabled while a resend is in flight and for as long as the cooldown has + * left to run — the email-link screen throttles to 60 seconds and starts disabled, matching the + * legacy `TimerButton`, while a code resend has no cooldown of its own. + */ +export function ResendButton({ + label, + resend, + onResend, + disabled = false, +}: { + label: string; + resend: { isResending: boolean; secondsRemaining: number }; + onResend: () => void; + disabled?: boolean; +}) { + const waiting = resend.secondsRemaining > 0; + return ( + + ); +} + +/** A centred waiting or outcome panel — the link and SSO steps, and the brief prepare step. */ +export function StatusPanel({ + tone = 'pending', + icon, + children, +}: { + tone?: 'pending' | 'positive' | 'negative'; + icon?: 'check' | 'alert-circle'; + children: ReactNode; +}) { + return ( +
+ {tone === 'pending' ? : null} + {icon ? ( + + ) : null} + {children} +
+ ); +} + +export function MutedText({ children }: { children: ReactNode }) { + return

{children}

; +} + +export function Identifier({ children }: { children: ReactNode }) { + return {children}; +} + +export interface CodeInputProps { + id?: string; + value: string; + length?: number; + status: 'idle' | 'verifying' | 'error' | 'success'; + disabled?: boolean; + autoFocus?: boolean; + /** Fired once the final digit lands, matching the legacy `onCodeEntryFinished` auto-submit. */ + onComplete: () => void; + onChange: (value: string) => void; +} + +/** + * TODO: Replace with the Mosaic OTP component, built on `@clerk/headless/otp`. A single input + * standing in for the segmented control: it keeps the flow states honest (idle / verifying / + * error / success, plus the auto-submit on the last digit) without pretending to be the real + * component. + */ +export function CodeInput({ + id, + value, + length = 6, + status, + disabled = false, + autoFocus = false, + onComplete, + onChange, +}: CodeInputProps) { + // No `maxLength`: it would truncate a pasted value before the non-digit strip below runs, so + // `42-4242` would lose its last digit rather than its separator. The handler owns the length. + const completedRef = React.useRef(false); + + const handleChange = (next: string) => { + const digits = next.replace(/\D/g, '').slice(0, length); + onChange(digits); + if (digits.length === length && !completedRef.current) { + completedRef.current = true; + onComplete(); + } + if (digits.length < length) { + completedRef.current = false; + } + }; + + return ( + handleChange(event.target.value)} + /> + ); +} + +/** A small, deliberately incomplete dialling-code list — enough to exercise the control. */ +const COUNTRIES = [ + { code: 'US', dialCode: '+1', label: '🇺🇸 US' }, + { code: 'GB', dialCode: '+44', label: '🇬🇧 UK' }, + { code: 'GR', dialCode: '+30', label: '🇬🇷 GR' }, + { code: 'DE', dialCode: '+49', label: '🇩🇪 DE' }, +] as const; + +export interface PhoneInputProps { + id?: string; + value: string; + disabled?: boolean; + autoFocus?: boolean; + onChange: (value: string) => void; +} + +/** + * TODO: Replace with a Mosaic PhoneInput; its country picker should be the Mosaic Select, built on + * `@clerk/headless/select`. A native ` { + const next = COUNTRIES.find(country => country.code === event.target.value) ?? COUNTRIES[0]; + onChange(`${next.dialCode}${national}`); + }} + > + {COUNTRIES.map(country => ( + + ))} + + onChange(`${matched.dialCode}${event.target.value}`)} + /> + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts new file mode 100644 index 00000000000..46049fd010b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts @@ -0,0 +1,153 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex'; + +export const styles = stylex.create({ + // TODO: Replace with `Dialog.Header` once Mosaic has one; it should own this spacing. + header: { + gap: space['1'], + display: 'flex', + flexDirection: 'column', + }, + // TODO: Replace with `Dialog.Body` once Mosaic has one. This scroll region is not cosmetic: + // `viewportSizes.prompt` sets `overflow: clip` under 48rem, so without it a tall form on a + // phone loses its submit button off the bottom of the sheet. + // + // A non-`visible` overflow on one axis computes the other to `auto`, so this clips horizontally + // as well as vertically — and an `Input`'s focus ring is a 3px `box-shadow` on a full-width + // control, so it was being sliced off at both sides. The padding gives the ring room; the + // matching negative margin takes it back out of the layout, so nothing else shifts. + body: { + margin: '-0.25rem', + padding: '0.25rem', + flex: '1', + gap: space['4'], + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflowY: 'auto', + }, + // TODO: Replace with `Dialog.Footer` once Mosaic has one. + footer: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'flex-end', + }, + footerSpread: { + justifyContent: 'space-between', + }, + form: { + gap: space['5'], + display: 'flex', + flexDirection: 'column', + minHeight: 0, + }, + // TODO: Replace with the Mosaic Alert component. Both legacy call sites hand-roll a + // `role="alert"` box because Clerk errors carrying no `paramName` have no field to land in. + alert: { + borderColor: colorVars['--cl-color-negative'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['2'], + paddingInline: space['3'], + backgroundColor: colorVars['--cl-color-negative-faded'], + color: colorVars['--cl-color-negative'], + fontSize: typeScaleVars['--cl-text-sm-size'], + }, + fields: { + gap: space['4'], + display: 'flex', + flexDirection: 'column', + }, + // TODO: Replace with the Mosaic OTP component, built on the headless `@clerk/headless/otp` + // primitive. This is a plain text input styled to look like cells. + codeInput: { + borderColor: colorVars['--cl-color-border'], + borderRadius: radiusVars['--cl-radius-md'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['3'], + paddingInline: space['4'], + backgroundColor: colorVars['--cl-color-input'], + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + fontSize: typeScaleVars['--cl-text-xl-size'], + letterSpacing: '0.5em', + textAlign: 'center', + width: '100%', + }, + codeInputInvalid: { + borderColor: colorVars['--cl-color-negative'], + }, + codeInputVerified: { + borderColor: colorVars['--cl-color-positive'], + }, + // TODO: Replace with the Mosaic Select component, built on `@clerk/headless/select`. + countrySelect: { + borderColor: colorVars['--cl-color-border'], + borderStyle: 'solid', + borderWidth: '1px', + paddingBlock: space['2'], + paddingInline: space['2'], + backgroundColor: colorVars['--cl-color-input'], + borderEndEndRadius: 0, + borderEndStartRadius: radiusVars['--cl-radius-md'], + borderStartEndRadius: 0, + borderStartStartRadius: radiusVars['--cl-radius-md'], + fontSize: typeScaleVars['--cl-text-sm-size'], + }, + phoneRow: { + display: 'flex', + width: '100%', + }, + phoneInput: { + flex: '1', + borderEndStartRadius: 0, + borderStartStartRadius: 0, + marginInlineStart: '-1px', + }, + status: { + gap: space['3'], + paddingBlock: space['6'], + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + textAlign: 'center', + }, + statusIconPositive: { + color: colorVars['--cl-color-positive'], + height: space['8'], + width: space['8'], + }, + statusIconNegative: { + color: colorVars['--cl-color-negative'], + height: space['8'], + width: space['8'], + }, + muted: { + color: colorVars['--cl-color-neutral-faded'], + fontSize: typeScaleVars['--cl-text-sm-size'], + }, + identifier: { + color: colorVars['--cl-color-neutral'], + fontWeight: 600, + overflowWrap: 'anywhere', + }, + avatarRow: { + gap: space['4'], + alignItems: 'center', + display: 'flex', + }, + footerActions: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + }, + resendRow: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts new file mode 100644 index 00000000000..ddefba15bde --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts @@ -0,0 +1,201 @@ +/** + * The contract between whatever drives a user-profile flow and the views that render it. + * + * Today the driver is a simulated backend in swingset; later it is a state machine plus its + * Clerk controller. The views branch on `step`, never on the driver — so the swap is a rewire. + * + * Modelled off the legacy flows in `packages/ui/src/components/UserProfile`: `EmailForm` + * (which picks between three verification strategies), `PhoneForm`, `VerifyWithCode`, + * `VerifyWithLink`, and `RemoveResourceForm`. + */ + +export type ContactKind = 'email' | 'phone'; + +/** + * Verification strategies a contact can take. The choice is made from the environment and the + * resource — instance-wide email links, or an address matching an enterprise connection — so it + * belongs to the machine; only the destinations are rendered. + */ +export type ContactVerificationStrategy = 'email_code' | 'email_link' | 'enterprise_sso' | 'phone_code'; + +/** A field-attributable error (Clerk's `meta.paramName`) versus one with nowhere to go. */ +export interface FlowErrors { + /** Rendered in the field's error slot. */ + field?: string; + /** Rendered above the form. Clerk errors without a `paramName` land here. */ + form?: string; +} + +/** Resend affordance shared by the code and link steps. The link step's is throttled to 60s. */ +export interface ResendState { + isResending: boolean; + /** Seconds left on the cooldown; `0` when resend is available. */ + secondsRemaining: number; +} + +/** Terminal outcomes of an email-link verification. The polling itself is not rendered. */ +export type EmailLinkOutcome = 'verified' | 'verified_other_tab' | 'expired' | 'failed'; + +export interface AddContactIdentifierStep { + step: 'identifier'; + value: string; + isSubmitting: boolean; + errors: FlowErrors; +} + +/** + * Between creating the resource and the verification surface. Legacy hides this — it fires + * `prepareVerification` from an effect beneath an already-rendered code screen — but under + * simulated latency it is long enough to see, so it gets a state of its own. + */ +export interface AddContactPreparingStep { + step: 'preparing'; + identifier: string; + strategy: ContactVerificationStrategy; +} + +export interface AddContactCodeStep { + step: 'code'; + identifier: string; + strategy: Extract; + code: string; + /** `success` holds briefly before the flow closes, so the check mark is visible. */ + status: 'idle' | 'verifying' | 'error' | 'success'; + errors: FlowErrors; + resend: ResendState; +} + +export interface AddContactLinkStep { + step: 'link'; + identifier: string; + resend: ResendState; + /** Absent while waiting for the click. */ + outcome?: Exclude; + errors: FlowErrors; +} + +export interface AddContactSsoStep { + step: 'sso'; + identifier: string; + providerName: string; + status: 'idle' | 'awaiting_popup' | 'error'; + errors: FlowErrors; +} + +export interface AddContactSuccessStep { + step: 'success'; + identifier: string; +} + +export type AddContactFlowState = + | AddContactIdentifierStep + | AddContactPreparingStep + | AddContactCodeStep + | AddContactLinkStep + | AddContactSsoStep + | AddContactSuccessStep; + +/** Events the add-contact views send back. Named as the machine will name them. */ +export interface AddContactFlowActions { + onValueChange: (value: string) => void; + onSubmitIdentifier: () => void; + onCodeChange: (code: string) => void; + onSubmitCode: () => void; + onResend: () => void; + onOpenSsoPopup: () => void; + onCancel: () => void; +} + +/** + * A destructive confirmation. `messageLine2` is only present for a verified contact — legacy + * suppresses the "you will no longer be able to sign in" line on an unverified one. + */ +export interface ConfirmContactActionState { + identifier: string; + isSubmitting: boolean; + errors: FlowErrors; +} + +/** + * A reverification challenge raised mid-mutation. Rendered stacked over the flow it interrupted, + * which stays open and pending behind it; on success the original flow resumes where it left off. + */ +export interface ReverificationChallengeState { + strategy: 'password' | 'email_code' | 'phone_code'; + /** The address or number a code was sent to. Absent for the password strategy. */ + identifier?: string; + value: string; + status: 'idle' | 'verifying' | 'error'; + errors: FlowErrors; + resend: ResendState; +} + +export interface ReverificationChallengeActions { + onValueChange: (value: string) => void; + onSubmit: () => void; + onResend: () => void; + onCancel: () => void; +} + +// ============================================================================= +// Profile fields — name, username, avatar +// ============================================================================= +// Single-step forms, unlike the contact flows. Modelled off the legacy `ProfileForm`, +// `UsernameForm`, and `AvatarUploader`. + +export interface EditNameState { + firstName: string; + lastName: string; + isSubmitting: boolean; + /** + * An account with an active enterprise connection cannot edit its own name — legacy renders an + * information box and disables the fields rather than hiding the form. + */ + isReadOnly: boolean; + errors: FlowErrors & { firstName?: string; lastName?: string }; +} + +export interface EditNameActions { + onFirstNameChange: (value: string) => void; + onLastNameChange: (value: string) => void; + onSubmit: () => void; + onCancel: () => void; +} + +export interface EditUsernameState { + value: string; + /** Legacy titles the form `set` versus `update` depending on whether one exists already. */ + hasUsername: boolean; + isSubmitting: boolean; + errors: FlowErrors; +} + +export interface EditUsernameActions { + onValueChange: (value: string) => void; + onSubmit: () => void; + onCancel: () => void; +} + +/** Why a chosen file was rejected before any upload was attempted. */ +export type AvatarRejection = 'type' | 'size'; + +export interface EditAvatarState { + /** What the preview shows: the staged file if there is one, otherwise the current image. */ + previewUrl?: string; + /** Name of the staged file. Absent when nothing is staged. */ + fileName?: string; + /** Whether a non-default image exists to remove. */ + canRemove: boolean; + status: 'idle' | 'uploading' | 'removing'; + errors: FlowErrors; +} + +export interface EditAvatarActions { + onSelectFile: (file: File) => void; + onSubmit: () => void; + onRemove: () => void; + onCancel: () => void; +} + +/** Which profile field a dialog is editing. */ +export type ProfileField = 'name' | 'username' | 'avatar'; diff --git a/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx new file mode 100644 index 00000000000..b792f83e12d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx @@ -0,0 +1,122 @@ +import * as stylex from '@stylexjs/stylex'; +import React from 'react'; + +import { Button } from '../../components/button'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import type { ReverificationChallengeActions, ReverificationChallengeState } from './flow.types'; +import { + CodeInput, + DialogBody, + DialogFooter, + DialogForm, + DialogHeader, + FormAlert, + Identifier, + MutedText, + ResendButton, +} from './flow-dialog-chrome'; +import { styles } from './flow-dialogs.styles'; + +export interface ReverificationDialogViewProps extends ReverificationChallengeActions { + state: ReverificationChallengeState; +} + +/** + * The challenge raised when a mutation needs the session reverified. + * + * It stacks over the flow it interrupted rather than replacing it — the dialog underneath stays + * mounted and inert, so the original flow visibly resumes at the step it was on once this clears. + * Most user mutations in the legacy profile are wrapped in `useReverification`, so almost any + * action here can raise one. + */ +export function ReverificationDialogView({ + state, + onValueChange, + onSubmit, + onResend, + onCancel, +}: ReverificationDialogViewProps) { + const fieldId = React.useId(); + const isCode = state.strategy !== 'password'; + const inert = state.status === 'verifying'; + const canSubmit = state.value.length > 0 && !inert; + + return ( + <> + + + Enter the verification code sent to {state.identifier} + + ) : ( + 'Enter your password to continue.' + ) + } + title='Verify it’s you' + /> + + + {state.errors.form} + + {isCode ? 'Verification code' : 'Password'} + {isCode ? ( + + ) : ( + // TODO: Replace with a Mosaic PasswordInput once one exists — this has no reveal + // toggle and no strength meter. + onValueChange(event.target.value)} + /> + )} + {state.status === 'error' && state.errors.field ? {state.errors.field} : null} + + {isCode ? ( +
+ Didn't receive a code? + +
+ ) : null} +
+ + + + +
+ + ); +} From 60b400caf31b7b17f3f229d591d90d795aaeaf62 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 12:46:05 -0600 Subject: [PATCH 02/14] refactor(ui): render the account section's dialogs from its own view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialogs were mounted alongside the section by whatever drove the flow, which left the composition — which dialogs exist, and which row opens which — as knowledge the driving layer had to hold. It belongs to the view. `UserProfileAccountSectionView` now renders them, behind optional props: with none supplied it renders rows exactly as before, so the profile panel and the section's own story are untouched. Each prop is a flow's snapshot plus its events, or null when that flow is not running; the view derives both the dialog's open state and its held exit frame from that. It renders the surfaces, it does not decide when they are open — the same split `user-button.view.tsx` uses for its popover. The discard guard moves to the view with them. Whether a form has been edited is answered by comparing what is on screen against what is saved, and the view has both. Fixes a stacking bug this surfaced: the reverification challenge was a sibling of the dialog it interrupts. A dialog finds its stack through React context, so two sibling modals are not a stack — each marked the other's portal inert and NEITHER was left in the accessibility tree. It now renders inside its host, as `AlertDialog.Confirm` already does. Co-Authored-By: Claude Opus 5 (1M context) --- ...r-profile-account-section-flow.stories.tsx | 324 ++++-------------- .../account-section-dialogs.view.test.tsx | 154 +++++++++ .../dialogs/account-section-dialogs.view.tsx | 313 +++++++++++++++++ .../mosaic/user-profile/dialogs/flow.types.ts | 63 ++++ .../user-profile-account-section.view.tsx | 27 +- 5 files changed, 617 insertions(+), 264 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx index 86749c6fc19..143e8e756ad 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx @@ -1,12 +1,5 @@ -import type { DialogOpenChangeDetails } from '@clerk/headless/dialog'; -import { Freeze } from '@clerk/headless/utils'; -import { AlertDialog, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog'; import { Dialog } from '@clerk/ui/mosaic/components/dialog'; import { AddContactDialogView } from '@clerk/ui/mosaic/user-profile/dialogs/add-contact-dialog.view'; -import { - RemoveContactDialogView, - SetPrimaryContactDialogView, -} from '@clerk/ui/mosaic/user-profile/dialogs/confirm-contact-dialog.view'; import { EditAvatarDialogView, EditNameDialogView, @@ -18,9 +11,8 @@ import type { EditNameState, EditUsernameState, } from '@clerk/ui/mosaic/user-profile/dialogs/flow.types'; -import { ReverificationDialogView } from '@clerk/ui/mosaic/user-profile/dialogs/reverification-dialog.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section.view'; -import { useId, useMemo, useRef, useState } from 'react'; +import { useId, useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -55,213 +47,6 @@ const INITIAL_IDENTITY = { imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', }; -/** - * The dialogs a contact flow mounts, wired to the simulated backend. - * - * Each surface is wrapped in `Freeze`, held while its state is null. A dialog stays mounted for the - * length of its exit transition, but the state driving it is cleared the moment it closes — so - * without this the contents blank out and the surface visibly collapses on the way out. Freezing - * holds the last committed frame until the dialog is actually gone. - * - * `AddContactDialogView` is the only surface that changes shape as the flow runs; the two - * confirmations and the reverification challenge are each one state. The challenge is stacked over - * whatever raised it, so its dialog is a sibling rather than a step — the interrupted surface stays - * mounted and inert underneath, and the flow resumes there when the challenge clears. - */ -function ContactFlowDialogs({ flow }: { flow: ReturnType }) { - const { add, edit, confirm, reverification } = flow; - const initials = `${flow.identity.firstName.at(0) ?? ''}${flow.identity.lastName.at(0) ?? ''}`.toUpperCase(); - - // One handle per mounted dialog, not module scope: two of these sharing single-flight state - // would let one dialog's question answer the other's. - const discardConfirm = useMemo(() => AlertDialog.createConfirmHandle(), []); - - /** - * The control that had focus inside the edit dialog when the discard question was raised, so - * "Keep editing" puts the caret back where it was rather than on the dialog itself. - * - * Recorded as focus moves rather than read at close time: the primitive's focus machinery runs - * synchronously on the close request, so by the time the question is asked the answer would - * already be wrong. - */ - const lastEditFocus = useRef(null); - const rememberEditFocus = (event: React.FocusEvent) => { - const target = event.target; - // React portals bubble through the React tree, so the confirmation's own buttons reach this - // handler too. Recording those would return focus to a button that no longer exists. - if (!target.closest('[role="alertdialog"]')) { - lastEditFocus.current = target; - } - }; - - // All three profile forms share one dialog, so one guard covers them — each field just reports - // dirtiness its own way. - const isEditDirty = () => { - if (!edit) { - return false; - } - if (edit.field === 'name') { - return edit.state.firstName !== flow.identity.firstName || edit.state.lastName !== flow.identity.lastName; - } - if (edit.field === 'username') { - return edit.state.value !== flow.identity.username; - } - return Boolean(edit.state.fileName); - }; - - const onEditOpenChange: (open: boolean, details: DialogOpenChangeDetails) => void = useConfirmedClose({ - handle: discardConfirm, - when: isEditDirty, - onOpenChange: open => { - if (!open) { - flow.closeEdit(); - } - }, - confirm: { - title: 'Discard changes?', - description: 'Your edits will be lost.', - actionLabel: 'Discard', - cancelLabel: 'Keep editing', - destructive: true, - }, - }); - - const cancelEdit = () => onEditOpenChange(false, PROGRAMMATIC_CLOSE); - const confirmKind = confirm?.pending.kind ?? 'email'; - const confirmRecord = - confirm && (confirmKind === 'email' ? flow.emails : flow.phones).find(item => item.id === confirm.pending.id); - - return ( - <> - { - if (!open) { - flow.closeAdd(); - } - }} - > - - {add ? ( - void flow.openSsoPopup()} - onResend={() => void flow.resend()} - onSubmitCode={() => void flow.submitCode()} - onSubmitIdentifier={() => void flow.submitIdentifier()} - onValueChange={flow.setIdentifier} - /> - ) : null} - - - - - - {edit?.field === 'name' ? ( - flow.setName('firstName', value)} - onLastNameChange={value => flow.setName('lastName', value)} - onSubmit={() => void flow.submitEdit()} - /> - ) : null} - {edit?.field === 'username' ? ( - void flow.submitEdit()} - onValueChange={flow.setUsername} - /> - ) : null} - {edit?.field === 'avatar' ? ( - void flow.removeAvatar()} - onSelectFile={flow.selectAvatarFile} - onSubmit={() => void flow.submitEdit()} - /> - ) : null} - - {/* Belongs INSIDE the dialog it guards, so the two share a floating tree, escape ordering, - the stacking treatment and the refcounted scroll lock. */} - - - - { - if (!open) { - flow.closeConfirm(); - } - }} - > - - {confirm ? ( - confirm.pending.action === 'remove' ? ( - void flow.submitConfirm()} - /> - ) : ( - void flow.submitConfirm()} - /> - ) - ) : null} - - - - { - if (!open) { - flow.cancelReverification(); - } - }} - > - - {reverification ? ( - void flow.resendReverification()} - onSubmit={() => void flow.submitReverification()} - onValueChange={flow.setReverificationValue} - /> - ) : null} - - - - ); -} - // `StoryEmbed` centres a story inside `flex items-center justify-center`, so a fragment's children // become flex items in a row. Stories here own a column wrapper rather than leaving the controls // sitting beside the component. @@ -269,52 +54,6 @@ const storyColumn = { display: 'flex', flexDirection: 'column', width: '100%' } // One control per line: these are independent conditions rather than a related set, and a wrapped // row made it ambiguous which options belonged to which label. -/** A close this code issues rather than one the user gestured, for routing Cancel through a guard. */ -const PROGRAMMATIC_CLOSE: DialogOpenChangeDetails = { trigger: null, triggerId: null, event: undefined }; - -/** - * A `Dialog` assembled from the compound parts, so it can take `finalFocus`. - * - * The `Dialog` wrapper deliberately does not forward focus props — purpose-built chrome is meant - * to talk to `Dialog.Root` / `Dialog.Popup` directly instead of widening the generic wrapper. This - * is that chrome, in the smallest form the story needs. - */ -function FlowDialog({ - open, - finalFocus, - closedBy, - onOpenChange, - onFocusCapture, - children, -}: { - open: boolean; - finalFocus?: React.RefObject; - closedBy?: 'any' | 'closerequest' | 'none'; - onOpenChange: (open: boolean, details: DialogOpenChangeDetails) => void; - onFocusCapture?: React.FocusEventHandler; - children: React.ReactNode; -}) { - return ( - - - - - - {children} - - - - - ); -} - const controlsBar = { alignItems: 'flex-start', border: '1px solid var(--cl-color-border)', @@ -602,13 +341,73 @@ export function Default() { initialPhones: INITIAL_PHONES, }); + const confirmRecord = flow.confirm + ? (flow.confirm.pending.kind === 'email' ? flow.emails : flow.phones).find( + item => item.id === flow.confirm?.pending.id, + ) + : undefined; + return (
setConfig(current => ({ ...current, ...next }))} /> + {/* The section renders the dialogs itself; the harness supplies only their state and events, + which is what the controller will hand it once a machine drives this. */} void flow.openSsoPopup(), + onResend: () => void flow.resend(), + onSubmitCode: () => void flow.submitCode(), + onSubmitIdentifier: () => void flow.submitIdentifier(), + onValueChange: flow.setIdentifier, + } + : null + } + confirmContact={ + flow.confirm + ? { + action: flow.confirm.pending.action, + kind: flow.confirm.pending.kind, + isVerified: confirmRecord?.isVerified ?? false, + state: flow.confirm.state, + onCancel: flow.closeConfirm, + onConfirm: () => void flow.submitConfirm(), + } + : null + } + editProfile={ + flow.edit + ? { + ...flow.edit, + onNameChange: flow.setName, + onUsernameChange: flow.setUsername, + onSelectAvatarFile: flow.selectAvatarFile, + onRemoveAvatar: () => void flow.removeAvatar(), + onSubmit: () => void flow.submitEdit(), + onCancel: flow.closeEdit, + } + : null + } + flowTriggerRef={flow.triggerRef} + reverification={ + flow.reverification + ? { + state: flow.reverification, + onCancel: flow.cancelReverification, + onResend: () => void flow.resendReverification(), + onSubmit: () => void flow.submitReverification(), + onValueChange: flow.setReverificationValue, + } + : null + } emails={flow.emails} imageUrl={flow.identity.imageUrl} name={`${flow.identity.firstName} ${flow.identity.lastName}`.trim()} @@ -646,7 +445,6 @@ export function Default() { onVerifyEmail={() => flow.openAdd('email')} onVerifyPhone={() => flow.openAdd('phone')} /> -
); } diff --git a/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx new file mode 100644 index 00000000000..fe30a43f2cc --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx @@ -0,0 +1,154 @@ +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 { AccountSectionFlows } from '../dialogs/flow.types'; +import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section.view'; + +const rows: UserProfileAccountSectionViewProps = { + name: 'Preston Booth', + username: 'prestonxyz', + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], + phones: [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }], +}; + +function renderSection(flows: AccountSectionFlows = {}) { + return render( + + + , + ); +} + +const addContactActions = { + onCancel: vi.fn(), + onCodeChange: vi.fn(), + onOpenSsoPopup: vi.fn(), + onResend: vi.fn(), + onSubmitCode: vi.fn(), + onSubmitIdentifier: vi.fn(), + onValueChange: vi.fn(), +}; + +const editActions = { + onNameChange: vi.fn(), + onUsernameChange: vi.fn(), + onSelectAvatarFile: vi.fn(), + onRemoveAvatar: vi.fn(), + onSubmit: vi.fn(), + onCancel: vi.fn(), +}; + +describe('UserProfileAccountSectionView flows', () => { + it('renders rows only when no flow is supplied, so existing callers are unaffected', () => { + renderSection(); + + expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('renders the add-contact dialog from the section itself', () => { + renderSection({ + addContact: { + kind: 'email', + state: { step: 'identifier', value: '', isSubmitting: false, errors: {} }, + ...addContactActions, + }, + }); + + expect(screen.getByRole('heading', { name: 'Add email address' })).toBeInTheDocument(); + }); + + it('renders a removal as an alertdialog, not a dialog', () => { + renderSection({ + confirmContact: { + action: 'remove', + kind: 'email', + isVerified: true, + state: { identifier: 'item1@clerk.dev', isSubmitting: false, errors: {} }, + onConfirm: vi.fn(), + onCancel: vi.fn(), + }, + }); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByText(/no longer be able to sign in/)).toBeInTheDocument(); + }); + + it('stacks the reverification challenge over the flow it interrupted', () => { + renderSection({ + addContact: { + kind: 'email', + state: { step: 'identifier', value: 'new@clerk.dev', isSubmitting: true, errors: {} }, + ...addContactActions, + }, + reverification: { + state: { + strategy: 'password', + value: '', + status: 'idle', + errors: {}, + resend: { isResending: false, secondsRemaining: 0 }, + }, + onValueChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + onCancel: vi.fn(), + }, + }); + + // The challenge is what the user can reach. + expect(screen.getByRole('heading', { name: /Verify it/ })).toBeInTheDocument(); + + // The interrupted flow stays MOUNTED underneath so it resumes where it was — but a modal + // hides everything beneath it from the accessibility tree, so it is only reachable with + // `hidden`. That it is inert rather than merely obscured is the point of `isInterrupted`. + expect(screen.getByRole('heading', { name: 'Add email address', hidden: true })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: 'Email address', hidden: true })).toBeDisabled(); + }); + + it('asks before discarding an edited profile field, and cancelling the question keeps it open', async () => { + const onCancel = vi.fn(); + renderSection({ + editProfile: { + field: 'username', + // Differs from the saved `username`, so the form is dirty. + state: { value: 'someone-else', hasUsername: true, isSubmitting: false, errors: {} }, + ...editActions, + onCancel, + }, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(await screen.findByRole('alertdialog', { name: 'Discard changes?' })).toBeInTheDocument(); + expect(onCancel).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: 'Keep editing' })); + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('heading', { name: 'Update username' })).toBeInTheDocument(); + }); + + it('closes without asking when the field matches what is saved', async () => { + const onCancel = vi.fn(); + renderSection({ + editProfile: { + field: 'username', + state: { value: 'prestonxyz', hasUsername: true, isSubmitting: false, errors: {} }, + ...editActions, + onCancel, + }, + }); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx new file mode 100644 index 00000000000..903194371d5 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx @@ -0,0 +1,313 @@ +import type { DialogOpenChangeDetails } from '@clerk/headless/dialog'; +import { Freeze } from '@clerk/headless/utils'; +import React from 'react'; + +import { AlertDialog, useConfirmedClose } from '../../components/alert-dialog'; +import { Dialog } from '../../components/dialog'; +import { AddContactDialogView } from './add-contact-dialog.view'; +import { RemoveContactDialogView, SetPrimaryContactDialogView } from './confirm-contact-dialog.view'; +import { EditAvatarDialogView, EditNameDialogView, EditUsernameDialogView } from './edit-profile-dialog.view'; +import type { AccountSectionFlows, EditProfileFlow } from './flow.types'; +import { ReverificationDialogView } from './reverification-dialog.view'; + +/** A close this code issues rather than one the user gestured, for routing Cancel through a guard. */ +const PROGRAMMATIC_CLOSE: DialogOpenChangeDetails = { trigger: null, triggerId: null, event: undefined }; + +interface FlowDialogProps { + open: boolean; + finalFocus?: React.RefObject; + closedBy?: 'any' | 'closerequest' | 'none'; + onOpenChange: (open: boolean, details: DialogOpenChangeDetails) => void; + onFocusCapture?: React.FocusEventHandler; + children: React.ReactNode; +} + +/** + * A `Dialog` assembled from the compound parts, so it can take `finalFocus`. + * + * The `Dialog` wrapper deliberately does not forward focus props — purpose-built chrome is meant to + * talk to `Dialog.Root` / `Dialog.Popup` directly rather than widening the generic wrapper. This is + * that chrome, in the smallest form these flows need. + */ +function FlowDialog({ open, finalFocus, closedBy, onOpenChange, onFocusCapture, children }: FlowDialogProps) { + return ( + + + + + + {children} + + + + + ); +} + +export interface AccountSectionDialogsViewProps extends AccountSectionFlows { + /** The saved values, so a form can tell whether it has been edited. */ + name: string; + username: string; + /** Initials for the avatar preview when there is no image. */ + fallback: string; +} + +/** + * Every dialog the account section can open. + * + * Rendering them here rather than wherever the flow is driven means the composition — which dialogs + * exist, and which surface each one is — is the view's, and the layer above supplies only a + * snapshot and its events. A `null` flow is one that is not running: the dialog stays mounted for + * its exit transition with its contents frozen, so it does not visibly collapse on the way out. + */ +export function AccountSectionDialogsView({ + name, + username, + fallback, + flowTriggerRef, + addContact, + confirmContact, + editProfile, + reverification, +}: AccountSectionDialogsViewProps) { + /** + * The challenge renders INSIDE the surface it interrupts, never beside it. + * + * A dialog finds the stack it belongs to through React context, so two modal dialogs rendered as + * siblings are not a stack — each marks the other's portal inert and NEITHER is left in the + * accessibility tree. Nesting is also what gives the pair its scrim and recede treatment. This is + * the same rule `AlertDialog.Confirm` follows. + */ + const challenge = reverification ? ( + { + if (!open) { + reverification.onCancel(); + } + }} + > + + + ) : null; + + // Only one flow runs at a time, so the challenge has exactly one host. + const host = addContact ? 'add' : editProfile ? 'edit' : confirmContact ? 'confirm' : null; + + return ( + <> + { + if (!open) { + addContact?.onCancel(); + } + }} + > + + {addContact ? ( + + ) : null} + + {host === 'add' ? challenge : null} + + + + + { + if (!open) { + confirmContact?.onCancel(); + } + }} + > + + {confirmContact ? ( + confirmContact.action === 'remove' ? ( + + ) : ( + + ) + ) : null} + + {host === 'confirm' ? challenge : null} + + + ); +} + +/** Whether the open form differs from what is saved, which is what the discard guard asks about. */ +function isDirty(editProfile: EditProfileFlow | null | undefined, name: string, username: string): boolean { + if (!editProfile) { + return false; + } + if (editProfile.field === 'name') { + const [firstName = '', ...rest] = name.split(/\s+/); + return editProfile.state.firstName !== firstName || editProfile.state.lastName !== rest.join(' '); + } + if (editProfile.field === 'username') { + return editProfile.state.value !== username; + } + return Boolean(editProfile.state.fileName); +} + +/** + * The three profile-field forms share one dialog, so one discard guard covers them. + * + * The guard is the view's rather than the layer above's: "does closing this need confirming" is + * answered by comparing what is on screen against what is saved, and the view has both. + */ +function EditProfileDialog({ + challenge, + editProfile, + name, + username, + fallback, + flowTriggerRef, + isInterrupted, +}: { + challenge: React.ReactNode; + editProfile: EditProfileFlow | null | undefined; + name: string; + username: string; + fallback: string; + flowTriggerRef?: React.RefObject; + isInterrupted: boolean; +}) { + // One handle per mounted dialog, not module scope: two of these sharing single-flight state + // would let one dialog's question answer the other's. + const discardConfirm = React.useMemo(() => AlertDialog.createConfirmHandle(), []); + + /** + * The control that had focus when the discard question was raised, so "Keep editing" puts the + * caret back where it was rather than on the dialog itself. + * + * Recorded as focus moves rather than read at close time: the primitive's focus machinery runs + * synchronously on the close request, so by then the answer would already be wrong. + */ + const lastFocus = React.useRef(null); + const rememberFocus = (event: React.FocusEvent) => { + // React portals bubble through the React tree, so the confirmation's own buttons reach this + // handler too. Recording those would return focus to a button that no longer exists. + if (!event.target.closest('[role="alertdialog"]')) { + lastFocus.current = event.target; + } + }; + + const onOpenChange = useConfirmedClose({ + handle: discardConfirm, + when: () => isDirty(editProfile, name, username), + onOpenChange: open => { + if (!open) { + editProfile?.onCancel(); + } + }, + confirm: { + title: 'Discard changes?', + description: 'Your edits will be lost.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); + + // Cancel has to go through the guard: a button wired straight to the layer above never reaches + // the dialog, so it would skip the question silently. + const cancel = () => onOpenChange(false, PROGRAMMATIC_CLOSE); + + return ( + + + {editProfile?.field === 'name' ? ( + editProfile.onNameChange('firstName', value)} + onLastNameChange={value => editProfile.onNameChange('lastName', value)} + onSubmit={editProfile.onSubmit} + /> + ) : null} + {editProfile?.field === 'username' ? ( + + ) : null} + {editProfile?.field === 'avatar' ? ( + + ) : null} + + {/* Belongs INSIDE the dialog it guards, so the two share a floating tree, escape ordering, + the stacking treatment and the refcounted scroll lock. */} + {challenge} + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts index ddefba15bde..266e12230d0 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts @@ -1,3 +1,5 @@ +import type React from 'react'; + /** * The contract between whatever drives a user-profile flow and the views that render it. * @@ -199,3 +201,64 @@ export interface EditAvatarActions { /** Which profile field a dialog is editing. */ export type ProfileField = 'name' | 'username' | 'avatar'; + +// ============================================================================= +// What the section view takes +// ============================================================================= +// The view renders the dialogs; it does not decide when they are open. Each prop below is the +// flow's snapshot plus its events, or `null` when that flow is not running — the view derives +// both the dialog's `open` and its held exit frame from that. This mirrors the UserButton, whose +// view renders the whole popover but forwards `open` straight through: the surface belongs to the +// view, the state driving it does not. + +export interface EditProfileDialogActions { + onNameChange: (key: 'firstName' | 'lastName', value: string) => void; + onUsernameChange: (value: string) => void; + onSelectAvatarFile: (file: File) => void; + onRemoveAvatar: () => void; + onSubmit: () => void; + onCancel: () => void; +} + +export type EditProfileFlow = EditProfileDialogActions & + ( + | { field: 'name'; state: EditNameState } + | { field: 'username'; state: EditUsernameState } + | { field: 'avatar'; state: EditAvatarState } + ); + +export interface ConfirmContactDialogActions { + onConfirm: () => void; + onCancel: () => void; +} + +export type ConfirmContactFlow = ConfirmContactDialogActions & { + action: 'remove' | 'set-primary'; + kind: ContactKind; + /** Drives whether the removal warns about losing sign-in. */ + isVerified: boolean; + state: ConfirmContactActionState; +}; + +export type AddContactFlow = AddContactFlowActions & { + kind: ContactKind; + state: AddContactFlowState; +}; + +export type ReverificationFlow = ReverificationChallengeActions & { + state: ReverificationChallengeState; +}; + +/** The flow half of the account section's props. Every field is optional and nullable. */ +export interface AccountSectionFlows { + /** + * Where focus returns when a dialog closes. These dialogs open from state rather than from a + * `Dialog.Trigger`, so without it focus lands on the body and the row is lost. + */ + flowTriggerRef?: React.RefObject; + addContact?: AddContactFlow | null; + confirmContact?: ConfirmContactFlow | null; + editProfile?: EditProfileFlow | null; + /** Stacks over whichever flow raised it. Shared across sections; it lives here until a second one needs it. */ + reverification?: ReverificationFlow | null; +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index 1e27884859d..bff6241f67a 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -5,6 +5,8 @@ import { Badge } from '../components/badge'; import { Button } from '../components/button'; import { Icon } from '../components/icon'; import { Section } from '../components/section'; +import { AccountSectionDialogsView } from './dialogs/account-section-dialogs.view'; +import type { AccountSectionFlows } from './dialogs/flow.types'; import type { UserProfileMenuAction } from './user-profile-action-menu'; import { UserProfileActionMenu } from './user-profile-action-menu'; import { styles } from './user-profile-profile-panel.styles'; @@ -25,7 +27,15 @@ export interface UserProfilePhone { canRemove?: boolean; } -export interface UserProfileAccountSectionViewProps { +/** + * The section's rows, plus the dialogs those rows open. + * + * The flow props are optional: with none supplied the section renders exactly as before, so + * anything that wants only the rows — the profile panel, the section's own story — is unaffected. + * Supplying them makes the section render the surfaces too, which is where they belong. The view + * renders the dialogs; it does not decide when they are open. See {@link AccountSectionFlows}. + */ +export interface UserProfileAccountSectionViewProps extends AccountSectionFlows { allowMultipleAccounts?: boolean; imageUrl?: string; name: string; @@ -49,6 +59,11 @@ export interface UserProfileAccountSectionViewProps { export function UserProfileAccountSectionView({ allowMultipleAccounts = false, + flowTriggerRef, + addContact, + confirmContact, + editProfile, + reverification, imageUrl, name, username, @@ -195,6 +210,16 @@ export function UserProfileAccountSectionView({ onVerify={onVerifyPhone} /> ) : null} + ); } From 5800ae506425042f4fc4bc3ed0736b5626201bcd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 13:33:39 -0600 Subject: [PATCH 03/14] fix(swingset): name flows as prose rather than as components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar and the breadcrumb both wrote an entry as the JSX you would render, so the account flow read as `` — a component that does not exist. A flow is a set of surfaces and the states they take, not something you render, so it now reads as prose from `meta.label`. That is the same reasoning the sidebar already applies to hooks, which it writes as calls, and to atomic styles, which it writes bare. `meta.label` was declared but unread until now; `meta.title` still drives the slug. Co-Authored-By: Claude Opus 5 (1M context) --- packages/swingset/src/components/ClientRoot.tsx | 5 ++++- packages/swingset/src/components/app-sidebar.tsx | 9 ++++++--- .../user-profile-account-section-flow.stories.tsx | 4 +++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/swingset/src/components/ClientRoot.tsx b/packages/swingset/src/components/ClientRoot.tsx index 08a85129e6b..d29ec77b68b 100644 --- a/packages/swingset/src/components/ClientRoot.tsx +++ b/packages/swingset/src/components/ClientRoot.tsx @@ -29,7 +29,10 @@ function useBreadcrumb() { // to how the entry is actually written — `scroll-area` → `Scroll Area`, `use-data-table` → // `useDataTable`. Fall back to title-casing the slug for any path the registry doesn't cover. return parts.map((part, index) => { - const title = index === 0 ? getModule(groupSlug, part)?.meta.title : undefined; + const meta = index === 0 ? getModule(groupSlug, part)?.meta : undefined; + // A flow is not a component, so it reads as prose here and in the sidebar rather than as the + // name of something you could render. See `app-sidebar.tsx` for the same rule. + const title = meta && (meta.navigation?.category === 'Flows' ? (meta.label ?? meta.title) : meta.title); return title ?? part.replace(/(^|-)([a-z])/g, (_, sep: string, ch: string) => (sep ? ' ' : '') + ch.toUpperCase()); }); } diff --git a/packages/swingset/src/components/app-sidebar.tsx b/packages/swingset/src/components/app-sidebar.tsx index 00cc791f6c2..d66afae92fb 100644 --- a/packages/swingset/src/components/app-sidebar.tsx +++ b/packages/swingset/src/components/app-sidebar.tsx @@ -106,14 +106,17 @@ function SidebarEntryMenu({ const href = `/${groupSlug}/${componentSlug}`; // How an entry is USED differs by layer, so the label follows the layer rather // than a guess at the title: hooks are called, atomic styles are a set of - // exports with no single call form worth privileging, and everything else is a - // component rendered as JSX. + // exports with no single call form worth privileging, flows are a set of surfaces + // and the states they take rather than anything you render, and everything else is + // a component rendered as JSX. const usage = mod.meta.group === 'Hooks' ? `${mod.meta.title}()` : mod.meta.group === 'Styles' ? mod.meta.title - : `<${mod.meta.title} />`; + : mod.meta.navigation?.category === 'Flows' + ? (mod.meta.label ?? mod.meta.title) + : `<${mod.meta.title} />`; return ( `, so the sidebar and the + // breadcrumb show this instead. `title` still drives the slug. + label: 'User profile account flow', layout: 'wide', navigation: { category: 'Flows' }, source: 'packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx', From fcebc20f7cfd8a1d8fea2e9f7c3b65990ba352c4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 13:51:55 -0600 Subject: [PATCH 04/14] fix(swingset): let the account flow story allow multiple emails and phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section collapses email and phone to a single row unless `allowMultipleAccounts` is set, so the story showed only the primary of its two emails, with no Add button and no per-row menu — none of the add, remove or set-primary flows were reachable. It is an instance condition like the others here, so it gets a control, defaulted on. Co-Authored-By: Claude Opus 5 (1M context) --- .../stories/user-profile-account-section-flow.harness.ts | 7 +++++++ .../user-profile-account-section-flow.stories.tsx | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts index 21fd709ea7e..65f4011dd76 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts +++ b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts @@ -50,6 +50,12 @@ export interface AccountSectionFlowConfig { emailLinkResolveMs: number; /** Fail the enterprise SSO popup instead of returning verified. */ ssoFails: boolean; + /** + * Whether the instance permits more than one email address or phone number. Off, the section + * collapses each to a single row showing only the primary; on, each gets its own card with an + * Add button and a per-row menu — which is what the add, remove and set-primary flows need. + */ + allowMultipleAccounts: boolean; /** * An account with an active enterprise connection: legacy renders the name form read-only. * Username and avatar stay editable. @@ -73,6 +79,7 @@ export const DEFAULT_ACCOUNT_SECTION_FLOW_CONFIG: AccountSectionFlowConfig = { emailLinkOutcome: 'verified', emailLinkResolveMs: 6000, ssoFails: false, + allowMultipleAccounts: true, enterpriseManaged: false, takenUsernames: ['prestonxyz'], }; diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx index ace72b33c90..218d191a620 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx @@ -298,6 +298,14 @@ function Controls({ value={config.reverificationStrategy} onChange={reverificationStrategy => onChange({ reverificationStrategy })} /> + - + code {config.validCode} · password {config.validPassword} · taken{' '} - {config.takenIdentifiers[0]} · SSO domain @{config.ssoDomains[0]} + {config.takenIdentifiers[0]} · taken username {config.takenUsernames[0]} From 8e5a196f9c8cccaf341a9486714ffd0a9979caec Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 14:44:23 -0600 Subject: [PATCH 08/14] refactor(ui): use SubmitButton for the account flow's pending actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every dialog swapped its button label mid-request — Add became Adding…, Save became Saving… — which reflows the footer just as the action starts and drops the busy announcement. `SubmitButton` already solves this: the label stays mounted so the width holds, the button announces itself busy and goes inert without leaving the tab order, and a spin delay keeps a fast action from flashing a spinner. Footer buttons now share the width evenly and cancels are outlined, matching the security dialogs in #9525 so the two sets read as one system. The story's docs take the same shape, including a table of what the flow covers against legacy. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-profile-account-section-flow.mdx | 89 +++++++++---------- .../add-contact-dialog.view.test.tsx | 11 ++- .../confirm-contact-dialog.view.test.tsx | 2 +- .../edit-profile-dialog.view.test.tsx | 12 ++- .../dialogs/add-contact-dialog.view.tsx | 40 +++++---- .../dialogs/confirm-contact-dialog.view.tsx | 24 ++--- .../dialogs/edit-profile-dialog.view.tsx | 49 +++++----- .../dialogs/flow-dialogs.styles.ts | 7 ++ .../dialogs/reverification-dialog.view.tsx | 17 ++-- 9 files changed, 141 insertions(+), 110 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.mdx b/packages/swingset/src/stories/user-profile-account-section-flow.mdx index 81b23915092..d7ba147fa00 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.mdx +++ b/packages/swingset/src/stories/user-profile-account-section-flow.mdx @@ -2,37 +2,36 @@ import * as Stories from './user-profile-account-section-flow.stories'; # Account section flow -Every action on the account section that opens a dialog — adding, verifying, removing and promoting -an email address or a phone number, plus editing the name, username and profile picture — driven by -a simulated backend that takes time and can fail. - -The views are pure: `AddContactDialogView` branches on `state.step` and sends events back, and -holds no flow state of its own. Everything that decides _when_ a step changes lives in -`user-profile-account-section-flow.harness.ts` in swingset, which is what a state machine and its -Clerk controller will replace. Each simulated delay there becomes a machine `invoke`; each branch, a -guard. - -Three verification strategies are covered, because the legacy `EmailForm` picks between them from -the environment rather than from anything the user did: a code, an email link (its own waiting -screen, a 60-second throttled resend, and four terminal outcomes), and enterprise SSO. Turning on -**Require reverification** stacks a challenge over whichever surface raised it — the interrupted -dialog stays mounted and inert underneath, and the flow resumes there once the challenge clears. - -Set-as-primary gets a confirmation it does not have today. Legacy fires it straight from the -three-dots menu, which leaves an async call that can fail with nowhere to report the failure. - -Two instance settings constrain the identifier lists, and neither is a count — **Clerk has no -"maximum one email address" setting.** `disableAdditionalIdentifications` lives on an active -enterprise connection and takes the Add button away; the `immutable` flag on the `email_address` or -`phone_number` attribute is instance-level and removes both adding and deleting. With an immutable -identifier and a single verified primary address, legacy leaves a row with no actions at all, and -that read-only state is deliberate rather than a gap. - -The three profile fields are single-step forms rather than flows, but they are not interchangeable: -only the **username** update is wrapped in `useReverification` upstream, so it is the one a -challenge interrupts; only the **name** goes read-only under an enterprise connection (toggle -**Enterprise-managed**); and the **avatar** validates type and size locally, so those two errors -appear instantly no matter what the latency is set to. +This story covers the profile picture, name, username, email, and phone actions on the account +section. The controls can change what the instance allows, add a delay, trigger an error, or +require an identity check. + +The views only display the current state and report what the user did. Everything that decides +_when_ a step changes lives in the mock controller, `user-profile-account-section-flow.harness.ts`. +A state machine and its Clerk controller replace it later: each delay becomes an `invoke`, each +branch a guard. + +## Legacy behavior covered + +| Area | What this story covers | +| ----------------- | ------------------------------------------------------------------------------------------------------------- | +| Profile picture | Choosing a file, previewing it, uploading, removing, and the type and size checks that run before any request | +| Name | First and last name, a missing first name, and the read-only form an active enterprise account produces | +| Username | Setting versus updating, names already taken, and identity checks | +| Email | Adding, verifying by code, by link, or through enterprise SSO, resending, removing, and making primary | +| Phone | Adding, verifying by code, resending, removing, and making primary | +| Instance settings | Disabling additional identifications on an enterprise connection, and immutable email and phone attributes | +| Identity checks | Password and email-code methods, stacked over whichever flow raised them, resuming that flow or abandoning it | + +Two things differ from legacy on purpose. Making an email or phone primary asks first; legacy fires +it straight from the row menu, which leaves a failed request with nowhere to report. And the step +that sends the code has a screen of its own; legacy runs it underneath an already-drawn code screen, +so you only see it when it fails. + +Clerk has no maximum-identifiers setting. Two things constrain the lists and neither is a count: +`disableAdditionalIdentifications` on an active enterprise connection hides Add, and the `immutable` +attribute flag hides Add and Remove. An immutable identifier with one verified primary address +leaves a row with no actions, and that read-only state is deliberate. -## Every contact state +## Dialog states -The same view with no backend behind it — one snapshot in, one surface out. This is the contract -from the machine's side, and the list a parity audit checks against the legacy flows. +Choose a state to open the real view directly. This makes loading, errors, and each verification +strategy easy to inspect without clicking through the full flow. It is also the contract from the +machine's side: one snapshot in, one surface out. -## Every profile-field state +## Profile field states -The single-step forms, on the same terms. +The same for the single-step forms behind the profile picture, name, and username. -## Standing in for components Mosaic does not have yet +## Temporary UI -The inputs here are deliberately rough and marked with `TODO` at their definitions. The code field -is a single input styled to look segmented, the country picker is a native `` with no drag-and-drop, and the unattributed-error banner -is a hand-rolled `role="alert"` box. Each is a placeholder for a real -Mosaic component — the OTP and Select ones already have unstyled `@clerk/headless` primitives -waiting behind them. +The code field is a single input until the Mosaic code input is ready. The country picker is a +native select until the Mosaic select is ready, and the profile picture uses a native file input +with no drag and drop. Unattributed errors use a hand-rolled alert until Mosaic has one. -`DialogHeader` / `DialogBody` / `DialogFooter` are the same kind of placeholder, but `DialogBody` -is load bearing rather than cosmetic: a `prompt` clips its overflow under 48rem, so without a -scroll region a tall form loses its submit button off the bottom of the phone sheet. +The dialog header, body, and footer are temporary too, but the body is not only cosmetic: a prompt +clips its overflow under 48rem, so without a scroll region a tall form loses its submit button off +the bottom of the phone sheet. diff --git a/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx index e6ae7548c28..26e05fc7de8 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx @@ -44,7 +44,7 @@ describe('AddContactDialogView', () => { it('keeps submit disabled until the value is longer than a single character', async () => { renderView({ step: 'identifier', value: 'a', isSubmitting: false, errors: {} }); - expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); }); it('submits the identifier', async () => { @@ -71,7 +71,10 @@ describe('AddContactDialogView', () => { renderView({ step: 'identifier', value: 'new@clerk.dev', isSubmitting: true, errors: {} }); expect(screen.getByRole('textbox')).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Adding…' })).toBeInTheDocument(); + // The label stays put and the button announces itself busy, rather than swapping its text + // and reflowing the footer mid-request. + expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar', { name: 'Adding' })).toBeInTheDocument(); }); it('goes inert behind a stacked reverification challenge', () => { @@ -81,7 +84,7 @@ describe('AddContactDialogView', () => { ); expect(screen.getByRole('textbox')).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Add' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); }); it('renders a country picker alongside the number for a phone', () => { @@ -143,7 +146,7 @@ describe('AddContactDialogView', () => { renderView({ ...codeState, code: '424242', status: 'verifying' }); expect(screen.getByRole('textbox', { name: 'Verification code' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Verifying…' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true'); }); it('resends on request', async () => { diff --git a/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx index dbbcdb57cea..8ac488061cf 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx @@ -92,7 +92,7 @@ describe('SetPrimaryContactDialogView', () => { ); expect(screen.getByRole('heading', { name: 'Set as primary email address' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Set as primary' })).toHaveAttribute('aria-busy', 'true'); }); }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx index 2f5f1b780c9..32e59f8a81c 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx @@ -81,7 +81,7 @@ describe('EditNameDialogView', () => { />, ); - expect(screen.getByRole('button', { name: 'Saving…' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-busy', 'true'); }); }); @@ -136,7 +136,7 @@ describe('EditUsernameDialogView', () => { ); expect(screen.getByLabelText('Username')).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); }); }); @@ -156,7 +156,7 @@ describe('EditAvatarDialogView', () => { />, ); - expect(screen.getByRole('button', { name: 'Upload' })).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled(); }); it('names the staged file and enables Upload', () => { @@ -237,7 +237,11 @@ describe('EditAvatarDialogView', () => { />, ); - expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + if (status === 'uploading') { + expect(screen.getByRole('button', { name: 'Upload' })).toHaveAttribute('aria-busy', 'true'); + } else { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + } expect(screen.getByLabelText('Image file')).toBeDisabled(); }); }); diff --git a/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx index ee3a563cb74..f562e6a52bf 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Input } from '../../components/input'; @@ -153,7 +153,7 @@ function IdentifierStep({ return ( <> - + Cancel - + {text.submit} + @@ -267,18 +270,21 @@ function CodeStep({ - + Verify + @@ -344,7 +350,7 @@ function LinkStep({ kind, state, isInterrupted = false, onResend, onCancel }: St /> @@ -389,6 +396,7 @@ function SsoStep({ kind, state, isInterrupted = false, onOpenSsoPopup, onCancel disabled={isInterrupted || state.status === 'awaiting_popup'} focusableWhenDisabled onClick={onOpenSsoPopup} + {...stylex.props(styles.footerButton)} > {state.status === 'error' ? 'Try again' : `Continue with ${state.providerName}`} diff --git a/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx index 4af292a3725..fa9bf9b2854 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/confirm-contact-dialog.view.tsx @@ -1,5 +1,5 @@ import { AlertDialog } from '../../components/alert-dialog'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; import type { ConfirmContactActionState, ContactKind } from './flow.types'; import { FormAlert, Identifier } from './flow-dialog-chrome'; @@ -59,14 +59,15 @@ export function RemoveContactDialogView({ > Cancel - + {text.action} + ); @@ -106,13 +107,14 @@ export function SetPrimaryContactDialogView({ kind, state, onConfirm, onCancel } > Cancel - + Set as primary + ); diff --git a/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx index 087224187a8..f07037f67a6 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/edit-profile-dialog.view.tsx @@ -2,7 +2,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { Avatar } from '../../components/avatar'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Input } from '../../components/input'; @@ -82,20 +82,23 @@ export function EditNameDialogView({ {/* A read-only form keeps Cancel as its only action, as the legacy page does. */} {state.isReadOnly ? null : ( - + Save + )} @@ -144,18 +147,21 @@ export function EditUsernameDialogView({ - + Save + @@ -239,18 +245,19 @@ export function EditAvatarDialogView({ - + Upload + diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts index 46049fd010b..c8b80a28a85 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialogs.styles.ts @@ -27,6 +27,13 @@ export const styles = stylex.create({ minHeight: 0, overflowY: 'auto', }, + /** + * Footer buttons share the width evenly rather than sitting right-aligned at their natural size. + * Matches the security dialogs, so the two sets read as one system. + */ + footerButton: { + flex: '1', + }, // TODO: Replace with `Dialog.Footer` once Mosaic has one. footer: { gap: space['2'], diff --git a/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx index b792f83e12d..46c306195df 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx @@ -1,7 +1,7 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; -import { Button } from '../../components/button'; +import { Button, SubmitButton } from '../../components/button'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { Input } from '../../components/input'; @@ -103,18 +103,21 @@ export function ReverificationDialogView({ - + Continue + From a3b9b683cb3beb48ffa0381c920dfac4be17e6b7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:50:53 -0600 Subject: [PATCH 09/14] fix(ui): compare the account edit form against the saved name fields The discard guard re-derived the saved first and last name by splitting the display name on its first whitespace run, so any first name carrying a space never matched what the form was seeded with and an untouched form always read as dirty. The fields arrive as themselves instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-profile-account-section-flow.stories.tsx | 2 ++ .../user-profile-account-section.view.tsx | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx index bc476be9236..026dfa6336f 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx @@ -497,6 +497,8 @@ export function Default() { } emails={flow.emails} imageUrl={flow.identity.imageUrl} + firstName={flow.identity.firstName} + lastName={flow.identity.lastName} name={`${flow.identity.firstName} ${flow.identity.lastName}`.trim()} phones={flow.phones} username={flow.identity.username} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx index bff6241f67a..6aa095f6bc9 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section.view.tsx @@ -39,6 +39,13 @@ export interface UserProfileAccountSectionViewProps extends AccountSectionFlows allowMultipleAccounts?: boolean; imageUrl?: string; name: string; + /** + * The saved name as its two fields. Required to drive {@link AccountSectionFlows.editProfile}: + * the edit form's discard guard compares against these, and `name` cannot be split back into + * them without corrupting a first name that carries a space. + */ + firstName?: string; + lastName?: string; username: string; emails: UserProfileEmail[]; phones: UserProfilePhone[]; @@ -66,6 +73,8 @@ export function UserProfileAccountSectionView({ reverification, imageUrl, name, + firstName = '', + lastName = '', username, emails, phones, @@ -215,8 +224,9 @@ export function UserProfileAccountSectionView({ confirmContact={confirmContact} editProfile={editProfile} fallback={initials} + firstName={firstName} flowTriggerRef={flowTriggerRef} - name={name} + lastName={lastName} reverification={reverification} username={username} /> From c719200c34d4ed532b8767ddd65c5ed7dcc54826 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:51:00 -0600 Subject: [PATCH 10/14] fix(ui): give the account section's dialogs somewhere to put a stray challenge A reverification challenge was rendered inside whichever flow dialog was open, so one raised by an action with no dialog of its own was rendered nowhere and the mutation waited on a prompt the user never saw. It now gets its own surface when nothing is hosting it. Alongside it, three fixes to the same view: the add-contact dialog no longer dismisses on an outside press once an identifier exists server-side with a code in flight, the discard guard stops recording focus that belongs to a surface stacked above it, and the guard reads the saved name from its own fields. Co-Authored-By: Claude Opus 5 (1M context) --- .../account-section-dialogs.view.test.tsx | 47 ++++++++++++++++++ .../dialogs/account-section-dialogs.view.tsx | 48 +++++++++++++------ 2 files changed, 81 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx index fe30a43f2cc..65dd9b3597f 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/account-section-dialogs.view.test.tsx @@ -9,6 +9,8 @@ import { UserProfileAccountSectionView } from '../user-profile-account-section.v const rows: UserProfileAccountSectionViewProps = { name: 'Preston Booth', + firstName: 'Preston', + lastName: 'Booth', username: 'prestonxyz', emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], phones: [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }], @@ -135,6 +137,51 @@ describe('UserProfileAccountSectionView flows', () => { expect(screen.getByRole('heading', { name: 'Update username' })).toBeInTheDocument(); }); + it('reads the saved name from its two fields, so a first name with a space is not dirty on open', async () => { + const onCancel = vi.fn(); + render( + + + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledOnce(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('renders a challenge raised outside any flow dialog, which would otherwise have no surface', () => { + renderSection({ + reverification: { + state: { + strategy: 'password', + value: '', + status: 'idle', + errors: {}, + resend: { isResending: false, secondsRemaining: 0 }, + }, + onValueChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + onCancel: vi.fn(), + }, + }); + + expect(screen.getByRole('heading', { name: /Verify it/ })).toBeInTheDocument(); + }); + it('closes without asking when the field matches what is saved', async () => { const onCancel = vi.fn(); renderSection({ diff --git a/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx index 903194371d5..11ce5ab8911 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/account-section-dialogs.view.tsx @@ -52,8 +52,13 @@ function FlowDialog({ open, finalFocus, closedBy, onOpenChange, onFocusCapture, } export interface AccountSectionDialogsViewProps extends AccountSectionFlows { - /** The saved values, so a form can tell whether it has been edited. */ - name: string; + /** + * The saved values, so a form can tell whether it has been edited. The name arrives as its two + * fields rather than as the display string: recomposing it and splitting it back is lossy for + * any first name that carries a space, and the form edits the fields. + */ + firstName: string; + lastName: string; username: string; /** Initials for the avatar preview when there is no image. */ fallback: string; @@ -68,7 +73,8 @@ export interface AccountSectionDialogsViewProps extends AccountSectionFlows { * its exit transition with its contents frozen, so it does not visibly collapse on the way out. */ export function AccountSectionDialogsView({ - name, + firstName, + lastName, username, fallback, flowTriggerRef, @@ -109,7 +115,14 @@ export function AccountSectionDialogsView({ return ( <> + {/* A challenge raised by an action that has no dialog of its own still needs a surface, or + the mutation waits on a prompt that was never drawn. */} + {host === null ? challenge : null} + { @@ -141,9 +154,10 @@ export function AccountSectionDialogsView({ challenge={host === 'edit' ? challenge : null} editProfile={editProfile} fallback={fallback} + firstName={firstName} flowTriggerRef={flowTriggerRef} isInterrupted={Boolean(reverification)} - name={name} + lastName={lastName} username={username} /> @@ -183,16 +197,18 @@ export function AccountSectionDialogsView({ } /** Whether the open form differs from what is saved, which is what the discard guard asks about. */ -function isDirty(editProfile: EditProfileFlow | null | undefined, name: string, username: string): boolean { +function isDirty( + editProfile: EditProfileFlow | null | undefined, + saved: { firstName: string; lastName: string; username: string }, +): boolean { if (!editProfile) { return false; } if (editProfile.field === 'name') { - const [firstName = '', ...rest] = name.split(/\s+/); - return editProfile.state.firstName !== firstName || editProfile.state.lastName !== rest.join(' '); + return editProfile.state.firstName !== saved.firstName || editProfile.state.lastName !== saved.lastName; } if (editProfile.field === 'username') { - return editProfile.state.value !== username; + return editProfile.state.value !== saved.username; } return Boolean(editProfile.state.fileName); } @@ -206,7 +222,8 @@ function isDirty(editProfile: EditProfileFlow | null | undefined, name: string, function EditProfileDialog({ challenge, editProfile, - name, + firstName, + lastName, username, fallback, flowTriggerRef, @@ -214,7 +231,8 @@ function EditProfileDialog({ }: { challenge: React.ReactNode; editProfile: EditProfileFlow | null | undefined; - name: string; + firstName: string; + lastName: string; username: string; fallback: string; flowTriggerRef?: React.RefObject; @@ -233,16 +251,18 @@ function EditProfileDialog({ */ const lastFocus = React.useRef(null); const rememberFocus = (event: React.FocusEvent) => { - // React portals bubble through the React tree, so the confirmation's own buttons reach this - // handler too. Recording those would return focus to a button that no longer exists. - if (!event.target.closest('[role="alertdialog"]')) { + // React portals bubble through the React tree, so the confirmation and the reverification + // challenge stacked over this dialog reach this handler too. Recording those would return focus + // to a control that unmounted with them, which lands on the body. `currentTarget` is this + // dialog's own popup, so anything whose nearest dialog is not it belongs to a surface above. + if (event.target.closest('[role="dialog"],[role="alertdialog"]') === event.currentTarget) { lastFocus.current = event.target; } }; const onOpenChange = useConfirmedClose({ handle: discardConfirm, - when: () => isDirty(editProfile, name, username), + when: () => isDirty(editProfile, { firstName, lastName, username }), onOpenChange: open => { if (!open) { editProfile?.onCancel(); From a1f570388e8315a423bb0ddc5567a7cdfceff779 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:51:36 -0600 Subject: [PATCH 11/14] fix(ui): re-arm the account flow's code auto-submit, and hand it the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CodeInput` fired `onComplete` with no argument, right after `onChange`, so a driver holding the code in ordinary React state read it a digit short and could only ever fail. It passes the completed value now — the signature `@clerk/ui`'s security dialogs already settled on. Its latch also only cleared when the code got shorter, so replacing a full code outright — pasting over a selection, or retyping after one was rejected — never auto-submitted again. It latches on the value it submitted instead. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-profile/dialogs/flow-dialog-chrome.tsx | 16 ++++++++++------ .../mosaic/user-profile/dialogs/flow.types.ts | 5 +---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx index dd6cb03c0fd..dbb34d599c1 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx @@ -146,7 +146,8 @@ export interface CodeInputProps { disabled?: boolean; autoFocus?: boolean; /** Fired once the final digit lands, matching the legacy `onCodeEntryFinished` auto-submit. */ - onComplete: () => void; + /** Fired once the final digit lands, matching the legacy `onCodeEntryFinished` auto-submit. */ + onComplete: (value: string) => void; onChange: (value: string) => void; } @@ -168,17 +169,20 @@ export function CodeInput({ }: CodeInputProps) { // No `maxLength`: it would truncate a pasted value before the non-digit strip below runs, so // `42-4242` would lose its last digit rather than its separator. The handler owns the length. - const completedRef = React.useRef(false); + // The last value auto-submitted, rather than a latched boolean: a boolean only clears when the + // code gets shorter, so replacing a full code wholesale — pasting over a selection, or retyping + // after a rejected one — would never reach `length` again and never auto-submit. + const completedRef = React.useRef(null); const handleChange = (next: string) => { const digits = next.replace(/\D/g, '').slice(0, length); onChange(digits); - if (digits.length === length && !completedRef.current) { - completedRef.current = true; - onComplete(); + if (digits.length === length && completedRef.current !== digits) { + completedRef.current = digits; + onComplete(digits); } if (digits.length < length) { - completedRef.current = false; + completedRef.current = null; } }; diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts index 266e12230d0..b6da54c2c1c 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts @@ -108,10 +108,7 @@ export interface AddContactFlowActions { onCancel: () => void; } -/** - * A destructive confirmation. `messageLine2` is only present for a verified contact — legacy - * suppresses the "you will no longer be able to sign in" line on an unverified one. - */ +/** A destructive confirmation, awaiting the user's answer. */ export interface ConfirmContactActionState { identifier: string; isSubmitting: boolean; From 9d39a206e7d8b1e4c09a6948d0efd9ea3e99e144 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:51:36 -0600 Subject: [PATCH 12/14] fix(ui): keep the account flow's pending buttons in the tab order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every submit button whose action was in flight was passed a `disabled` that was true because of that action, which `Button` turns into the native attribute — the exact thing `SubmitButton`'s `isPending` exists to avoid. Pressing Verify dropped the button out of the tab order mid-action, taking focus to the body just as the busy state and any error that followed were announced. `disabled` now carries only the real preconditions. Alongside it, three fixes to the same views: the reverification dialog has copy for a code challenge that carries no identifier rather than trailing off, the avatar dialog clears its file input so re-picking the same file after a failed upload still registers, and the add-contact guard counts digits for a phone, whose value is seeded with a dial code that made Add live over an empty field. Co-Authored-By: Claude Opus 5 (1M context) --- .../confirm-contact-dialog.view.test.tsx | 29 +++++++++++++++++-- .../edit-profile-dialog.view.test.tsx | 27 +++++++++++++++-- .../dialogs/add-contact-dialog.view.tsx | 8 +++-- .../dialogs/edit-profile-dialog.view.tsx | 4 ++- .../dialogs/reverification-dialog.view.tsx | 6 ++-- 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx index 8ac488061cf..c9d5fa8b93c 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/confirm-contact-dialog.view.test.tsx @@ -12,7 +12,7 @@ import { ReverificationDialogView } from '../dialogs/reverification-dialog.view' const IDLE: ConfirmContactActionState = { identifier: 'item2@clerk.dev', isSubmitting: false, errors: {} }; function renderAlert(children: React.ReactNode) { - render( + return render( {children} , @@ -51,18 +51,41 @@ describe('RemoveContactDialogView', () => { it('confirms, then locks both actions while the removal is in flight', async () => { const onConfirm = vi.fn(); - renderAlert( + const onCancel = vi.fn(); + const { rerender } = renderAlert( , ); await userEvent.click(screen.getByRole('button', { name: 'Remove' })); expect(onConfirm).toHaveBeenCalledOnce(); + + rerender( + + + + + , + ); + + // Remove keeps the tab order — it goes inert through `aria-disabled` so focus survives the + // action it just started — while Cancel, which has no pending state of its own, goes disabled. + const remove = screen.getByRole('button', { name: /Remove/ }); + expect(remove).toHaveAttribute('aria-disabled', 'true'); + await userEvent.click(remove); + expect(onConfirm).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); }); it('reports a failure that has no field to land in', () => { diff --git a/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx index 32e59f8a81c..d86aacc0cd1 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/edit-profile-dialog.view.test.tsx @@ -159,17 +159,38 @@ describe('EditAvatarDialogView', () => { expect(screen.getByRole('button', { name: 'Upload' })).toBeDisabled(); }); - it('names the staged file and enables Upload', () => { + it('names the staged file and enables Upload', async () => { + const handlers = avatarHandlers(); renderDialog( , ); expect(screen.getByText('headshot.png')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Upload' })).not.toHaveAttribute('aria-disabled', 'true'); + const upload = screen.getByRole('button', { name: 'Upload' }); + expect(upload).toBeEnabled(); + expect(upload).not.toHaveAttribute('aria-disabled', 'true'); + + await userEvent.click(upload); + expect(handlers.onSubmit).toHaveBeenCalledOnce(); + }); + + it('stays focusable while the upload runs, rather than dropping out of the tab order', () => { + renderDialog( + , + ); + + const upload = screen.getByRole('button', { name: /Upload/ }); + expect(upload).toBeEnabled(); + expect(upload).toHaveAttribute('aria-disabled', 'true'); + expect(upload).toHaveAttribute('aria-busy', 'true'); }); it('passes a chosen file up rather than validating it itself', async () => { diff --git a/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx index f562e6a52bf..d916212e697 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/add-contact-dialog.view.tsx @@ -148,8 +148,10 @@ function IdentifierStep({ }: StepProps<'identifier'>) { const text = copy[kind]; const fieldId = React.useId(); - // Matches the legacy guard: anything past a single character, and never the current username. - const canSubmit = state.value.trim().length > 1 && !state.isSubmitting && !isInterrupted; + // Matches the legacy guard: anything past a single character. A phone value is seeded with its + // dial code, so it counts digits instead — otherwise Add is live over an empty number. + const hasValue = kind === 'phone' ? state.value.replace(/\D/g, '').length > 1 : state.value.trim().length > 1; + const canSubmit = hasValue && !isInterrupted; return ( <> @@ -278,7 +280,7 @@ function CodeStep({ Cancel { const file = event.target.files?.[0]; + // Cleared so re-picking the same file after a failed upload still fires `change`. + event.target.value = ''; if (file) { onSelectFile(file); } @@ -252,7 +254,7 @@ export function EditAvatarDialogView({ Cancel diff --git a/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx index 46c306195df..f75075a3c55 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/reverification-dialog.view.tsx @@ -41,17 +41,19 @@ export function ReverificationDialogView({ const fieldId = React.useId(); const isCode = state.strategy !== 'password'; const inert = state.status === 'verifying'; - const canSubmit = state.value.length > 0 && !inert; + const canSubmit = state.value.length > 0; return ( <> Enter the verification code sent to {state.identifier} + ) : isCode ? ( + 'Enter the verification code we sent you.' ) : ( 'Enter your password to continue.' ) From 8b939a874f3b28ed2f377cb3c6003850c1fdf795 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:51:49 -0600 Subject: [PATCH 13/14] fix(swingset): drop the account flow harness's writes when a dialog closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `add.*`, `edit.*` and `confirm.*` dispatches are scoped to an open flow, but `contacts.*` and `identity.set` are not — so cancelling during the simulated latency still added the address, removed the contact or committed the name, and the harness reported a mutation the user had abandoned. Every continuation now re-reads the flow after its sleep and bails if it is gone. This is the shape the state machine will copy, so the cancellation semantics are the point. Two more in the same file: the flow trigger is captured from the row's menu button rather than from the menu item, which unmounts with the menu and leaves focus to fall to the body; and a staged avatar's object URL is revoked once it is replaced or abandoned rather than pinning its blob for the life of the page. Co-Authored-By: Claude Opus 5 (1M context) --- ...er-profile-account-section-flow.harness.ts | 65 +++++++++++++++++-- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts index dbbcfe88f1f..ed15ea2b464 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts +++ b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts @@ -605,11 +605,35 @@ export function useAccountSectionFlow({ const triggerRef = useRef(null); const captureTrigger = useCallback(() => { const active = document.activeElement; - triggerRef.current = active instanceof HTMLElement ? active : null; + if (!(active instanceof HTMLElement)) { + triggerRef.current = null; + return; + } + // A row action is dispatched from a menu item, and the menu unmounts as it closes — returning + // focus to that item puts it on a detached node, which is the body. The menu's trigger outlives + // it, and is where focus would have gone anyway had the menu closed on its own. + const menu = active.closest('[role="menu"]'); + const opener = menu?.id ? document.querySelector(`[aria-controls="${CSS.escape(menu.id)}"]`) : null; + triggerRef.current = opener ?? active; }, []); const sleep = useCallback((ms: number) => new Promise(resolve => setTimeout(resolve, ms)), []); + /** + * The object URL for the staged avatar, so it can be released. + * + * `URL.createObjectURL` pins the blob for the life of the document, so a preview that is replaced + * or abandoned leaks until the page goes away. Only the one that reached `identity` is kept. + */ + const stagedAvatarUrl = useRef(null); + const releaseStagedAvatar = useCallback(() => { + const staged = stagedAvatarUrl.current; + stagedAvatarUrl.current = null; + if (staged && staged !== stateRef.current.identity.imageUrl) { + URL.revokeObjectURL(staged); + } + }, []); + /** Runs the reverification gate, if configured, and reports whether the caller may proceed. */ const gate = useCallback(async () => { if (!settingsRef.current.requireReverification) { @@ -743,6 +767,11 @@ export function useAccountSectionFlow({ dispatch({ type: 'add.codeStatus', status: 'error', message: 'Incorrect code. Please try again.' }); return; } + // The flow reducer ignores `add.*` once the dialog is closed, but `contacts.add` is not scoped + // to it — without this, cancelling mid-request still adds the identifier. + if (stateRef.current.add?.state.step !== 'code') { + return; + } dispatch({ type: 'add.codeStatus', status: 'success' }); dispatch({ type: 'contacts.add', kind, record: makeRecord(identifier, true) }); // Hold on the check mark before moving on, as the legacy OTP control does. @@ -774,6 +803,9 @@ export function useAccountSectionFlow({ dispatch({ type: 'add.ssoStatus', status: 'error', message: 'Verification was cancelled or failed.' }); return; } + if (stateRef.current.add?.state.step !== 'sso') { + return; + } dispatch({ type: 'contacts.add', kind: 'email', record: makeRecord(identifier, true) }); dispatch({ type: 'add.success', identifier }); }, [dispatch, sleep]); @@ -797,9 +829,10 @@ export function useAccountSectionFlow({ const closeEdit = useCallback(() => { reverificationGate.current?.resolve(false); reverificationGate.current = null; + releaseStagedAvatar(); dispatch({ type: 'reverification.close' }); dispatch({ type: 'edit.close' }); - }, [dispatch]); + }, [dispatch, releaseStagedAvatar]); const submitEdit = useCallback(async () => { const current = stateRef.current.edit; @@ -825,6 +858,9 @@ export function useAccountSectionFlow({ dispatch({ type: 'edit.error', errors: { firstName: 'Enter a first name.' } }); return; } + if (stateRef.current.edit?.field !== 'name') { + return; + } dispatch({ type: 'identity.set', identity: { firstName: firstName.trim(), lastName: lastName.trim() } }); dispatch({ type: 'edit.close' }); return; @@ -859,6 +895,9 @@ export function useAccountSectionFlow({ dispatch({ type: 'edit.error', errors: { field: 'That username is taken. Please try another.' } }); return; } + if (stateRef.current.edit?.field !== 'username') { + return; + } dispatch({ type: 'identity.set', identity: { username: trimmed } }); dispatch({ type: 'edit.close' }); return; @@ -875,9 +914,13 @@ export function useAccountSectionFlow({ dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); return; } + if (stateRef.current.edit?.field !== 'avatar') { + return; + } dispatch({ type: 'identity.set', identity: { imageUrl: nextUrl } }); + releaseStagedAvatar(); dispatch({ type: 'edit.close' }); - }, [dispatch, gate, sleep]); + }, [dispatch, gate, releaseStagedAvatar, sleep]); /** * Type and size are checked before anything is sent, matching the legacy uploader — a rejected @@ -893,9 +936,12 @@ export function useAccountSectionFlow({ dispatch({ type: 'edit.error', errors: { field: 'That image is larger than 10MB.' } }); return; } - dispatch({ type: 'edit.avatarFile', fileName: file.name, previewUrl: URL.createObjectURL(file) }); + releaseStagedAvatar(); + const previewUrl = URL.createObjectURL(file); + stagedAvatarUrl.current = previewUrl; + dispatch({ type: 'edit.avatarFile', fileName: file.name, previewUrl }); }, - [dispatch], + [dispatch, releaseStagedAvatar], ); const removeAvatar = useCallback(async () => { @@ -910,9 +956,13 @@ export function useAccountSectionFlow({ dispatch({ type: 'edit.error', errors: { form: 'Something went wrong. Please try again.' } }); return; } + if (stateRef.current.edit?.field !== 'avatar') { + return; + } dispatch({ type: 'identity.set', identity: { imageUrl: undefined } }); + releaseStagedAvatar(); dispatch({ type: 'edit.close' }); - }, [dispatch, sleep]); + }, [dispatch, releaseStagedAvatar, sleep]); const openConfirm = useCallback( (pending: PendingConfirm, identifier: string) => { @@ -944,6 +994,9 @@ export function useAccountSectionFlow({ dispatch({ type: 'confirm.error', message: 'Something went wrong. Please try again.' }); return; } + if (!stateRef.current.confirm) { + return; + } const { pending } = current; if (pending.action === 'remove') { dispatch({ type: 'contacts.remove', kind: pending.kind, id: pending.id }); From 7163407d2b894cdce1c6628b6af562047aa7b187 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 21 Aug 2026 16:55:42 -0600 Subject: [PATCH 14/14] fix(ui): carry the completed code on the flow's submit events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CodeInput` hands the completed value to `onComplete`, but the flow contract dropped it: `onSubmitCode` and the challenge's `onSubmit` took no argument, so the value died at the view boundary and a driver holding the code in ordinary React state could still only read it a digit short. The harness got away with it by keeping a synchronous shadow of its reducer state — which is the workaround, not the contract. Both now take the value as an OPTIONAL argument: passed when the final digit fires them, omitted when the button does. A machine, whose context is already current by the time the event arrives, can go on ignoring it. This is the shape the security dialogs settled on for the same problem. Also drops a JSDoc line that ended up duplicated on `onComplete`. Co-Authored-By: Claude Opus 5 (1M context) --- ...er-profile-account-section-flow.harness.ts | 100 ++++++++++-------- ...r-profile-account-section-flow.stories.tsx | 4 +- .../add-contact-dialog.view.test.tsx | 21 +++- .../dialogs/flow-dialog-chrome.tsx | 1 - .../mosaic/user-profile/dialogs/flow.types.ts | 11 +- 5 files changed, 86 insertions(+), 51 deletions(-) diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts index ed15ea2b464..2f4b93f5710 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts +++ b/packages/swingset/src/stories/user-profile-account-section-flow.harness.ts @@ -752,32 +752,38 @@ export function useAccountSectionFlow({ dispatch({ type: 'add.verify', identifier, strategy, providerName: settingsRef.current.ssoProviderName }); }, [dispatch, gate, resolveStrategy, sleep]); - const submitCode = useCallback(async () => { - const current = stateRef.current.add; - if (current?.state.step !== 'code' || current.state.status === 'verifying') { - return; - } - const { code, identifier } = current.state; - const { kind } = current; + const submitCode = useCallback( + async (completedCode?: string) => { + const current = stateRef.current.add; + if (current?.state.step !== 'code' || current.state.status === 'verifying') { + return; + } + const { identifier } = current.state; + const { kind } = current; + // Prefer what the input handed up. The shadow state is current too, but only because this + // harness keeps one — a driver on ordinary React state would read a digit short here. + const code = completedCode ?? current.state.code; - dispatch({ type: 'add.codeStatus', status: 'verifying' }); - await sleep(settingsRef.current.latencyMs); + dispatch({ type: 'add.codeStatus', status: 'verifying' }); + await sleep(settingsRef.current.latencyMs); - if (code !== settingsRef.current.validCode) { - dispatch({ type: 'add.codeStatus', status: 'error', message: 'Incorrect code. Please try again.' }); - return; - } - // The flow reducer ignores `add.*` once the dialog is closed, but `contacts.add` is not scoped - // to it — without this, cancelling mid-request still adds the identifier. - if (stateRef.current.add?.state.step !== 'code') { - return; - } - dispatch({ type: 'add.codeStatus', status: 'success' }); - dispatch({ type: 'contacts.add', kind, record: makeRecord(identifier, true) }); - // Hold on the check mark before moving on, as the legacy OTP control does. - await sleep(600); - dispatch({ type: 'add.success', identifier }); - }, [dispatch, sleep]); + if (code !== settingsRef.current.validCode) { + dispatch({ type: 'add.codeStatus', status: 'error', message: 'Incorrect code. Please try again.' }); + return; + } + // The flow reducer ignores `add.*` once the dialog is closed, but `contacts.add` is not scoped + // to it — without this, cancelling mid-request still adds the identifier. + if (stateRef.current.add?.state.step !== 'code') { + return; + } + dispatch({ type: 'add.codeStatus', status: 'success' }); + dispatch({ type: 'contacts.add', kind, record: makeRecord(identifier, true) }); + // Hold on the check mark before moving on, as the legacy OTP control does. + await sleep(600); + dispatch({ type: 'add.success', identifier }); + }, + [dispatch, sleep], + ); const resend = useCallback(async () => { const current = stateRef.current.add; @@ -1006,28 +1012,32 @@ export function useAccountSectionFlow({ dispatch({ type: 'confirm.close' }); }, [dispatch, gate, sleep]); - const submitReverification = useCallback(async () => { - const current = stateRef.current.reverification; - if (!current || current.status === 'verifying') { - return; - } - dispatch({ type: 'reverification.status', status: 'verifying' }); - await sleep(settingsRef.current.latencyMs); + const submitReverification = useCallback( + async (completedValue?: string) => { + const current = stateRef.current.reverification; + if (!current || current.status === 'verifying') { + return; + } + const value = completedValue ?? current.value; + dispatch({ type: 'reverification.status', status: 'verifying' }); + await sleep(settingsRef.current.latencyMs); - const expected = - current.strategy === 'password' ? settingsRef.current.validPassword : settingsRef.current.validCode; - if (current.value !== expected) { - dispatch({ - type: 'reverification.status', - status: 'error', - message: current.strategy === 'password' ? 'Incorrect password.' : 'Incorrect code. Please try again.', - }); - return; - } - dispatch({ type: 'reverification.close' }); - reverificationGate.current?.resolve(true); - reverificationGate.current = null; - }, [dispatch, sleep]); + const expected = + current.strategy === 'password' ? settingsRef.current.validPassword : settingsRef.current.validCode; + if (value !== expected) { + dispatch({ + type: 'reverification.status', + status: 'error', + message: current.strategy === 'password' ? 'Incorrect password.' : 'Incorrect code. Please try again.', + }); + return; + } + dispatch({ type: 'reverification.close' }); + reverificationGate.current?.resolve(true); + reverificationGate.current = null; + }, + [dispatch, sleep], + ); const cancelReverification = useCallback(() => { dispatch({ type: 'reverification.close' }); diff --git a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx index 026dfa6336f..74dcce281ed 100644 --- a/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section-flow.stories.tsx @@ -452,7 +452,7 @@ export function Default() { onCodeChange: flow.setCode, onOpenSsoPopup: () => void flow.openSsoPopup(), onResend: () => void flow.resend(), - onSubmitCode: () => void flow.submitCode(), + onSubmitCode: code => void flow.submitCode(code), onSubmitIdentifier: () => void flow.submitIdentifier(), onValueChange: flow.setIdentifier, } @@ -490,7 +490,7 @@ export function Default() { state: flow.reverification, onCancel: flow.cancelReverification, onResend: () => void flow.resendReverification(), - onSubmit: () => void flow.submitReverification(), + onSubmit: value => void flow.submitReverification(value), onValueChange: flow.setReverificationValue, } : null diff --git a/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx index 26e05fc7de8..2bad3d36002 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/add-contact-dialog.view.test.tsx @@ -123,7 +123,26 @@ describe('AddContactDialogView', () => { await userEvent.paste('424242'); expect(handlers.onCodeChange).toHaveBeenCalledWith('424242'); - expect(handlers.onSubmitCode).toHaveBeenCalledOnce(); + // The completed code rides along: a driver holding it in ordinary React state cannot read + // what it just set, so the value has to arrive with the event. + expect(handlers.onSubmitCode).toHaveBeenCalledWith('424242'); + }); + + it('re-arms after a rejected code is replaced wholesale', async () => { + const handlers = renderView(codeState); + const field = screen.getByRole('textbox', { name: 'Verification code' }); + + await userEvent.click(field); + await userEvent.paste('111111'); + expect(handlers.onSubmitCode).toHaveBeenCalledWith('111111'); + + // Selecting all and pasting over never shortens the value, so a latched boolean would + // never let this fire again. + await userEvent.clear(field); + await userEvent.paste('424242'); + + expect(handlers.onSubmitCode).toHaveBeenCalledWith('424242'); + expect(handlers.onSubmitCode).toHaveBeenCalledTimes(2); }); it('ignores non-digits and stops at the code length', async () => { diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx index dbb34d599c1..e585f1fe7f1 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow-dialog-chrome.tsx @@ -146,7 +146,6 @@ export interface CodeInputProps { disabled?: boolean; autoFocus?: boolean; /** Fired once the final digit lands, matching the legacy `onCodeEntryFinished` auto-submit. */ - /** Fired once the final digit lands, matching the legacy `onCodeEntryFinished` auto-submit. */ onComplete: (value: string) => void; onChange: (value: string) => void; } diff --git a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts index b6da54c2c1c..e596d04f1ff 100644 --- a/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts +++ b/packages/ui/src/mosaic/user-profile/dialogs/flow.types.ts @@ -102,7 +102,13 @@ export interface AddContactFlowActions { onValueChange: (value: string) => void; onSubmitIdentifier: () => void; onCodeChange: (code: string) => void; - onSubmitCode: () => void; + /** + * The completed code is passed when the final digit fires this, and omitted when the button + * does. Optional rather than required so a machine, whose context is already current by the + * time this arrives, can keep ignoring it — while a driver holding the code in ordinary React + * state, which cannot read what it just set, has the value to hand. + */ + onSubmitCode: (completedCode?: string) => void; onResend: () => void; onOpenSsoPopup: () => void; onCancel: () => void; @@ -131,7 +137,8 @@ export interface ReverificationChallengeState { export interface ReverificationChallengeActions { onValueChange: (value: string) => void; - onSubmit: () => void; + /** Carries the completed code on auto-submit, as {@link AddContactFlowActions.onSubmitCode} does. */ + onSubmit: (completedValue?: string) => void; onResend: () => void; onCancel: () => void; }