create-astram-app 0.1.3 → 0.3.0

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.
Files changed (88) hide show
  1. package/dist/add-frontend.js +107 -0
  2. package/dist/add-frontend.js.map +1 -0
  3. package/dist/index.js +122 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -2
  6. package/template/frontend/FRONTEND.md +92 -0
  7. package/template/frontend/_env.example +6 -0
  8. package/template/frontend/_gitignore +9 -0
  9. package/template/frontend/eslint.config.js +71 -0
  10. package/template/frontend/index.html +13 -0
  11. package/template/frontend/package.json +52 -0
  12. package/template/frontend/public/astram-logo-full.png +0 -0
  13. package/template/frontend/public/astram-logo-white.png +0 -0
  14. package/template/frontend/public/favicon.png +0 -0
  15. package/template/frontend/src/App.jsx +191 -0
  16. package/template/frontend/src/auth/AuthContext.jsx +343 -0
  17. package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
  18. package/template/frontend/src/components/EntityFormModal.jsx +154 -0
  19. package/template/frontend/src/components/EntityList.jsx +480 -0
  20. package/template/frontend/src/components/PageHeader.jsx +54 -0
  21. package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
  22. package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
  23. package/template/frontend/src/components/ui/alert.tsx +83 -0
  24. package/template/frontend/src/components/ui/avatar.tsx +51 -0
  25. package/template/frontend/src/components/ui/badge.tsx +56 -0
  26. package/template/frontend/src/components/ui/button.tsx +67 -0
  27. package/template/frontend/src/components/ui/card.tsx +92 -0
  28. package/template/frontend/src/components/ui/checkbox.tsx +30 -0
  29. package/template/frontend/src/components/ui/date-field.tsx +47 -0
  30. package/template/frontend/src/components/ui/dialog.tsx +214 -0
  31. package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
  32. package/template/frontend/src/components/ui/input-otp.tsx +81 -0
  33. package/template/frontend/src/components/ui/input.tsx +21 -0
  34. package/template/frontend/src/components/ui/label.tsx +46 -0
  35. package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
  36. package/template/frontend/src/components/ui/select.tsx +199 -0
  37. package/template/frontend/src/components/ui/separator.tsx +26 -0
  38. package/template/frontend/src/components/ui/sheet.tsx +139 -0
  39. package/template/frontend/src/components/ui/skeleton.tsx +13 -0
  40. package/template/frontend/src/components/ui/sonner.tsx +23 -0
  41. package/template/frontend/src/components/ui/table.tsx +181 -0
  42. package/template/frontend/src/components/ui/tooltip.tsx +59 -0
  43. package/template/frontend/src/config/branding.js +80 -0
  44. package/template/frontend/src/hooks/useMobile.tsx +21 -0
  45. package/template/frontend/src/index.css +265 -0
  46. package/template/frontend/src/lib/utils.js +7 -0
  47. package/template/frontend/src/main.jsx +27 -0
  48. package/template/frontend/src/pages/Dashboard.jsx +127 -0
  49. package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
  50. package/template/frontend/src/pages/Login.jsx +393 -0
  51. package/template/frontend/src/pages/ResetPassword.jsx +159 -0
  52. package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
  53. package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
  54. package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
  55. package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
  56. package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
  57. package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
  58. package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
  59. package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
  60. package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
  61. package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
  62. package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
  63. package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
  64. package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
  65. package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
  66. package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
  67. package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
  68. package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
  69. package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
  70. package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
  71. package/template/frontend/src/pages/users/UserForm.jsx +167 -0
  72. package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
  73. package/template/frontend/src/platform/httpClient.js +125 -0
  74. package/template/frontend/src/platform/returnPathStore.js +43 -0
  75. package/template/frontend/src/platform/sessionStore.js +29 -0
  76. package/template/frontend/src/platform/shieldTransport.js +172 -0
  77. package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
  78. package/template/frontend/src/shell/AppShell.jsx +237 -0
  79. package/template/frontend/src/shell/Sidebar.jsx +257 -0
  80. package/template/frontend/src/shell/nav.js +103 -0
  81. package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
  82. package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
  83. package/template/frontend/tsconfig.json +22 -0
  84. package/template/frontend/vite.config.js +30 -0
  85. package/template/server/_env.example +27 -15
  86. package/template/server/package.json +7 -4
  87. package/template/server/src/app.module.ts +28 -2
  88. package/template/server/src/health/demo.controller.ts +23 -12
