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.
- package/dist/add-frontend.js +107 -0
- package/dist/add-frontend.js.map +1 -0
- package/dist/index.js +122 -25
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/template/frontend/FRONTEND.md +92 -0
- package/template/frontend/_env.example +6 -0
- package/template/frontend/_gitignore +9 -0
- package/template/frontend/eslint.config.js +71 -0
- package/template/frontend/index.html +13 -0
- package/template/frontend/package.json +52 -0
- package/template/frontend/public/astram-logo-full.png +0 -0
- package/template/frontend/public/astram-logo-white.png +0 -0
- package/template/frontend/public/favicon.png +0 -0
- package/template/frontend/src/App.jsx +191 -0
- package/template/frontend/src/auth/AuthContext.jsx +343 -0
- package/template/frontend/src/components/ConfirmDialog.jsx +92 -0
- package/template/frontend/src/components/EntityFormModal.jsx +154 -0
- package/template/frontend/src/components/EntityList.jsx +480 -0
- package/template/frontend/src/components/PageHeader.jsx +54 -0
- package/template/frontend/src/components/access/CollapsibleGroup.jsx +34 -0
- package/template/frontend/src/components/ui/alert-dialog.tsx +175 -0
- package/template/frontend/src/components/ui/alert.tsx +83 -0
- package/template/frontend/src/components/ui/avatar.tsx +51 -0
- package/template/frontend/src/components/ui/badge.tsx +56 -0
- package/template/frontend/src/components/ui/button.tsx +67 -0
- package/template/frontend/src/components/ui/card.tsx +92 -0
- package/template/frontend/src/components/ui/checkbox.tsx +30 -0
- package/template/frontend/src/components/ui/date-field.tsx +47 -0
- package/template/frontend/src/components/ui/dialog.tsx +214 -0
- package/template/frontend/src/components/ui/dropdown-menu.tsx +255 -0
- package/template/frontend/src/components/ui/input-otp.tsx +81 -0
- package/template/frontend/src/components/ui/input.tsx +21 -0
- package/template/frontend/src/components/ui/label.tsx +46 -0
- package/template/frontend/src/components/ui/searchable-select.tsx +459 -0
- package/template/frontend/src/components/ui/select.tsx +199 -0
- package/template/frontend/src/components/ui/separator.tsx +26 -0
- package/template/frontend/src/components/ui/sheet.tsx +139 -0
- package/template/frontend/src/components/ui/skeleton.tsx +13 -0
- package/template/frontend/src/components/ui/sonner.tsx +23 -0
- package/template/frontend/src/components/ui/table.tsx +181 -0
- package/template/frontend/src/components/ui/tooltip.tsx +59 -0
- package/template/frontend/src/config/branding.js +80 -0
- package/template/frontend/src/hooks/useMobile.tsx +21 -0
- package/template/frontend/src/index.css +265 -0
- package/template/frontend/src/lib/utils.js +7 -0
- package/template/frontend/src/main.jsx +27 -0
- package/template/frontend/src/pages/Dashboard.jsx +127 -0
- package/template/frontend/src/pages/ForgotPassword.jsx +104 -0
- package/template/frontend/src/pages/Login.jsx +393 -0
- package/template/frontend/src/pages/ResetPassword.jsx +159 -0
- package/template/frontend/src/pages/access/AccessForm.jsx +416 -0
- package/template/frontend/src/pages/access/AccessPage.jsx +282 -0
- package/template/frontend/src/pages/access/RoleActionsMenu.jsx +42 -0
- package/template/frontend/src/pages/account/AccountLayout.jsx +229 -0
- package/template/frontend/src/pages/account/ChangePasswordPage.jsx +235 -0
- package/template/frontend/src/pages/account/ProfilePage.jsx +122 -0
- package/template/frontend/src/pages/account/ReenrollPage.jsx +98 -0
- package/template/frontend/src/pages/account/TwoFactorPage.jsx +833 -0
- package/template/frontend/src/pages/audit/AuditPage.jsx +268 -0
- package/template/frontend/src/pages/companies/CompaniesPage.jsx +232 -0
- package/template/frontend/src/pages/companies/CompanyForm.jsx +127 -0
- package/template/frontend/src/pages/cross-tenant/GrantForm.jsx +337 -0
- package/template/frontend/src/pages/cross-tenant/GrantsPage.jsx +237 -0
- package/template/frontend/src/pages/cross-tenant/InboxPage.jsx +179 -0
- package/template/frontend/src/pages/roles/RoleForm.jsx +99 -0
- package/template/frontend/src/pages/roles/RolesPage.jsx +207 -0
- package/template/frontend/src/pages/users/AssignmentsEditor.jsx +193 -0
- package/template/frontend/src/pages/users/MembershipCard.jsx +303 -0
- package/template/frontend/src/pages/users/PostingRow.jsx +264 -0
- package/template/frontend/src/pages/users/UserForm.jsx +167 -0
- package/template/frontend/src/pages/users/UsersPage.jsx +232 -0
- package/template/frontend/src/platform/httpClient.js +125 -0
- package/template/frontend/src/platform/returnPathStore.js +43 -0
- package/template/frontend/src/platform/sessionStore.js +29 -0
- package/template/frontend/src/platform/shieldTransport.js +172 -0
- package/template/frontend/src/platform/shieldTransport.test.mjs +245 -0
- package/template/frontend/src/shell/AppShell.jsx +237 -0
- package/template/frontend/src/shell/Sidebar.jsx +257 -0
- package/template/frontend/src/shell/nav.js +103 -0
- package/template/frontend/src/theme/ThemeProvider.jsx +14 -0
- package/template/frontend/src/theme/ThemeToggle.jsx +44 -0
- package/template/frontend/tsconfig.json +22 -0
- package/template/frontend/vite.config.js +30 -0
- package/template/server/_env.example +27 -15
- package/template/server/package.json +7 -4
- package/template/server/src/app.module.ts +28 -2
- 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 “enter a setup key” 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’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 & 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
|
+
}
|