portal-mojo 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,822 @@
1
+ // account/dialogs — the AccountModal's focused flows, each a `modal.open`
2
+ // body stacked over the modal: add passkey, authenticator (TOTP) enrolment,
3
+ // recovery codes (view / regenerate / shown-once), change password,
4
+ // email change, phone add/change, verification code, avatar.
5
+ //
6
+ // Invariants (brief #7184):
7
+ // · one-time values — TOTP secret/uri/QR, recovery codes, the phone
8
+ // change session_token — live in THIS dialog's component state only;
9
+ // none of these flows uses useMutation/useQuery, so no TanStack cache
10
+ // ever observes them, and they die with the dialog
11
+ // · the QR renders only from a `data:image/(png|svg+xml);base64,` value
12
+ // · every server-gated call runs under withFreshAuth (in account/api or
13
+ // client/auth); a dismissed step-up reads "For your security, sign in
14
+ // again to make this change."
15
+ // · server messages are shown as written
16
+ // · a shown-once code set cannot be dismissed until "I saved these" is
17
+ // ticked; pending writes block Escape/backdrop dismissal
18
+ import { useQueryClient } from '@tanstack/react-query';
19
+ import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react';
20
+ import {
21
+ downloadBlob, getAuthSnapshot, isReauthRequired, useMe, type Me,
22
+ } from '../client/runtime';
23
+ import {
24
+ ImageField, PasswordStrengthMeter, copyText, modal, toast,
25
+ type FileFieldOwnerResult,
26
+ } from '../ui';
27
+ import {
28
+ changePassword, confirmEmailChange, confirmPhoneChange, confirmTotp, confirmVerificationCode,
29
+ getMaskedRecoveryCodes, passkeyErrorMessage, registerPasskey, regenerateRecoveryCodes,
30
+ requestEmailChange, requestPhoneChange, revokeOtherSessions, safeQrDataUrl, sendVerificationCode, startTotpSetup,
31
+ suggestPasskeyName,
32
+ } from './api';
33
+ import { MeSaveModel, PasskeyModel, accountKeys, invalidateMe } from './models';
34
+
35
+ export const FRESH_AUTH_COPY = 'For your security, sign in again to make this change.';
36
+
37
+ /** One error voice for every account flow: step-up, authenticator, server text as written. */
38
+ export function accountErrorMessage(error: unknown): string {
39
+ if (isReauthRequired(error)) return FRESH_AUTH_COPY;
40
+ return passkeyErrorMessage(error);
41
+ }
42
+
43
+ /** Mutable dismissal lock shared between a dialog body and its modal.open. */
44
+ interface DismissLock { locked: boolean }
45
+
46
+ const newLock = (): DismissLock => ({ locked: false });
47
+
48
+ function useLock(lock: DismissLock, locked: boolean) {
49
+ useEffect(() => {
50
+ lock.locked = locked;
51
+ return () => { lock.locked = false; };
52
+ }, [lock, locked]);
53
+ }
54
+
55
+ function CodeInput({ value, onChange, label = 'Code', autoFocus = true }: {
56
+ value: string; onChange: (value: string) => void; label?: string; autoFocus?: boolean;
57
+ }) {
58
+ const id = useId();
59
+ return (
60
+ <label className="field" htmlFor={id}>
61
+ <span className="field-label">{label}</span>
62
+ <input
63
+ id={id}
64
+ className="input acct-code-input"
65
+ inputMode="numeric"
66
+ autoComplete="one-time-code"
67
+ maxLength={6}
68
+ placeholder="123456"
69
+ value={value}
70
+ autoFocus={autoFocus}
71
+ onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, 6))}
72
+ />
73
+ </label>
74
+ );
75
+ }
76
+
77
+ function Alert({ children }: { children: ReactNode }) {
78
+ return children ? <div className="form-alert" role="alert">{children}</div> : null;
79
+ }
80
+
81
+ // ── Add passkey ───────────────────────────────────────────────────────
82
+
83
+ export function openAddPasskeyDialog(): Promise<boolean> {
84
+ const lock = newLock();
85
+ return modal.open<boolean | null>((close) => <AddPasskeyDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
86
+ .then((v) => v === true);
87
+ }
88
+
89
+ function AddPasskeyDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
90
+ const qc = useQueryClient();
91
+ const [name, setName] = useState(() => suggestPasskeyName());
92
+ const [busy, setBusy] = useState(false);
93
+ const [error, setError] = useState('');
94
+ const nameId = useId();
95
+ useLock(lock, busy);
96
+ const host = typeof window !== 'undefined' ? window.location.hostname : '';
97
+
98
+ const submit = async (e: FormEvent) => {
99
+ e.preventDefault();
100
+ setBusy(true);
101
+ setError('');
102
+ try {
103
+ await registerPasskey(name);
104
+ await Promise.all([qc.invalidateQueries({ queryKey: PasskeyModel.keys.root }), invalidateMe(qc)]);
105
+ toast.success('Passkey added');
106
+ close(true);
107
+ } catch (err) {
108
+ setError(accountErrorMessage(err));
109
+ setBusy(false);
110
+ }
111
+ };
112
+
113
+ return (
114
+ <form className="modal-pad acct-dialog" onSubmit={submit}>
115
+ <span className="eyebrow">Add a passkey</span>
116
+ <div className="acct-dialog-lead">
117
+ <span className="acct-card-icon" aria-hidden="true"><i className="bi bi-fingerprint" /></span>
118
+ <div>
119
+ <h2 className="modal-title">Use this device to sign in</h2>
120
+ <p className="acct-muted">Your browser will ask for Touch ID, Face ID, Windows Hello or a security key.</p>
121
+ </div>
122
+ </div>
123
+ <Alert>{error}</Alert>
124
+ <label className="field" htmlFor={nameId}>
125
+ <span className="field-label">Name this passkey</span>
126
+ <input id={nameId} className="input" value={name} maxLength={100} autoFocus onChange={(e) => setName(e.target.value)} />
127
+ <span className="field-help">Suggested from this device. It only has to make sense to you.</span>
128
+ </label>
129
+ {host && <p className="acct-note">A passkey works only on the site it was made on — this one will work on <b>{host}</b>.</p>}
130
+ <div className="modal-actions">
131
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
132
+ <button type="submit" className="btn btn-primary" disabled={busy}>
133
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Continue
134
+ </button>
135
+ </div>
136
+ </form>
137
+ );
138
+ }
139
+
140
+ // ── Recovery codes — the shown-once panel ─────────────────────────────
141
+
142
+ export function RecoveryCodesPanel({ codes, step, onDone }: { codes: string[]; step?: string; onDone: () => void }) {
143
+ const [saved, setSaved] = useState(false);
144
+ const [copied, setCopied] = useState(false);
145
+ const savedId = useId();
146
+ const text = codes.join('\n');
147
+ const download = () => {
148
+ // downloadBlob revokes its object URL right after the click.
149
+ downloadBlob(new Blob([`Recovery codes — each signs you in once.\n\n${text}\n`], { type: 'text/plain' }), 'recovery-codes.txt');
150
+ };
151
+ return (
152
+ <div className="acct-dialog">
153
+ {step && <span className="eyebrow">{step}</span>}
154
+ <h2 className="modal-title">Save your recovery codes</h2>
155
+ <p className="acct-muted">Each signs you in once if you lose your phone.</p>
156
+ <div className="acct-warning" role="alert">
157
+ <i className="bi bi-exclamation-triangle-fill" aria-hidden="true" />
158
+ <span>Save these codes now. They will not be shown again. Old codes no longer work.</span>
159
+ </div>
160
+ <ul className="acct-codes" aria-label="Recovery codes">
161
+ {codes.map((code) => <li key={code}><code>{code}</code></li>)}
162
+ </ul>
163
+ <div className="acct-codes-actions">
164
+ <button
165
+ type="button"
166
+ className="btn btn-compact"
167
+ onClick={() => void copyText(text).then((ok) => {
168
+ if (!ok) { toast.error('Copy failed — select the codes and copy them by hand'); return; }
169
+ setCopied(true);
170
+ setTimeout(() => setCopied(false), 1600);
171
+ })}
172
+ >
173
+ <i className={`bi ${copied ? 'bi-check-lg' : 'bi-clipboard'}`} aria-hidden="true" /> {copied ? 'Copied' : 'Copy'}
174
+ </button>
175
+ <button type="button" className="btn btn-compact" onClick={download}>
176
+ <i className="bi bi-download" aria-hidden="true" /> Download .txt
177
+ </button>
178
+ </div>
179
+ <label className="acct-check" htmlFor={savedId}>
180
+ <input id={savedId} type="checkbox" checked={saved} onChange={(e) => setSaved(e.target.checked)} />
181
+ I saved these somewhere safe
182
+ </label>
183
+ <div className="modal-actions">
184
+ <button type="button" className="btn btn-primary" disabled={!saved} onClick={onDone}>Done</button>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ // ── Authenticator app (TOTP) enrolment ────────────────────────────────
191
+
192
+ /**
193
+ * Per-OPEN state for the enrol dialog. It lives in the openTotpEnrolDialog
194
+ * closure, not a useRef: a host that unmounts and remounts the stack (an
195
+ * auth guard around <ModalHost>) remounts the body, and a ref would reset and
196
+ * run setup again. `uid` is who the dialog was opened for — setup REPLACES
197
+ * that user's secret, so it never runs for anyone else.
198
+ */
199
+ interface TotpRun { started: boolean; uid: string | null }
200
+
201
+ export function openTotpEnrolDialog(opts: { replacing: boolean }): Promise<boolean> {
202
+ const lock = newLock();
203
+ const run: TotpRun = { started: false, uid: getAuthSnapshot().uid };
204
+ if (run.uid == null) {
205
+ toast.error(FRESH_AUTH_COPY);
206
+ return Promise.resolve(false);
207
+ }
208
+ return modal.open<boolean | null>((close) => <TotpEnrolDialog replacing={opts.replacing} run={run} lock={lock} close={close} />, { size: 'md', canDismiss: () => !lock.locked })
209
+ .then((v) => v === true);
210
+ }
211
+
212
+ /** False (and the dialog closed with the sign-in copy) once the session is not the one it opened for. */
213
+ function sameTotpUser(run: TotpRun, close: (value: boolean | null) => void): boolean {
214
+ if (getAuthSnapshot().uid === run.uid) return true;
215
+ toast.error(FRESH_AUTH_COPY);
216
+ close(null);
217
+ return false;
218
+ }
219
+
220
+ type TotpStep =
221
+ | { kind: 'loading' }
222
+ | { kind: 'failed'; error: string }
223
+ | { kind: 'scan'; secret: string; uri: string; qr: string | null }
224
+ | { kind: 'codes'; codes: string[] };
225
+
226
+ function TotpEnrolDialog({ replacing, run, lock, close }: { replacing: boolean; run: TotpRun; lock: DismissLock; close: (value: boolean | null) => void }) {
227
+ const qc = useQueryClient();
228
+ const uid = useMe().data?.id ?? null;
229
+ const [step, setStep] = useState<TotpStep>({ kind: 'loading' });
230
+ const [code, setCode] = useState('');
231
+ const [busy, setBusy] = useState(false);
232
+ const [error, setError] = useState('');
233
+ const [showKey, setShowKey] = useState(false);
234
+ const [phase, setPhase] = useState<'scan' | 'code'>('scan');
235
+ const mounted = useRef(false);
236
+ useLock(lock, busy || step.kind === 'codes' || step.kind === 'loading');
237
+
238
+ const refresh = () => Promise.all([
239
+ qc.invalidateQueries({ queryKey: accountKeys.recoveryStatus(uid) }),
240
+ invalidateMe(qc),
241
+ ]);
242
+
243
+ const begin = async () => {
244
+ if (!sameTotpUser(run, close)) return;
245
+ setStep({ kind: 'loading' });
246
+ setPhase('scan');
247
+ try {
248
+ const setup = await startTotpSetup();
249
+ setStep({ kind: 'scan', secret: setup.secret, uri: setup.uri, qr: setup.qrCode });
250
+ } catch (err) {
251
+ setStep({ kind: 'failed', error: accountErrorMessage(err) });
252
+ } finally {
253
+ // Setup disables an active authenticator until confirmed.
254
+ void refresh();
255
+ }
256
+ };
257
+
258
+ useEffect(() => {
259
+ // Once per OPEN (not per mount): setup REPLACES the secret, so neither
260
+ // StrictMode's double effect nor a host remount may run it again.
261
+ if (mounted.current) return; // StrictMode re-run of THIS instance
262
+ mounted.current = true;
263
+ if (run.started) {
264
+ // A remount lost the setup this open already ran; never re-run it unasked.
265
+ setStep({ kind: 'failed', error: 'Setup was interrupted. Try again to get a new code.' });
266
+ return;
267
+ }
268
+ run.started = true;
269
+ void begin();
270
+ // eslint-disable-next-line react-hooks/exhaustive-deps
271
+ }, []);
272
+
273
+ const confirm = async (e: FormEvent) => {
274
+ e.preventDefault();
275
+ if (code.length !== 6) { setError('Enter the 6-digit code from your app.'); return; }
276
+ if (!sameTotpUser(run, close)) return;
277
+ setBusy(true);
278
+ setError('');
279
+ try {
280
+ const codes = await confirmTotp(code);
281
+ setStep({ kind: 'codes', codes });
282
+ void refresh();
283
+ } catch (err) {
284
+ setError(accountErrorMessage(err));
285
+ } finally {
286
+ setBusy(false);
287
+ }
288
+ };
289
+
290
+ if (step.kind === 'codes') {
291
+ return (
292
+ <div className="modal-pad">
293
+ <RecoveryCodesPanel codes={step.codes} step="Authenticator app · step 3 of 3" onDone={() => { toast.success('Authenticator app is on'); close(true); }} />
294
+ </div>
295
+ );
296
+ }
297
+
298
+ const onCodeStep = step.kind === 'scan' && phase === 'code';
299
+ return (
300
+ <form className="modal-pad acct-dialog" onSubmit={confirm}>
301
+ <span className="eyebrow">Authenticator app · step {onCodeStep ? 2 : 1} of 3</span>
302
+ <h2 className="modal-title">{onCodeStep ? 'Enter the code from your app' : 'Scan with your authenticator app'}</h2>
303
+ {replacing && !onCodeStep && (
304
+ <p className="acct-note acct-note-warn">
305
+ <i className="bi bi-info-circle" aria-hidden="true" /> Your current app has stopped working and stays off until you finish here.
306
+ </p>
307
+ )}
308
+ {step.kind === 'loading' && <div className="acct-qr acct-qr-loading" aria-busy="true"><span className="skel skel-block" /></div>}
309
+ {step.kind === 'failed' && (
310
+ <>
311
+ <Alert>{step.error}</Alert>
312
+ <div className="modal-actions">
313
+ <button type="button" className="btn" onClick={() => close(null)}>Close</button>
314
+ <button type="button" className="btn btn-primary" onClick={() => void begin()}>Try again</button>
315
+ </div>
316
+ </>
317
+ )}
318
+ {step.kind === 'scan' && !onCodeStep && (
319
+ <>
320
+ <div className="acct-scan">
321
+ <div className="acct-qr">
322
+ {/* Re-checked at the sink: only a base64 PNG/SVG data URL renders. */}
323
+ {safeQrDataUrl(step.qr)
324
+ ? <img src={safeQrDataUrl(step.qr)!} alt="QR code for your authenticator app" width={168} height={168} />
325
+ : <span className="acct-muted">QR unavailable — enter the key instead.</span>}
326
+ </div>
327
+ <div className="acct-scan-help">
328
+ <p className="acct-muted">Open 1Password, Google Authenticator or a similar app, add an account and scan this code.</p>
329
+ <p className="acct-muted">
330
+ Can&rsquo;t scan?{' '}
331
+ <button type="button" className="acct-link" aria-expanded={showKey} onClick={() => setShowKey((v) => !v)}>
332
+ {showKey ? 'Hide the key' : 'Enter a key instead'}
333
+ </button>
334
+ </p>
335
+ {showKey && <code className="acct-secret" aria-label="Setup key">{step.secret.replace(/(.{4})/g, '$1 ').trim()}</code>}
336
+ </div>
337
+ </div>
338
+ <div className="modal-actions">
339
+ <button type="button" className="btn" onClick={() => close(null)}>Cancel</button>
340
+ <button type="button" className="btn btn-primary" onClick={() => setPhase('code')}>Next</button>
341
+ </div>
342
+ </>
343
+ )}
344
+ {onCodeStep && (
345
+ <>
346
+ <p className="acct-muted">Type the 6-digit code your app shows for this account.</p>
347
+ <Alert>{error}</Alert>
348
+ <CodeInput value={code} onChange={setCode} label="Code from the app" />
349
+ <div className="modal-actions">
350
+ <button type="button" className="btn acct-push-left" disabled={busy} onClick={() => { setError(''); setPhase('scan'); }}>Back</button>
351
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
352
+ <button type="submit" className="btn btn-primary" disabled={busy || code.length !== 6}>
353
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Turn on
354
+ </button>
355
+ </div>
356
+ </>
357
+ )}
358
+ </form>
359
+ );
360
+ }
361
+
362
+ // ── Recovery codes: view (masked) + regenerate ────────────────────────
363
+
364
+ export function openRecoveryCodesView(): Promise<unknown> {
365
+ return modal.open((close) => <RecoveryCodesView close={() => close(null)} />, { size: 'sm' });
366
+ }
367
+
368
+ function RecoveryCodesView({ close }: { close: () => void }) {
369
+ // Masked hints live in this dialog only — the cached status is just a count.
370
+ const [state, setState] = useState<{ remaining: number; codes: string[] } | null>(null);
371
+ const [error, setError] = useState('');
372
+ useEffect(() => {
373
+ let live = true;
374
+ getMaskedRecoveryCodes().then((value) => { if (live) setState(value); }, (err) => { if (live) setError(accountErrorMessage(err)); });
375
+ return () => { live = false; };
376
+ }, []);
377
+ return (
378
+ <div className="modal-pad acct-dialog">
379
+ <h2 className="modal-title">Recovery codes</h2>
380
+ <Alert>{error}</Alert>
381
+ {!state && !error && <span className="skel skel-block" aria-busy="true" />}
382
+ {state && (
383
+ <>
384
+ <p className="acct-muted">
385
+ <b>{state.remaining} of 8</b> left. Only the first four characters are shown — the full codes were shown once, when they were made.
386
+ </p>
387
+ <ul className="acct-codes acct-codes-masked" aria-label="Remaining recovery codes">
388
+ {state.codes.map((code, i) => <li key={`${code}-${i}`}><code>{code}</code></li>)}
389
+ </ul>
390
+ </>
391
+ )}
392
+ <div className="modal-actions">
393
+ <button type="button" className="btn" onClick={close}>Close</button>
394
+ <button type="button" className="btn btn-primary" onClick={() => { close(); void openRegenerateRecoveryCodes(); }}>Regenerate…</button>
395
+ </div>
396
+ </div>
397
+ );
398
+ }
399
+
400
+ export function openRegenerateRecoveryCodes(): Promise<boolean> {
401
+ const lock = newLock();
402
+ return modal.open<boolean | null>((close) => <RegenerateRecoveryCodes lock={lock} close={close} />, { size: 'md', canDismiss: () => !lock.locked })
403
+ .then((v) => v === true);
404
+ }
405
+
406
+ function RegenerateRecoveryCodes({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
407
+ const qc = useQueryClient();
408
+ const uid = useMe().data?.id ?? null;
409
+ const [code, setCode] = useState('');
410
+ const [codes, setCodes] = useState<string[] | null>(null);
411
+ const [busy, setBusy] = useState(false);
412
+ const [error, setError] = useState('');
413
+ useLock(lock, busy || codes != null);
414
+
415
+ const submit = async (e: FormEvent) => {
416
+ e.preventDefault();
417
+ setBusy(true);
418
+ setError('');
419
+ try {
420
+ setCodes(await regenerateRecoveryCodes(code));
421
+ void qc.invalidateQueries({ queryKey: accountKeys.recoveryStatus(uid) });
422
+ } catch (err) {
423
+ setError(accountErrorMessage(err));
424
+ } finally {
425
+ setBusy(false);
426
+ }
427
+ };
428
+
429
+ if (codes) {
430
+ return <div className="modal-pad"><RecoveryCodesPanel codes={codes} onDone={() => { toast.success('New recovery codes saved'); close(true); }} /></div>;
431
+ }
432
+ return (
433
+ <form className="modal-pad acct-dialog" onSubmit={submit}>
434
+ <h2 className="modal-title">Regenerate recovery codes</h2>
435
+ <p className="acct-muted">A fresh set of 8 replaces every code you have now. Enter a code from your authenticator app to continue.</p>
436
+ <Alert>{error}</Alert>
437
+ <CodeInput value={code} onChange={setCode} label="Code from the app" />
438
+ <div className="modal-actions">
439
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
440
+ <button type="submit" className="btn btn-primary" disabled={busy || code.length !== 6}>
441
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Regenerate
442
+ </button>
443
+ </div>
444
+ </form>
445
+ );
446
+ }
447
+
448
+ // ── Password ──────────────────────────────────────────────────────────
449
+
450
+ export function openChangePasswordDialog(): Promise<boolean> {
451
+ const lock = newLock();
452
+ return modal.open<boolean | null>((close) => <ChangePasswordDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
453
+ .then((v) => v === true);
454
+ }
455
+
456
+ function ChangePasswordDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
457
+ const [current, setCurrent] = useState('');
458
+ const [next, setNext] = useState('');
459
+ const [confirm, setConfirm] = useState('');
460
+ const [busy, setBusy] = useState(false);
461
+ const [error, setError] = useState('');
462
+ const ids = { current: useId(), next: useId(), confirm: useId() };
463
+ useLock(lock, busy);
464
+
465
+ const submit = async (e: FormEvent) => {
466
+ e.preventDefault();
467
+ if (!current) return setError('Enter your current password.');
468
+ if (!next) return setError('Enter a new password.');
469
+ if (next !== confirm) return setError('The new passwords do not match.');
470
+ setBusy(true);
471
+ setError('');
472
+ try {
473
+ await changePassword(current, next);
474
+ toast.success('Password changed');
475
+ // A password change does not rotate auth_key: other sessions live on.
476
+ toast.action('Other devices stay signed in.', 'Sign out everywhere else', () => {
477
+ revokeOtherSessions().then(
478
+ () => toast.success('Signed out of every other session'),
479
+ (err) => toast.error(accountErrorMessage(err)),
480
+ );
481
+ });
482
+ close(true);
483
+ } catch (err) {
484
+ setError(accountErrorMessage(err));
485
+ setBusy(false);
486
+ }
487
+ };
488
+
489
+ return (
490
+ <form className="modal-pad acct-dialog" onSubmit={submit}>
491
+ <h2 className="modal-title">Change password</h2>
492
+ <Alert>{error}</Alert>
493
+ <label className="field" htmlFor={ids.current}>
494
+ <span className="field-label">Current password</span>
495
+ <input id={ids.current} className="input" type="password" autoComplete="current-password" value={current} autoFocus onChange={(e) => setCurrent(e.target.value)} />
496
+ </label>
497
+ <label className="field" htmlFor={ids.next}>
498
+ <span className="field-label">New password</span>
499
+ <input id={ids.next} className="input" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} />
500
+ </label>
501
+ <PasswordStrengthMeter password={next} />
502
+ <label className="field" htmlFor={ids.confirm}>
503
+ <span className="field-label">Confirm new password</span>
504
+ <input id={ids.confirm} className="input" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} />
505
+ </label>
506
+ <div className="modal-actions">
507
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
508
+ <button type="submit" className="btn btn-primary" disabled={busy}>
509
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Change password
510
+ </button>
511
+ </div>
512
+ </form>
513
+ );
514
+ }
515
+
516
+ // ── Email change ──────────────────────────────────────────────────────
517
+
518
+ export function openEmailChangeDialog(): Promise<boolean> {
519
+ const lock = newLock();
520
+ return modal.open<boolean | null>((close) => <EmailChangeDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
521
+ .then((v) => v === true);
522
+ }
523
+
524
+ function EmailChangeDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
525
+ const qc = useQueryClient();
526
+ const me = useMe().data;
527
+ const [email, setEmail] = useState('');
528
+ const [sentTo, setSentTo] = useState<string | null>(null);
529
+ const [notice, setNotice] = useState('');
530
+ const [code, setCode] = useState('');
531
+ const [busy, setBusy] = useState(false);
532
+ const [error, setError] = useState('');
533
+ const emailId = useId();
534
+ useLock(lock, busy);
535
+
536
+ const request = async (e?: FormEvent) => {
537
+ e?.preventDefault();
538
+ const target = email.trim();
539
+ if (!target) return setError('Enter the new email address.');
540
+ setBusy(true);
541
+ setError('');
542
+ try {
543
+ setNotice(await requestEmailChange(target));
544
+ setSentTo(target);
545
+ } catch (err) {
546
+ setError(accountErrorMessage(err));
547
+ } finally {
548
+ setBusy(false);
549
+ }
550
+ };
551
+
552
+ const confirm = async (e: FormEvent) => {
553
+ e.preventDefault();
554
+ setBusy(true);
555
+ setError('');
556
+ try {
557
+ await confirmEmailChange(code);
558
+ await invalidateMe(qc);
559
+ toast.success('Email changed');
560
+ close(true);
561
+ } catch (err) {
562
+ setError(accountErrorMessage(err));
563
+ setBusy(false);
564
+ }
565
+ };
566
+
567
+ if (sentTo) {
568
+ return (
569
+ <form className="modal-pad acct-dialog" onSubmit={confirm}>
570
+ <span className="eyebrow">Change email · step 2 of 2</span>
571
+ <h2 className="modal-title">Check your inbox</h2>
572
+ <p className="acct-muted">{notice} Enter the 6-digit code sent to <b>{sentTo}</b>.</p>
573
+ <Alert>{error}</Alert>
574
+ <CodeInput value={code} onChange={setCode} />
575
+ <p className="acct-note">You stay signed in here. Every other session is signed out when the change completes.</p>
576
+ <div className="modal-actions">
577
+ <button type="button" className="btn acct-push-left" disabled={busy} onClick={() => void request()}>Resend code</button>
578
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
579
+ <button type="submit" className="btn btn-primary" disabled={busy || code.length !== 6}>
580
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Change email
581
+ </button>
582
+ </div>
583
+ </form>
584
+ );
585
+ }
586
+ return (
587
+ <form className="modal-pad acct-dialog" onSubmit={request}>
588
+ <span className="eyebrow">Change email · step 1 of 2</span>
589
+ <h2 className="modal-title">Change your email</h2>
590
+ {me?.email && <p className="acct-muted">Currently <b>{String(me.email)}</b>. We&rsquo;ll send a code to the new address; nothing changes until you enter it.</p>}
591
+ <Alert>{error}</Alert>
592
+ <label className="field" htmlFor={emailId}>
593
+ <span className="field-label">New email</span>
594
+ <input id={emailId} className="input" type="email" autoComplete="email" value={email} autoFocus onChange={(e) => setEmail(e.target.value)} />
595
+ </label>
596
+ <div className="modal-actions">
597
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
598
+ <button type="submit" className="btn btn-primary" disabled={busy || !email.trim()}>
599
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Send code
600
+ </button>
601
+ </div>
602
+ </form>
603
+ );
604
+ }
605
+
606
+ // ── Phone add / change ────────────────────────────────────────────────
607
+
608
+ export function openPhoneChangeDialog(): Promise<boolean> {
609
+ const lock = newLock();
610
+ return modal.open<boolean | null>((close) => <PhoneChangeDialog lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
611
+ .then((v) => v === true);
612
+ }
613
+
614
+ function PhoneChangeDialog({ lock, close }: { lock: DismissLock; close: (value: boolean | null) => void }) {
615
+ const qc = useQueryClient();
616
+ const me = useMe().data;
617
+ const current = typeof me?.phone_number === 'string' && me.phone_number ? me.phone_number : null;
618
+ const [phone, setPhone] = useState('');
619
+ // The session_token is a one-time pairing value: dialog state only.
620
+ const [ticket, setTicket] = useState<{ token: string; to: string; message: string } | null>(null);
621
+ const [code, setCode] = useState('');
622
+ const [busy, setBusy] = useState(false);
623
+ const [error, setError] = useState('');
624
+ const phoneId = useId();
625
+ useLock(lock, busy);
626
+ const verb = current ? 'Change' : 'Add';
627
+
628
+ const request = async (e?: FormEvent) => {
629
+ e?.preventDefault();
630
+ const target = phone.trim();
631
+ if (!target) return setError('Enter a phone number.');
632
+ setBusy(true);
633
+ setError('');
634
+ try {
635
+ const result = await requestPhoneChange(target);
636
+ setTicket({ token: result.sessionToken, to: target, message: result.message });
637
+ setCode('');
638
+ } catch (err) {
639
+ setError(accountErrorMessage(err));
640
+ } finally {
641
+ setBusy(false);
642
+ }
643
+ };
644
+
645
+ const confirm = async (e: FormEvent) => {
646
+ e.preventDefault();
647
+ if (!ticket) return;
648
+ setBusy(true);
649
+ setError('');
650
+ try {
651
+ await confirmPhoneChange(ticket.token, code);
652
+ await invalidateMe(qc);
653
+ toast.success(current ? 'Phone number changed' : 'Phone number added');
654
+ close(true);
655
+ } catch (err) {
656
+ setError(accountErrorMessage(err));
657
+ setBusy(false);
658
+ }
659
+ };
660
+
661
+ if (ticket) {
662
+ return (
663
+ <form className="modal-pad acct-dialog" onSubmit={confirm}>
664
+ <span className="eyebrow">{verb} phone · step 2 of 2</span>
665
+ <h2 className="modal-title">Enter the code</h2>
666
+ <p className="acct-muted">{ticket.message} We texted <b>{ticket.to}</b>.</p>
667
+ <Alert>{error}</Alert>
668
+ <CodeInput value={code} onChange={setCode} />
669
+ <div className="modal-actions">
670
+ <button type="button" className="btn acct-push-left" disabled={busy} onClick={() => void request()}>Resend code</button>
671
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
672
+ <button type="submit" className="btn btn-primary" disabled={busy || code.length !== 6}>
673
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} {verb} phone
674
+ </button>
675
+ </div>
676
+ </form>
677
+ );
678
+ }
679
+ return (
680
+ <form className="modal-pad acct-dialog" onSubmit={request}>
681
+ <span className="eyebrow">{verb} phone · step 1 of 2</span>
682
+ <h2 className="modal-title">{current ? 'Change your phone number' : 'Add a phone number'}</h2>
683
+ <p className="acct-muted">
684
+ {current ? <>Currently <b>{current}</b>. </> : null}
685
+ We&rsquo;ll text a code to the new number; nothing changes until you enter it.
686
+ </p>
687
+ <Alert>{error}</Alert>
688
+ <label className="field" htmlFor={phoneId}>
689
+ <span className="field-label">Phone number</span>
690
+ <input id={phoneId} className="input" type="tel" autoComplete="tel" placeholder="+1 555 555 0100" value={phone} autoFocus onChange={(e) => setPhone(e.target.value)} />
691
+ </label>
692
+ <div className="modal-actions">
693
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
694
+ <button type="submit" className="btn btn-primary" disabled={busy || !phone.trim()}>
695
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Send code
696
+ </button>
697
+ </div>
698
+ </form>
699
+ );
700
+ }
701
+
702
+ // ── Verify email / phone ──────────────────────────────────────────────
703
+
704
+ export function openVerifyDialog(channel: 'email' | 'phone'): Promise<boolean> {
705
+ const lock = newLock();
706
+ return modal.open<boolean | null>((close) => <VerifyDialog channel={channel} lock={lock} close={close} />, { size: 'sm', canDismiss: () => !lock.locked })
707
+ .then((v) => v === true);
708
+ }
709
+
710
+ function VerifyDialog({ channel, lock, close }: { channel: 'email' | 'phone'; lock: DismissLock; close: (value: boolean | null) => void }) {
711
+ const qc = useQueryClient();
712
+ const me = useMe().data;
713
+ const target = String((channel === 'email' ? me?.email : me?.phone_number) ?? '');
714
+ const [sent, setSent] = useState('');
715
+ const [code, setCode] = useState('');
716
+ const [busy, setBusy] = useState(false);
717
+ const [error, setError] = useState('');
718
+ useLock(lock, busy);
719
+ const noun = channel === 'email' ? 'email' : 'phone number';
720
+
721
+ const send = async () => {
722
+ setBusy(true);
723
+ setError('');
724
+ try {
725
+ setSent(await sendVerificationCode(channel));
726
+ } catch (err) {
727
+ setError(accountErrorMessage(err));
728
+ } finally {
729
+ setBusy(false);
730
+ }
731
+ };
732
+
733
+ const confirm = async (e: FormEvent) => {
734
+ e.preventDefault();
735
+ setBusy(true);
736
+ setError('');
737
+ try {
738
+ const message = await confirmVerificationCode(channel, code);
739
+ await invalidateMe(qc);
740
+ toast.success(message);
741
+ close(true);
742
+ } catch (err) {
743
+ setError(accountErrorMessage(err));
744
+ setBusy(false);
745
+ }
746
+ };
747
+
748
+ return (
749
+ <form className="modal-pad acct-dialog" onSubmit={confirm}>
750
+ <h2 className="modal-title">Verify your {noun}</h2>
751
+ <p className="acct-muted">
752
+ {sent ? <>{sent}. Enter the 6-digit code sent to <b>{target}</b>.</> : <>We&rsquo;ll send a 6-digit code to <b>{target}</b>.</>}
753
+ </p>
754
+ <Alert>{error}</Alert>
755
+ {sent && <CodeInput value={code} onChange={setCode} />}
756
+ <div className="modal-actions">
757
+ {sent && <button type="button" className="btn acct-push-left" disabled={busy} onClick={() => void send()}>Resend code</button>}
758
+ <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
759
+ {sent
760
+ ? <button type="submit" className="btn btn-primary" disabled={busy || code.length !== 6}>Verify</button>
761
+ : <button type="button" className="btn btn-primary" disabled={busy || !target} onClick={() => void send()}>
762
+ {busy && <i className="bi bi-arrow-repeat spin" aria-hidden="true" />} Send code
763
+ </button>}
764
+ </div>
765
+ </form>
766
+ );
767
+ }
768
+
769
+ // ── Avatar ────────────────────────────────────────────────────────────
770
+
771
+ export function openAvatarDialog(): Promise<unknown> {
772
+ const lock = newLock();
773
+ return modal.open((close) => <AvatarDialog lock={lock} close={() => close(null)} />, { size: 'md', canDismiss: () => !lock.locked });
774
+ }
775
+
776
+ /** Personal-scope upload → `POST /api/user/me {avatar: <File id> | null}` (MeSaveModel). */
777
+ function AvatarDialog({ lock, close }: { lock: DismissLock; close: () => void }) {
778
+ const me = useMe().data as Me | undefined;
779
+ const save = MeSaveModel.useSave();
780
+ const [busy, setBusy] = useState(false);
781
+ const [uploadPending, setUploadPending] = useState(false);
782
+ const [ownerResult, setOwnerResult] = useState<FileFieldOwnerResult>();
783
+ const generation = useRef(0);
784
+ useLock(lock, busy || uploadPending);
785
+ const relationId = me?.avatar?.id ?? null;
786
+
787
+ const attach = async (avatar: number | null) => {
788
+ setBusy(true);
789
+ try {
790
+ const saved = await save.mutateAsync({ id: me?.id ?? null, changes: { avatar } });
791
+ setOwnerResult({ generation: ++generation.current, status: 'success', requestedValue: avatar, authoritativeValue: saved.avatar ?? null });
792
+ if ((saved.avatar?.id ?? null) !== avatar) throw new Error('The server did not confirm the new photo');
793
+ toast.success(avatar == null ? 'Photo removed' : 'Photo updated');
794
+ } catch (err) {
795
+ setOwnerResult({ generation: ++generation.current, status: 'failed', requestedValue: avatar });
796
+ toast.error(accountErrorMessage(err));
797
+ } finally {
798
+ setBusy(false);
799
+ }
800
+ };
801
+
802
+ return (
803
+ <div className="modal-pad acct-dialog">
804
+ <h2 className="modal-title">Profile photo</h2>
805
+ <p className="acct-muted">Choose an image, crop it, and it becomes your photo everywhere you appear.</p>
806
+ <ImageField
807
+ value={relationId}
808
+ onChange={(value) => { void attach(value); }}
809
+ disabled={busy}
810
+ accept="image/*"
811
+ edit={{ title: 'Crop photo', startMode: 'crop', crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } } }}
812
+ requireEdit
813
+ ownerResult={ownerResult}
814
+ onPendingChange={setUploadPending}
815
+ onOrphan={(fileId) => toast.warning(`File #${fileId} was uploaded but is not your photo.`)}
816
+ />
817
+ <div className="modal-actions">
818
+ <button type="button" className="btn" disabled={busy || uploadPending} onClick={close}>Done</button>
819
+ </div>
820
+ </div>
821
+ );
822
+ }