@@ -0,0 +1,833 @@
1
+ /**
2
+ * TwoFactorPage — a multi-device authenticator manager, in astram3's
3
+ * two-pane account-card language. Rendered inside <AccountLayout/>'s <Outlet/>.
4
+ *
5
+ * On mount it lists the user's CONFIRMED authenticator devices via the shared
6
+ * accountService. From there:
7
+ * - empty state → "Two-factor is off" + an "Add device" button.
8
+ * - device list → one row per device (name · added · last used · rename · remove),
9
+ * plus "Add device" and "Regenerate recovery codes".
10
+ *
11
+ * The ADD-DEVICE sub-flow reuses the classic enroll UX: name → begin → a SCANNABLE QR
12
+ * (otpauth:// rendered to a data-URL, kept dark-on-white so it scans in dark mode too)
13
+ * + a manual base32 key + a 6-digit code → confirm. On the FIRST device the server
14
+ * returns recovery codes ONCE — we show them in a prominent "save these" panel.
15
+ *
16
+ * RENAME is inline (edit-in-place on the row). REMOVE and REGENERATE are SENSITIVE ops
17
+ * gated behind the themed <ConfirmDialog/>: each gathers a proof code INSIDE the dialog
18
+ * and fires its API ONLY from the dialog's confirm handler (never on the initial click).
19
+ *
20
+ * Device ids are machine-only (used as keys/params) — never rendered to the user. Only
21
+ * names + formatted dates + relative "last used" reach the DOM.
22
+ */
23
+ import { useCallback, useEffect, useRef, useState } from 'react';
24
+ import QRCode from 'qrcode';
25
+ import { toast } from 'sonner';
26
+ import {
27
+ AlertTriangle,
28
+ Check,
29
+ Copy,
30
+ KeyRound,
31
+ Loader2,
32
+ Pencil,
33
+ Plus,
34
+ ShieldCheck,
35
+ Smartphone,
36
+ Trash2,
37
+ X,
38
+ } from 'lucide-react';
39
+ import { useAuth } from '@/auth/AuthContext';
40
+ import { Button } from '@/components/ui/button';
41
+ import { Input } from '@/components/ui/input';
42
+ import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
43
+ import { Label } from '@/components/ui/label';
44
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
45
+ import ConfirmDialog from '@/components/ConfirmDialog';
46
+
47
+ /** Absolute date for "Added" — e.g. "13 Jul 2026". */
48
+ function formatAdded(ms) {
49
+ if (!ms) return '—';
50
+ try {
51
+ return new Date(ms).toLocaleDateString(undefined, {
52
+ day: 'numeric',
53
+ month: 'short',
54
+ year: 'numeric',
55
+ });
56
+ } catch {
57
+ return '—';
58
+ }
59
+ }
60
+
61
+ /** Relative "last used" — "Never", "just now", "2 hours ago", or a date if far back. */
62
+ function formatLastUsed(ms) {
63
+ if (!ms) return 'Never';
64
+ const diff = Date.now() - ms;
65
+ if (diff < 0) return 'just now';
66
+ const min = Math.floor(diff / 60000);
67
+ if (min < 1) return 'just now';
68
+ if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
69
+ const hr = Math.floor(min / 60);
70
+ if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
71
+ const days = Math.floor(hr / 24);
72
+ if (days < 30) return `${days} day${days === 1 ? '' : 's'} ago`;
73
+ return formatAdded(ms);
74
+ }
75
+
76
+ /** The static "how to" guide shown in the right pane. */
77
+ function HowToGuide() {
78
+ return (
79
+ <div className="md:border-l md:pl-6">
80
+ <p className="text-sm font-medium text-foreground">How it works</p>
81
+ <ol className="mt-3 space-y-3 text-sm text-muted-foreground">
82
+ <li className="flex gap-2.5">
83
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
84
+ 1
85
+ </span>
86
+ <span>Install an authenticator app — Google Authenticator, Authy, or 1Password.</span>
87
+ </li>
88
+ <li className="flex gap-2.5">
89
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
90
+ 2
91
+ </span>
92
+ <span>
93
+ Add a device here, then scan the QR code — or choose &ldquo;enter a setup key&rdquo; and
94
+ paste the key manually.
95
+ </span>
96
+ </li>
97
+ <li className="flex gap-2.5">
98
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
99
+ 3
100
+ </span>
101
+ <span>Enter the 6-digit code the app generates to confirm and enable the device.</span>
102
+ </li>
103
+ <li className="flex gap-2.5">
104
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-[11px] font-semibold text-primary">
105
+ 4
106
+ </span>
107
+ <span>
108
+ Add more devices for backup, and save your recovery codes — they let you sign in if you
109
+ lose every device.
110
+ </span>
111
+ </li>
112
+ </ol>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ /** The prominent one-time recovery-codes panel (first enroll + regenerate). */
118
+ function RecoveryCodesPanel({ codes, onCopy, onDone, doneLabel = 'I’ve saved them' }) {
119
+ return (
120
+ <div className="space-y-3">
121
+ <div className="flex items-start gap-2 rounded-lg border border-[var(--warn)]/40 bg-[var(--warn)]/10 px-3 py-2 text-sm">
122
+ <AlertTriangle className="mt-0.5 size-4 shrink-0 text-[var(--warn)]" />
123
+ <div>
124
+ <p className="font-medium text-foreground">Save these recovery codes now</p>
125
+ <p className="text-muted-foreground">
126
+ They are shown ONCE and will not be shown again. Each code works once if you lose access
127
+ to every authenticator device. Store them somewhere safe.
128
+ </p>
129
+ </div>
130
+ </div>
131
+ <div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/40 p-3">
132
+ {codes.map((c) => (
133
+ <code
134
+ key={c}
135
+ className="select-all rounded bg-background px-2 py-1 text-center font-mono text-sm"
136
+ >
137
+ {c}
138
+ </code>
139
+ ))}
140
+ </div>
141
+ <div className="flex items-center gap-2">
142
+ <Button type="button" variant="outline" onClick={onCopy}>
143
+ <Copy className="size-4" />
144
+ Copy codes
145
+ </Button>
146
+ <Button type="button" onClick={onDone}>
147
+ {doneLabel}
148
+ </Button>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ /**
155
+ * A single device row — displays name/added/last-used, with inline rename + remove.
156
+ * `reenrollMode` hides Rename/Remove: those routes (PATCH/DELETE /account/mfa/devices)
157
+ * are NOT on the server's re-enroll allowlist, so offering them on the forced re-enroll
158
+ * screen would only yield a confusing 403 — the display still shows the device.
159
+ */
160
+ function DeviceRow({ device, onRename, onRemove, disabled, reenrollMode = false }) {
161
+ const [editing, setEditing] = useState(false);
162
+ const [draft, setDraft] = useState(device.name);
163
+ const [savingRename, setSavingRename] = useState(false);
164
+
165
+ const startEdit = () => {
166
+ setDraft(device.name);
167
+ setEditing(true);
168
+ };
169
+
170
+ const submitRename = async () => {
171
+ const next = draft.trim();
172
+ if (!next) {
173
+ toast.error('Please enter a device name.');
174
+ return;
175
+ }
176
+ if (next === device.name) {
177
+ setEditing(false);
178
+ return;
179
+ }
180
+ setSavingRename(true);
181
+ const ok = await onRename(device.id, next);
182
+ setSavingRename(false);
183
+ if (ok) setEditing(false);
184
+ };
185
+
186
+ return (
187
+ <div className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
188
+ <div className="flex min-w-0 items-start gap-3">
189
+ <span className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
190
+ <Smartphone className="size-4" />
191
+ </span>
192
+ <div className="min-w-0">
193
+ {editing ? (
194
+ <div className="flex items-center gap-2">
195
+ <Input
196
+ value={draft}
197
+ autoFocus
198
+ maxLength={64}
199
+ className="h-8 w-44"
200
+ disabled={savingRename}
201
+ onChange={(e) => setDraft(e.target.value)}
202
+ onKeyDown={(e) => {
203
+ if (e.key === 'Enter') {
204
+ e.preventDefault();
205
+ submitRename();
206
+ }
207
+ if (e.key === 'Escape') setEditing(false);
208
+ }}
209
+ />
210
+ <Button
211
+ type="button"
212
+ size="icon"
213
+ className="size-8"
214
+ onClick={submitRename}
215
+ disabled={savingRename}
216
+ aria-label="Save name"
217
+ >
218
+ {savingRename ? (
219
+ <Loader2 className="size-4 animate-spin" />
220
+ ) : (
221
+ <Check className="size-4" />
222
+ )}
223
+ </Button>
224
+ <Button
225
+ type="button"
226
+ size="icon"
227
+ variant="ghost"
228
+ className="size-8"
229
+ onClick={() => setEditing(false)}
230
+ disabled={savingRename}
231
+ aria-label="Cancel rename"
232
+ >
233
+ <X className="size-4" />
234
+ </Button>
235
+ </div>
236
+ ) : (
237
+ <p className="truncate font-medium text-foreground">{device.name}</p>
238
+ )}
239
+ <p className="mt-0.5 text-xs text-muted-foreground">
240
+ Added {formatAdded(device.createdAtMs)} · Last used {formatLastUsed(device.lastUsedAtMs)}
241
+ </p>
242
+ </div>
243
+ </div>
244
+
245
+ {!editing && !reenrollMode && (
246
+ <div className="flex items-center gap-1 self-end sm:self-auto">
247
+ <Button
248
+ type="button"
249
+ variant="ghost"
250
+ size="sm"
251
+ onClick={startEdit}
252
+ disabled={disabled}
253
+ >
254
+ <Pencil className="size-4" />
255
+ Rename
256
+ </Button>
257
+ <Button
258
+ type="button"
259
+ variant="ghost"
260
+ size="sm"
261
+ className="text-destructive hover:bg-destructive/10 hover:text-destructive"
262
+ onClick={() => onRemove(device)}
263
+ disabled={disabled}
264
+ >
265
+ <Trash2 className="size-4" />
266
+ Remove
267
+ </Button>
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /**
275
+ * @param {object} props
276
+ * @param {(() => void)} [props.onDeviceConfirmed] Called after a device confirm
277
+ * succeeds. Undefined in the normal account context; the forced-re-enroll screen
278
+ * passes it to clear the re-enroll gate + leave the screen once a device is added.
279
+ * @param {boolean} [props.reenrollMode] When true (the forced-re-enroll screen),
280
+ * hides Rename/Remove on existing devices — those routes aren't on the server's
281
+ * re-enroll allowlist, so offering them would only 403. Add-device + regenerate
282
+ * (both allowlisted) stay usable. Normal /account/2fa omits this → actions shown.
283
+ */
284
+ export default function TwoFactorPage({ onDeviceConfirmed, reenrollMode = false } = {}) {
285
+ const { tokens, account } = useAuth();
286
+ const token = tokens?.accessToken;
287
+
288
+ // List state.
289
+ const [loading, setLoading] = useState(true);
290
+ const [loadError, setLoadError] = useState('');
291
+ const [unavailable, setUnavailable] = useState(false); // seam-absent (501)
292
+ const [devices, setDevices] = useState([]);
293
+
294
+ // Add-device sub-flow: 'idle' | 'naming' | 'enrolling'.
295
+ const [addPhase, setAddPhase] = useState('idle');
296
+ const [newName, setNewName] = useState('');
297
+ const [deviceId, setDeviceId] = useState('');
298
+ const [secret, setSecret] = useState('');
299
+ const [otpauthUri, setOtpauthUri] = useState('');
300
+ const [qrDataUrl, setQrDataUrl] = useState('');
301
+ const [code, setCode] = useState('');
302
+ const [addError, setAddError] = useState('');
303
+ const [addBusy, setAddBusy] = useState(false);
304
+ // The boxed-OTP input (input-otp forwards its ref to the real <input>). Focused when the
305
+ // enroll step opens so the user can type the code straight away without clicking a box.
306
+ const codeInputRef = useRef(null);
307
+
308
+ // One-time recovery codes panel (first enroll OR regenerate).
309
+ const [recoveryCodes, setRecoveryCodes] = useState([]);
310
+ const [showRecovery, setShowRecovery] = useState(false);
311
+
312
+ // Remove ConfirmDialog.
313
+ const [removeTarget, setRemoveTarget] = useState(null);
314
+ const [removeCode, setRemoveCode] = useState('');
315
+ const [removeError, setRemoveError] = useState('');
316
+ const [removeBusy, setRemoveBusy] = useState(false);
317
+
318
+ // Regenerate ConfirmDialog.
319
+ const [regenOpen, setRegenOpen] = useState(false);
320
+ const [regenCode, setRegenCode] = useState('');
321
+ const [regenError, setRegenError] = useState('');
322
+ const [regenBusy, setRegenBusy] = useState(false);
323
+
324
+ const refresh = useCallback(async () => {
325
+ if (!token) return;
326
+ const r = await account.listMfaDevices(token);
327
+ if (r.ok) {
328
+ setDevices(r.devices || []);
329
+ setUnavailable(false);
330
+ setLoadError('');
331
+ } else if (r.status === 501) {
332
+ setUnavailable(true);
333
+ setLoadError('');
334
+ } else {
335
+ setLoadError(r.error || 'Could not load your two-factor devices.');
336
+ }
337
+ }, [account, token]);
338
+
339
+ useEffect(() => {
340
+ let active = true;
341
+ (async () => {
342
+ setLoading(true);
343
+ const r = await account.listMfaDevices(token);
344
+ if (!active) return;
345
+ if (r.ok) {
346
+ setDevices(r.devices || []);
347
+ setUnavailable(false);
348
+ } else if (r.status === 501) {
349
+ setUnavailable(true);
350
+ } else {
351
+ setLoadError(r.error || 'Could not load your two-factor devices.');
352
+ }
353
+ setLoading(false);
354
+ })();
355
+ return () => {
356
+ active = false;
357
+ };
358
+ }, [account, token]);
359
+
360
+ // Render the otpauth:// URI to a scannable QR data-URL. Explicit dark-on-white so it
361
+ // scans in dark mode too.
362
+ useEffect(() => {
363
+ if (!otpauthUri) {
364
+ setQrDataUrl('');
365
+ return;
366
+ }
367
+ let cancelled = false;
368
+ QRCode.toDataURL(otpauthUri, {
369
+ errorCorrectionLevel: 'M',
370
+ margin: 2,
371
+ width: 200,
372
+ color: { dark: '#000000ff', light: '#ffffffff' },
373
+ })
374
+ .then((url) => {
375
+ if (!cancelled) setQrDataUrl(url);
376
+ })
377
+ .catch(() => {
378
+ if (!cancelled) setQrDataUrl('');
379
+ });
380
+ return () => {
381
+ cancelled = true;
382
+ };
383
+ }, [otpauthUri]);
384
+
385
+ // When the enroll step opens (QR shown), focus the OTP boxes so the user can type the
386
+ // code straight away — no click needed. Deferred a tick so the input is mounted first.
387
+ useEffect(() => {
388
+ if (addPhase !== 'enrolling') return undefined;
389
+ const t = setTimeout(() => codeInputRef.current?.focus(), 0);
390
+ return () => clearTimeout(t);
391
+ }, [addPhase]);
392
+
393
+ const copy = async (text, label) => {
394
+ try {
395
+ await navigator.clipboard.writeText(text);
396
+ toast.success(`${label} copied`);
397
+ } catch {
398
+ toast.error('Could not copy to clipboard');
399
+ }
400
+ };
401
+
402
+ // ---- Add-device flow -----------------------------------------------------
403
+
404
+ const openAdd = () => {
405
+ setAddError('');
406
+ setNewName('');
407
+ setAddPhase('naming');
408
+ };
409
+
410
+ const resetAdd = () => {
411
+ setAddPhase('idle');
412
+ setNewName('');
413
+ setDeviceId('');
414
+ setSecret('');
415
+ setOtpauthUri('');
416
+ setCode('');
417
+ setAddError('');
418
+ };
419
+
420
+ const onBegin = async (e) => {
421
+ e.preventDefault();
422
+ setAddError('');
423
+ const name = newName.trim();
424
+ if (!name) {
425
+ setAddError('Enter a name for this device.');
426
+ return;
427
+ }
428
+ setAddBusy(true);
429
+ const r = await account.beginMfaDevice(token, name);
430
+ setAddBusy(false);
431
+ if (!r.ok) {
432
+ setAddError(r.error || 'Could not start two-factor setup.');
433
+ return;
434
+ }
435
+ setDeviceId(r.deviceId || '');
436
+ setSecret(r.secret || '');
437
+ setOtpauthUri(r.otpauthUri || '');
438
+ setCode('');
439
+ setAddPhase('enrolling');
440
+ };
441
+
442
+ const onConfirm = async (e) => {
443
+ e.preventDefault();
444
+ setAddError('');
445
+ if (!code.trim()) {
446
+ setAddError('Enter the 6-digit code from your authenticator app.');
447
+ return;
448
+ }
449
+ setAddBusy(true);
450
+ const r = await account.confirmMfaDevice(token, deviceId, code.trim());
451
+ setAddBusy(false);
452
+ if (!r.ok) {
453
+ setAddError(r.error || 'Could not confirm this device.');
454
+ return;
455
+ }
456
+ const codes = r.recoveryCodes || [];
457
+ resetAdd();
458
+ await refresh();
459
+ toast.success('Authenticator device added');
460
+ if (codes.length > 0) {
461
+ setRecoveryCodes(codes);
462
+ setShowRecovery(true);
463
+ }
464
+ // In the forced-re-enroll flow, a successful device confirm is what re-secures the
465
+ // account — the server has cleared the gate, so release the UI. Deferred so any
466
+ // one-time recovery-codes panel is shown first (the parent decides when to leave).
467
+ onDeviceConfirmed?.();
468
+ };
469
+
470
+ // ---- Rename (inline, from the row) ---------------------------------------
471
+
472
+ const onRename = async (id, name) => {
473
+ const r = await account.renameMfaDevice(token, id, name);
474
+ if (!r.ok) {
475
+ toast.error(r.error || 'Could not rename the device.');
476
+ return false;
477
+ }
478
+ await refresh();
479
+ toast.success('Device renamed');
480
+ return true;
481
+ };
482
+
483
+ // ---- Remove (ConfirmDialog + code) ---------------------------------------
484
+
485
+ const openRemove = (device) => {
486
+ setRemoveError('');
487
+ setRemoveCode('');
488
+ setRemoveTarget(device);
489
+ };
490
+
491
+ const closeRemove = () => {
492
+ setRemoveTarget(null);
493
+ setRemoveCode('');
494
+ setRemoveError('');
495
+ };
496
+
497
+ const onConfirmRemove = async () => {
498
+ if (!removeTarget) return;
499
+ setRemoveError('');
500
+ if (!removeCode.trim()) {
501
+ setRemoveError('Enter a current 6-digit code or a recovery code.');
502
+ return;
503
+ }
504
+ setRemoveBusy(true);
505
+ const r = await account.removeMfaDevice(token, removeTarget.id, removeCode.trim());
506
+ setRemoveBusy(false);
507
+ if (!r.ok) {
508
+ setRemoveError(r.error || 'Could not remove the device.');
509
+ return;
510
+ }
511
+ closeRemove();
512
+ await refresh();
513
+ toast.success('Device removed');
514
+ };
515
+
516
+ // ---- Regenerate recovery codes (ConfirmDialog + code) --------------------
517
+
518
+ const openRegen = () => {
519
+ setRegenError('');
520
+ setRegenCode('');
521
+ setRegenOpen(true);
522
+ };
523
+
524
+ const closeRegen = () => {
525
+ setRegenOpen(false);
526
+ setRegenCode('');
527
+ setRegenError('');
528
+ };
529
+
530
+ const onConfirmRegen = async () => {
531
+ setRegenError('');
532
+ if (!regenCode.trim()) {
533
+ setRegenError('Enter a current 6-digit code or a recovery code.');
534
+ return;
535
+ }
536
+ setRegenBusy(true);
537
+ const r = await account.regenerateRecoveryCodes(token, regenCode.trim());
538
+ setRegenBusy(false);
539
+ if (!r.ok) {
540
+ setRegenError(r.error || 'Could not regenerate recovery codes.');
541
+ return;
542
+ }
543
+ closeRegen();
544
+ setRecoveryCodes(r.recoveryCodes || []);
545
+ setShowRecovery(true);
546
+ toast.success('Recovery codes regenerated');
547
+ };
548
+
549
+ // --------------------------------------------------------------------------
550
+
551
+ const busyAny = addBusy || removeBusy || regenBusy;
552
+
553
+ return (
554
+ <Card>
555
+ <CardHeader>
556
+ <CardTitle className="flex items-center gap-2 text-base">
557
+ <Smartphone className="size-4 text-primary" />
558
+ Two-factor authentication
559
+ </CardTitle>
560
+ <CardDescription>
561
+ Add one or more authenticator apps (Google Authenticator, Authy, 1Password) as a second
562
+ factor when you sign in. Any device can approve — keep more than one for backup.
563
+ </CardDescription>
564
+ </CardHeader>
565
+ <CardContent>
566
+ <div className="grid gap-6 md:grid-cols-2">
567
+ {/* LEFT — device manager / add-flow / recovery codes. */}
568
+ <div className="space-y-4">
569
+ {loadError && (
570
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
571
+ {loadError}
572
+ </div>
573
+ )}
574
+
575
+ {loading ? (
576
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
577
+ <Loader2 className="size-4 animate-spin" />
578
+ Loading your devices…
579
+ </div>
580
+ ) : unavailable ? (
581
+ <div className="rounded-lg border bg-muted/40 px-3 py-4 text-sm text-muted-foreground">
582
+ Two-factor authentication is not available.
583
+ </div>
584
+ ) : showRecovery ? (
585
+ <RecoveryCodesPanel
586
+ codes={recoveryCodes}
587
+ onCopy={() => copy(recoveryCodes.join('\n'), 'Recovery codes')}
588
+ onDone={() => {
589
+ setShowRecovery(false);
590
+ setRecoveryCodes([]);
591
+ }}
592
+ />
593
+ ) : addPhase === 'naming' ? (
594
+ <form onSubmit={onBegin} className="space-y-4" noValidate>
595
+ {addError && (
596
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
597
+ {addError}
598
+ </div>
599
+ )}
600
+ <div className="space-y-1.5">
601
+ <Label htmlFor="mfa-name">Device name</Label>
602
+ <Input
603
+ id="mfa-name"
604
+ autoFocus
605
+ maxLength={64}
606
+ placeholder="e.g. iPhone, Work laptop"
607
+ value={newName}
608
+ onChange={(e) => setNewName(e.target.value)}
609
+ />
610
+ <p className="text-xs text-muted-foreground">
611
+ A friendly label so you can tell your devices apart.
612
+ </p>
613
+ </div>
614
+ <div className="flex items-center gap-2">
615
+ <Button type="submit" disabled={addBusy}>
616
+ {addBusy ? (
617
+ <Loader2 className="size-4 animate-spin" />
618
+ ) : (
619
+ <ShieldCheck className="size-4" />
620
+ )}
621
+ Continue
622
+ </Button>
623
+ <Button type="button" variant="ghost" onClick={resetAdd} disabled={addBusy}>
624
+ Cancel
625
+ </Button>
626
+ </div>
627
+ </form>
628
+ ) : addPhase === 'enrolling' ? (
629
+ <form onSubmit={onConfirm} className="space-y-4" noValidate>
630
+ {addError && (
631
+ <div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
632
+ {addError}
633
+ </div>
634
+ )}
635
+ <div className="rounded-lg border bg-muted/40 p-3">
636
+ <p className="text-sm font-medium">1. Scan this with your authenticator app</p>
637
+ <p className="mt-1 text-xs text-muted-foreground">
638
+ Open your authenticator app and scan the QR code below.
639
+ </p>
640
+ {/* Fixed white/quiet-zone container so the QR always scans — including dark
641
+ mode, where a dark-on-dark QR would be unreadable. */}
642
+ <div className="mt-3 flex justify-center">
643
+ <div className="rounded-xl bg-white p-3 shadow-sm ring-1 ring-black/5">
644
+ {qrDataUrl ? (
645
+ <img
646
+ src={qrDataUrl}
647
+ alt="Two-factor setup QR code"
648
+ width={180}
649
+ height={180}
650
+ className="size-[180px]"
651
+ />
652
+ ) : (
653
+ <div className="flex size-[180px] items-center justify-center text-muted-foreground">
654
+ <Loader2 className="size-6 animate-spin" />
655
+ </div>
656
+ )}
657
+ </div>
658
+ </div>
659
+ <div className="mt-3 border-t pt-3">
660
+ <p className="text-xs font-medium text-muted-foreground">
661
+ Can&rsquo;t scan? Enter this key manually
662
+ </p>
663
+ <div className="mt-2 flex items-center gap-2">
664
+ <code className="flex-1 select-all break-all rounded bg-background px-2 py-1.5 font-mono text-sm">
665
+ {secret}
666
+ </code>
667
+ <Button
668
+ type="button"
669
+ variant="outline"
670
+ size="icon"
671
+ onClick={() => copy(secret, 'Key')}
672
+ >
673
+ <Copy className="size-4" />
674
+ </Button>
675
+ </div>
676
+ </div>
677
+ </div>
678
+ <div className="space-y-1.5">
679
+ <Label htmlFor="mfa-code">2. Enter the 6-digit code it shows</Label>
680
+ {/* Boxed segmented OTP (one box per digit) — same control as the login
681
+ MFA step: auto-advance, backspace-to-previous, paste fills all 6.
682
+ On the 6th digit we auto-submit the confirm (belt-and-suspenders with
683
+ the "Verify & add device" button). */}
684
+ <div className="flex py-1">
685
+ <InputOTP
686
+ ref={codeInputRef}
687
+ id="mfa-code"
688
+ maxLength={6}
689
+ inputMode="numeric"
690
+ pattern="[0-9]*"
691
+ autoComplete="one-time-code"
692
+ value={code}
693
+ onChange={(v) => setCode(v)}
694
+ onComplete={() => {
695
+ if (!addBusy) void onConfirm({ preventDefault: () => {} });
696
+ }}
697
+ disabled={addBusy}
698
+ >
699
+ <InputOTPGroup>
700
+ <InputOTPSlot index={0} />
701
+ <InputOTPSlot index={1} />
702
+ <InputOTPSlot index={2} />
703
+ <InputOTPSlot index={3} />
704
+ <InputOTPSlot index={4} />
705
+ <InputOTPSlot index={5} />
706
+ </InputOTPGroup>
707
+ </InputOTP>
708
+ </div>
709
+ </div>
710
+ <div className="flex items-center gap-2">
711
+ <Button type="submit" disabled={addBusy}>
712
+ {addBusy ? (
713
+ <Loader2 className="size-4 animate-spin" />
714
+ ) : (
715
+ <ShieldCheck className="size-4" />
716
+ )}
717
+ Verify &amp; add device
718
+ </Button>
719
+ <Button type="button" variant="ghost" onClick={resetAdd} disabled={addBusy}>
720
+ Cancel
721
+ </Button>
722
+ </div>
723
+ </form>
724
+ ) : devices.length === 0 ? (
725
+ // Empty / off state.
726
+ <div className="space-y-4">
727
+ <div className="rounded-lg border border-dashed bg-muted/30 px-4 py-6 text-center">
728
+ <Smartphone className="mx-auto size-6 text-muted-foreground" />
729
+ <p className="mt-2 text-sm font-medium text-foreground">Two-factor is off</p>
730
+ <p className="mt-1 text-sm text-muted-foreground">
731
+ Add an authenticator device to secure your account.
732
+ </p>
733
+ </div>
734
+ <Button type="button" onClick={openAdd}>
735
+ <Plus className="size-4" />
736
+ Add device
737
+ </Button>
738
+ </div>
739
+ ) : (
740
+ // Device list.
741
+ <div className="space-y-3">
742
+ <div className="space-y-2">
743
+ {devices.map((d) => (
744
+ <DeviceRow
745
+ key={d.id}
746
+ device={d}
747
+ onRename={onRename}
748
+ onRemove={openRemove}
749
+ disabled={busyAny}
750
+ reenrollMode={reenrollMode}
751
+ />
752
+ ))}
753
+ </div>
754
+ <div className="flex flex-nowrap items-center gap-2 pt-1">
755
+ <Button type="button" onClick={openAdd} disabled={busyAny}>
756
+ <Plus className="size-4" />
757
+ Add device
758
+ </Button>
759
+ <Button
760
+ type="button"
761
+ variant="outline"
762
+ onClick={openRegen}
763
+ disabled={busyAny}
764
+ className="whitespace-nowrap"
765
+ >
766
+ <KeyRound className="size-4" />
767
+ Regenerate recovery codes
768
+ </Button>
769
+ </div>
770
+ </div>
771
+ )}
772
+ </div>
773
+
774
+ {/* RIGHT — how-to guide. */}
775
+ <HowToGuide />
776
+ </div>
777
+ </CardContent>
778
+
779
+ {/* Sensitive op — remove is gated behind a themed confirm dialog that NAMES the
780
+ device; the proof code lives inside it and removeMfaDevice fires ONLY from
781
+ onConfirm. */}
782
+ <ConfirmDialog
783
+ open={!!removeTarget}
784
+ title={removeTarget ? `Remove “${removeTarget.name}”?` : 'Remove device?'}
785
+ message="This authenticator will no longer approve sign-in. Enter a current code from another device or a recovery code to confirm."
786
+ confirmLabel="Remove"
787
+ destructive
788
+ busy={removeBusy}
789
+ onConfirm={onConfirmRemove}
790
+ onCancel={closeRemove}
791
+ >
792
+ <div className="space-y-1.5">
793
+ <Label htmlFor="mfa-remove-code">Current code or recovery code</Label>
794
+ <Input
795
+ id="mfa-remove-code"
796
+ autoComplete="one-time-code"
797
+ placeholder="123456 or a recovery code"
798
+ value={removeCode}
799
+ onChange={(e) => setRemoveCode(e.target.value)}
800
+ disabled={removeBusy}
801
+ />
802
+ {removeError && <p className="text-sm text-destructive">{removeError}</p>}
803
+ </div>
804
+ </ConfirmDialog>
805
+
806
+ {/* Sensitive op — regenerate replaces the recovery-code set; a proof code is
807
+ gathered inside the dialog and the API fires ONLY from onConfirm. */}
808
+ <ConfirmDialog
809
+ open={regenOpen}
810
+ title="Regenerate recovery codes?"
811
+ message="Your existing recovery codes will stop working and a fresh set will be shown once. Enter a current code or a recovery code to confirm."
812
+ confirmLabel="Regenerate"
813
+ destructive
814
+ busy={regenBusy}
815
+ onConfirm={onConfirmRegen}
816
+ onCancel={closeRegen}
817
+ >
818
+ <div className="space-y-1.5">
819
+ <Label htmlFor="mfa-regen-code">Current code or recovery code</Label>
820
+ <Input
821
+ id="mfa-regen-code"
822
+ autoComplete="one-time-code"
823
+ placeholder="123456 or a recovery code"
824
+ value={regenCode}
825
+ onChange={(e) => setRegenCode(e.target.value)}
826
+ disabled={regenBusy}
827
+ />
828
+ {regenError && <p className="text-sm text-destructive">{regenError}</p>}
829
+ </div>
830
+ </ConfirmDialog>
831
+ </Card>
832
+ );
833
+ }