create-basalt 1.2.0 → 1.2.2

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,684 @@
1
+ /** Kept in sync with the monorepo release line (mirror of templates.ts). */
2
+ const BASALT_VERSION = '^1.0.0';
3
+ /**
4
+ * The `web/` frontend emitted when `--ui` is passed: a Vite + React app on
5
+ * @basaltkit/admin-shadcn (authentic shadcn/ui components) talking to the API
6
+ * through @basaltkit/sdk. With auth on, it ships a login/register gate and a
7
+ * small dashboard; otherwise a live status page. The Vite dev server proxies
8
+ * `/api` to the backend so there is no CORS to configure.
9
+ */
10
+ export function uiFiles(options) {
11
+ return {
12
+ 'web/package.json': webPackageJson(options),
13
+ 'web/vite.config.ts': webViteConfig(),
14
+ 'web/tsconfig.json': webTsconfig(),
15
+ 'web/index.html': webIndexHtml(options),
16
+ 'web/postcss.config.js': webPostcss(),
17
+ 'web/tailwind.config.js': webTailwind(),
18
+ 'web/src/main.tsx': webMain(),
19
+ 'web/src/crypto-shim.ts': webCryptoShim(),
20
+ 'web/src/index.css': webCss(),
21
+ 'web/src/api.ts': webApi(options),
22
+ 'web/src/App.tsx': webApp(options),
23
+ };
24
+ }
25
+ function webPackageJson(options) {
26
+ return `${JSON.stringify({
27
+ name: `${options.name}-web`,
28
+ private: true,
29
+ type: 'module',
30
+ scripts: { dev: 'vite', build: 'vite build', preview: 'vite preview' },
31
+ dependencies: {
32
+ '@basaltkit/admin': BASALT_VERSION,
33
+ '@basaltkit/admin-shadcn': BASALT_VERSION,
34
+ '@basaltkit/sdk': BASALT_VERSION,
35
+ react: '^18.3.1',
36
+ 'react-dom': '^18.3.1',
37
+ zod: '^3.24.0',
38
+ },
39
+ devDependencies: {
40
+ '@types/react': '^18.3.0',
41
+ '@types/react-dom': '^18.3.0',
42
+ '@vitejs/plugin-react': '^4.3.0',
43
+ autoprefixer: '^10.4.0',
44
+ postcss: '^8.4.0',
45
+ tailwindcss: '^3.4.0',
46
+ typescript: '^5.8.0',
47
+ vite: '^6.0.0',
48
+ },
49
+ }, null, 2)}\n`;
50
+ }
51
+ function webViteConfig() {
52
+ return `import { fileURLToPath } from 'node:url'
53
+ import react from '@vitejs/plugin-react'
54
+ import { defineConfig } from 'vite'
55
+
56
+ export default defineConfig({
57
+ plugins: [react()],
58
+ resolve: {
59
+ alias: {
60
+ // @basaltkit/admin uses randomUUID from crypto; map it to Web Crypto in the browser.
61
+ crypto: fileURLToPath(new URL('./src/crypto-shim.ts', import.meta.url)),
62
+ },
63
+ },
64
+ server: {
65
+ port: 5180,
66
+ // Proxy API calls to the backend so there is no CORS in dev.
67
+ proxy: {
68
+ '/api': {
69
+ target: 'http://localhost:3000',
70
+ changeOrigin: true,
71
+ rewrite: (path) => path.replace(/^\\/api/, ''),
72
+ },
73
+ },
74
+ },
75
+ })
76
+ `;
77
+ }
78
+ function webTsconfig() {
79
+ return `${JSON.stringify({
80
+ compilerOptions: {
81
+ target: 'ES2022',
82
+ lib: ['ES2022', 'DOM', 'DOM.Iterable'],
83
+ module: 'ESNext',
84
+ moduleResolution: 'Bundler',
85
+ jsx: 'react-jsx',
86
+ strict: true,
87
+ noUncheckedIndexedAccess: true,
88
+ skipLibCheck: true,
89
+ noEmit: true,
90
+ allowImportingTsExtensions: true,
91
+ verbatimModuleSyntax: true,
92
+ types: ['node'],
93
+ },
94
+ include: ['src'],
95
+ }, null, 2)}\n`;
96
+ }
97
+ function webIndexHtml(options) {
98
+ return `<!doctype html>
99
+ <html lang="en">
100
+ <head>
101
+ <meta charset="UTF-8" />
102
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
103
+ <title>${options.name}</title>
104
+ </head>
105
+ <body>
106
+ <div id="root"></div>
107
+ <script type="module" src="/src/main.tsx"></script>
108
+ </body>
109
+ </html>
110
+ `;
111
+ }
112
+ function webPostcss() {
113
+ return `export default {
114
+ plugins: {
115
+ tailwindcss: {},
116
+ autoprefixer: {},
117
+ },
118
+ }
119
+ `;
120
+ }
121
+ function webTailwind() {
122
+ return `export default {
123
+ darkMode: ['class'],
124
+ content: ['./index.html', './src/**/*.{ts,tsx}', './node_modules/@basaltkit/admin-shadcn/dist/**/*.js'],
125
+ theme: {
126
+ extend: {
127
+ colors: {
128
+ border: 'hsl(var(--border))',
129
+ input: 'hsl(var(--input))',
130
+ ring: 'hsl(var(--ring))',
131
+ background: 'hsl(var(--background))',
132
+ foreground: 'hsl(var(--foreground))',
133
+ primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))' },
134
+ secondary: { DEFAULT: 'hsl(var(--secondary))', foreground: 'hsl(var(--secondary-foreground))' },
135
+ destructive: { DEFAULT: 'hsl(var(--destructive))', foreground: 'hsl(var(--destructive-foreground))' },
136
+ muted: { DEFAULT: 'hsl(var(--muted))', foreground: 'hsl(var(--muted-foreground))' },
137
+ accent: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-foreground))' },
138
+ card: { DEFAULT: 'hsl(var(--card))', foreground: 'hsl(var(--card-foreground))' },
139
+ },
140
+ borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)' },
141
+ },
142
+ },
143
+ plugins: [],
144
+ }
145
+ `;
146
+ }
147
+ function webMain() {
148
+ return `import { StrictMode } from 'react'
149
+ import { createRoot } from 'react-dom/client'
150
+ import { App } from './App.js'
151
+ import './index.css'
152
+
153
+ createRoot(document.getElementById('root') as HTMLElement).render(
154
+ <StrictMode>
155
+ <App />
156
+ </StrictMode>,
157
+ )
158
+ `;
159
+ }
160
+ function webCryptoShim() {
161
+ return `export const randomUUID = (): string => globalThis.crypto.randomUUID()
162
+ `;
163
+ }
164
+ function webCss() {
165
+ return `@tailwind base;
166
+ @tailwind components;
167
+ @tailwind utilities;
168
+
169
+ @layer base {
170
+ :root {
171
+ --background: 240 20% 99%;
172
+ --foreground: 240 10% 3.9%;
173
+ --card: 0 0% 100%;
174
+ --card-foreground: 240 10% 3.9%;
175
+ --primary: 243 75% 59%;
176
+ --primary-foreground: 0 0% 100%;
177
+ --secondary: 240 4.8% 95.9%;
178
+ --secondary-foreground: 240 5.9% 10%;
179
+ --muted: 240 4.8% 95.9%;
180
+ --muted-foreground: 240 3.8% 46.1%;
181
+ --accent: 243 75% 96%;
182
+ --accent-foreground: 243 60% 40%;
183
+ --destructive: 0 72% 51%;
184
+ --destructive-foreground: 0 0% 100%;
185
+ --border: 240 5.9% 90%;
186
+ --input: 240 5.9% 88%;
187
+ --ring: 243 75% 59%;
188
+ --radius: 0.6rem;
189
+ }
190
+ .dark {
191
+ --background: 240 10% 6%;
192
+ --foreground: 0 0% 98%;
193
+ --card: 240 10% 9%;
194
+ --card-foreground: 0 0% 98%;
195
+ --primary: 243 75% 68%;
196
+ --primary-foreground: 240 10% 6%;
197
+ --secondary: 240 3.7% 15.9%;
198
+ --secondary-foreground: 0 0% 98%;
199
+ --muted: 240 3.7% 15.9%;
200
+ --muted-foreground: 240 5% 64.9%;
201
+ --accent: 243 40% 22%;
202
+ --accent-foreground: 243 75% 85%;
203
+ --destructive: 0 72% 55%;
204
+ --destructive-foreground: 0 0% 98%;
205
+ --border: 240 3.7% 16%;
206
+ --input: 240 3.7% 18%;
207
+ --ring: 243 75% 68%;
208
+ }
209
+ * {
210
+ border-color: hsl(var(--border));
211
+ }
212
+ body {
213
+ margin: 0;
214
+ background-color: hsl(var(--background));
215
+ color: hsl(var(--foreground));
216
+ font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
217
+ -webkit-font-smoothing: antialiased;
218
+ }
219
+ }
220
+ `;
221
+ }
222
+ function webApi(options) {
223
+ const authEndpoints = options.auth
224
+ ? `
225
+ register: endpoint({ method: 'POST', path: '/auth/register', body: credentials, result: User }),
226
+ login: endpoint({
227
+ method: 'POST',
228
+ path: '/auth/login',
229
+ body: credentials.extend({ mfaCode: z.string().optional() }),
230
+ result: z.object({ accessToken: z.string(), refreshToken: z.string(), user: User }),
231
+ }),
232
+ me: endpoint({ method: 'GET', path: '/auth/me', result: User }),
233
+ // Password recovery
234
+ forgotPassword: endpoint({
235
+ method: 'POST',
236
+ path: '/auth/password/forgot',
237
+ body: z.object({ email: z.string().email() }),
238
+ result: z.object({ ok: z.boolean() }),
239
+ }),
240
+ resetPassword: endpoint({
241
+ method: 'POST',
242
+ path: '/auth/password/reset',
243
+ body: z.object({ token: z.string(), password: z.string().min(8) }),
244
+ result: z.object({ ok: z.boolean() }),
245
+ }),
246
+ // TOTP / two-factor
247
+ mfaStatus: endpoint({
248
+ method: 'GET',
249
+ path: '/auth/mfa/status',
250
+ result: z.object({ enabled: z.boolean(), pending: z.boolean() }),
251
+ }),
252
+ mfaEnroll: endpoint({
253
+ method: 'POST',
254
+ path: '/auth/mfa/enroll',
255
+ result: z.object({ secret: z.string(), otpauthUri: z.string() }),
256
+ }),
257
+ mfaActivate: endpoint({
258
+ method: 'POST',
259
+ path: '/auth/mfa/activate',
260
+ body: z.object({ code: z.string().min(6) }),
261
+ result: z.object({ recoveryCodes: z.array(z.string()) }),
262
+ }),
263
+ mfaDisable: endpoint({
264
+ method: 'POST',
265
+ path: '/auth/mfa/disable',
266
+ body: z.object({ code: z.string().min(6) }),
267
+ result: z.unknown(),
268
+ }),`
269
+ : '';
270
+ const credentials = options.auth
271
+ ? `\nconst credentials = z.object({ email: z.string().email(), password: z.string().min(8) })`
272
+ : '';
273
+ const user = options.auth ? `\nconst User = z.object({ id: z.string(), email: z.string() })` : '';
274
+ return `import { createClient, endpoint, type Client as SdkClient } from '@basaltkit/sdk'
275
+ import { z } from 'zod'
276
+ ${user}${credentials}
277
+
278
+ const Index = z.object({ name: z.string(), status: z.string(), endpoints: z.array(z.string()) })
279
+ const Health = z.object({ ok: z.boolean() })
280
+
281
+ export const endpoints = {
282
+ index: endpoint({ method: 'GET', path: '/', result: Index }),
283
+ health: endpoint({ method: 'GET', path: '/health', result: Health }),${authEndpoints}
284
+ }
285
+
286
+ export type Api = SdkClient<typeof endpoints>
287
+
288
+ export function makeApi(getToken: () => string | undefined = () => undefined): Api {
289
+ return createClient(endpoints, { baseUrl: '/api', getToken })
290
+ }
291
+ `;
292
+ }
293
+ function webApp(options) {
294
+ return options.auth ? webAppWithAuth(options) : webAppStatusOnly(options);
295
+ }
296
+ function webAppStatusOnly(options) {
297
+ return `import { useEffect, useState } from 'react'
298
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from '@basaltkit/admin-shadcn'
299
+ import { makeApi } from './api.js'
300
+
301
+ const api = makeApi()
302
+
303
+ export function App() {
304
+ const [name, setName] = useState('${options.name}')
305
+ const [endpoints, setEndpoints] = useState<string[]>([])
306
+ const [healthy, setHealthy] = useState<boolean | null>(null)
307
+ const [error, setError] = useState<string | null>(null)
308
+ const [dark, setDark] = useState(false)
309
+
310
+ const refresh = async () => {
311
+ setError(null)
312
+ try {
313
+ const [index, health] = await Promise.all([api.index(), api.health()])
314
+ setName(index.name)
315
+ setEndpoints(index.endpoints)
316
+ setHealthy(health.ok)
317
+ } catch (err) {
318
+ setError(String((err as Error)?.message ?? err))
319
+ }
320
+ }
321
+ useEffect(() => {
322
+ void refresh()
323
+ }, [])
324
+
325
+ const toggleTheme = () => {
326
+ const next = !dark
327
+ document.documentElement.classList.toggle('dark', next)
328
+ setDark(next)
329
+ }
330
+
331
+ return (
332
+ <div className="grid min-h-screen place-items-center px-4">
333
+ <Card className="w-full max-w-lg">
334
+ <CardHeader className="flex-row items-center justify-between space-y-0">
335
+ <CardTitle>{name}</CardTitle>
336
+ <div className="flex items-center gap-2">
337
+ {healthy !== null && (
338
+ <Badge variant={healthy ? 'secondary' : 'destructive'}>
339
+ {healthy ? 'healthy' : 'down'}
340
+ </Badge>
341
+ )}
342
+ <Button variant="ghost" size="sm" onClick={toggleTheme}>
343
+ {dark ? 'Light' : 'Dark'}
344
+ </Button>
345
+ </div>
346
+ </CardHeader>
347
+ <CardContent>
348
+ {error && <p className="mb-3 text-sm font-medium text-destructive">{error}</p>}
349
+ <p className="mb-2 text-sm text-muted-foreground">Endpoints</p>
350
+ <ul className="space-y-1">
351
+ {endpoints.map((path) => (
352
+ <li key={path} className="rounded-md bg-muted px-3 py-1.5 font-mono text-sm">
353
+ {path}
354
+ </li>
355
+ ))}
356
+ </ul>
357
+ <Button className="mt-4" variant="outline" size="sm" onClick={() => void refresh()}>
358
+ Refresh
359
+ </Button>
360
+ </CardContent>
361
+ </Card>
362
+ </div>
363
+ )
364
+ }
365
+ `;
366
+ }
367
+ function webAppWithAuth(options) {
368
+ return `import { useEffect, useState, type FormEvent, type ReactNode } from 'react'
369
+ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from '@basaltkit/admin-shadcn'
370
+ import { makeApi } from './api.js'
371
+
372
+ const STORAGE_KEY = '${options.name}.token'
373
+ const errorMessage = (err: unknown): string => String((err as Error)?.message ?? err)
374
+ const errorCode = (err: unknown): string => String((err as { code?: string })?.code ?? '')
375
+ // Password-reset emails link back here with ?token=… — that switches to the reset screen.
376
+ const resetToken = new URLSearchParams(window.location.search).get('token')
377
+
378
+ export function App() {
379
+ const [token, setToken] = useState<string | null>(() => localStorage.getItem(STORAGE_KEY))
380
+
381
+ const store = (next: string | null) => {
382
+ if (next) localStorage.setItem(STORAGE_KEY, next)
383
+ else localStorage.removeItem(STORAGE_KEY)
384
+ setToken(next)
385
+ }
386
+
387
+ if (resetToken) return <ResetPassword token={resetToken} />
388
+ if (!token) return <AuthScreen onToken={store} />
389
+ return <Dashboard token={token} onLogout={() => store(null)} />
390
+ }
391
+
392
+ // Centered card shell reused by every auth screen.
393
+ function Shell({ subtitle, children }: { subtitle?: string; children: ReactNode }) {
394
+ return (
395
+ <div className="grid min-h-screen place-items-center px-4">
396
+ <Card className="w-full max-w-md">
397
+ <CardHeader>
398
+ <CardTitle>${options.name}</CardTitle>
399
+ {subtitle && <p className="text-sm text-muted-foreground">{subtitle}</p>}
400
+ </CardHeader>
401
+ <CardContent>{children}</CardContent>
402
+ </Card>
403
+ </div>
404
+ )
405
+ }
406
+
407
+ type Mode = 'login' | 'register' | 'forgot'
408
+
409
+ function AuthScreen({ onToken }: { onToken: (token: string) => void }) {
410
+ const [mode, setMode] = useState<Mode>('login')
411
+ const [error, setError] = useState<string | null>(null)
412
+ const [notice, setNotice] = useState<string | null>(null)
413
+ const [busy, setBusy] = useState(false)
414
+ // When the account has 2FA on, login throws AUTH_MFA_REQUIRED; we keep the
415
+ // credentials and ask for the 6-digit code, then retry.
416
+ const [challenge, setChallenge] = useState<{ email: string; password: string } | null>(null)
417
+ const api = makeApi()
418
+
419
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
420
+ event.preventDefault()
421
+ setError(null)
422
+ setNotice(null)
423
+ setBusy(true)
424
+ const form = new FormData(event.currentTarget)
425
+ try {
426
+ if (challenge) {
427
+ const res = await api.login({ body: { ...challenge, mfaCode: String(form.get('mfaCode')) } })
428
+ onToken(res.accessToken)
429
+ return
430
+ }
431
+ const email = String(form.get('email'))
432
+ const password = String(form.get('password'))
433
+ if (mode === 'forgot') {
434
+ await api.forgotPassword({ body: { email } })
435
+ setNotice('If that email exists, a reset link is on its way.')
436
+ return
437
+ }
438
+ if (mode === 'register') await api.register({ body: { email, password } })
439
+ const res = await api.login({ body: { email, password } })
440
+ onToken(res.accessToken)
441
+ } catch (err) {
442
+ if (errorCode(err) === 'AUTH_MFA_REQUIRED') {
443
+ setChallenge({
444
+ email: String(new FormData(event.currentTarget).get('email')),
445
+ password: String(new FormData(event.currentTarget).get('password')),
446
+ })
447
+ } else {
448
+ setError(errorMessage(err))
449
+ }
450
+ } finally {
451
+ setBusy(false)
452
+ }
453
+ }
454
+
455
+ if (challenge) {
456
+ return (
457
+ <Shell subtitle="Two-factor authentication">
458
+ <form onSubmit={submit} className="space-y-4">
459
+ <div className="space-y-2">
460
+ <Label htmlFor="mfaCode">Authenticator code</Label>
461
+ <Input id="mfaCode" name="mfaCode" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" required />
462
+ </div>
463
+ {error && <p className="text-sm font-medium text-destructive">{error}</p>}
464
+ <Button type="submit" className="w-full" disabled={busy}>{busy ? '…' : 'Verify'}</Button>
465
+ </form>
466
+ <button className="mt-4 w-full text-center text-sm text-muted-foreground hover:text-foreground" onClick={() => { setChallenge(null); setError(null) }}>
467
+ Back to sign in
468
+ </button>
469
+ </Shell>
470
+ )
471
+ }
472
+
473
+ const subtitle = mode === 'register' ? 'Create an account' : mode === 'forgot' ? 'Reset your password' : 'Sign in'
474
+ return (
475
+ <Shell subtitle={subtitle}>
476
+ <form onSubmit={submit} className="space-y-4">
477
+ <div className="space-y-2">
478
+ <Label htmlFor="email">Email</Label>
479
+ <Input id="email" name="email" type="email" placeholder="you@example.com" required />
480
+ </div>
481
+ {mode !== 'forgot' && (
482
+ <div className="space-y-2">
483
+ <Label htmlFor="password">Password</Label>
484
+ <Input id="password" name="password" type="password" placeholder="••••••••" required />
485
+ </div>
486
+ )}
487
+ {error && <p className="text-sm font-medium text-destructive">{error}</p>}
488
+ {notice && <p className="text-sm font-medium text-foreground">{notice}</p>}
489
+ <Button type="submit" className="w-full" disabled={busy}>
490
+ {busy ? '…' : mode === 'register' ? 'Create account' : mode === 'forgot' ? 'Send reset link' : 'Sign in'}
491
+ </Button>
492
+ </form>
493
+ <div className="mt-4 space-y-1 text-center text-sm text-muted-foreground">
494
+ {mode === 'login' && (
495
+ <button className="block w-full hover:text-foreground" onClick={() => { setMode('forgot'); setError(null); setNotice(null) }}>
496
+ Forgot your password?
497
+ </button>
498
+ )}
499
+ <button className="block w-full hover:text-foreground" onClick={() => { setMode(mode === 'register' ? 'login' : 'register'); setError(null); setNotice(null) }}>
500
+ {mode === 'register' ? 'Already have an account? Sign in' : mode === 'forgot' ? 'Back to sign in' : 'New here? Create an account'}
501
+ </button>
502
+ </div>
503
+ </Shell>
504
+ )
505
+ }
506
+
507
+ function ResetPassword({ token }: { token: string }) {
508
+ const [done, setDone] = useState(false)
509
+ const [error, setError] = useState<string | null>(null)
510
+ const [busy, setBusy] = useState(false)
511
+
512
+ const backToLogin = () => {
513
+ window.history.replaceState({}, '', window.location.pathname)
514
+ window.location.reload()
515
+ }
516
+
517
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
518
+ event.preventDefault()
519
+ setError(null)
520
+ setBusy(true)
521
+ const password = String(new FormData(event.currentTarget).get('password'))
522
+ try {
523
+ await makeApi().resetPassword({ body: { token, password } })
524
+ setDone(true)
525
+ } catch (err) {
526
+ setError(errorMessage(err))
527
+ } finally {
528
+ setBusy(false)
529
+ }
530
+ }
531
+
532
+ if (done) {
533
+ return (
534
+ <Shell subtitle="Password updated">
535
+ <Button className="w-full" onClick={backToLogin}>Sign in</Button>
536
+ </Shell>
537
+ )
538
+ }
539
+ return (
540
+ <Shell subtitle="Choose a new password">
541
+ <form onSubmit={submit} className="space-y-4">
542
+ <div className="space-y-2">
543
+ <Label htmlFor="password">New password</Label>
544
+ <Input id="password" name="password" type="password" placeholder="••••••••" required minLength={8} />
545
+ </div>
546
+ {error && <p className="text-sm font-medium text-destructive">{error}</p>}
547
+ <Button type="submit" className="w-full" disabled={busy}>{busy ? '…' : 'Reset password'}</Button>
548
+ </form>
549
+ </Shell>
550
+ )
551
+ }
552
+
553
+ function Dashboard({ token, onLogout }: { token: string; onLogout: () => void }) {
554
+ const api = makeApi(() => token)
555
+ const [email, setEmail] = useState<string | null>(null)
556
+ const [healthy, setHealthy] = useState<boolean | null>(null)
557
+ const [mfa, setMfa] = useState<{ enabled: boolean; pending: boolean } | null>(null)
558
+ const [error, setError] = useState<string | null>(null)
559
+
560
+ const load = async () => {
561
+ try {
562
+ const [me, health, status] = await Promise.all([api.me(), api.health(), api.mfaStatus()])
563
+ setEmail(me.email)
564
+ setHealthy(health.ok)
565
+ setMfa(status)
566
+ } catch (err) {
567
+ setError(errorMessage(err))
568
+ }
569
+ }
570
+ useEffect(() => {
571
+ void load()
572
+ // eslint-disable-next-line react-hooks/exhaustive-deps
573
+ }, [token])
574
+
575
+ return (
576
+ <div className="grid min-h-screen place-items-center px-4 py-10">
577
+ <Card className="w-full max-w-lg">
578
+ <CardHeader className="flex-row items-center justify-between space-y-0">
579
+ <CardTitle>${options.name}</CardTitle>
580
+ {healthy !== null && (
581
+ <Badge variant={healthy ? 'secondary' : 'destructive'}>{healthy ? 'healthy' : 'down'}</Badge>
582
+ )}
583
+ </CardHeader>
584
+ <CardContent className="space-y-4">
585
+ {error && <p className="text-sm font-medium text-destructive">{error}</p>}
586
+ <p className="text-sm text-muted-foreground">
587
+ Signed in as <span className="font-medium text-foreground">{email ?? '…'}</span>
588
+ </p>
589
+ <MfaCard status={mfa} api={api} onChange={() => void load()} />
590
+ <Button variant="outline" size="sm" onClick={onLogout}>Sign out</Button>
591
+ </CardContent>
592
+ </Card>
593
+ </div>
594
+ )
595
+ }
596
+
597
+ type ApiClient = ReturnType<typeof makeApi>
598
+
599
+ function MfaCard({ status, api, onChange }: { status: { enabled: boolean; pending: boolean } | null; api: ApiClient; onChange: () => void }) {
600
+ const [enroll, setEnroll] = useState<{ secret: string; otpauthUri: string } | null>(null)
601
+ const [recovery, setRecovery] = useState<string[] | null>(null)
602
+ const [error, setError] = useState<string | null>(null)
603
+ const [busy, setBusy] = useState(false)
604
+
605
+ const run = async (fn: () => Promise<void>) => {
606
+ setError(null)
607
+ setBusy(true)
608
+ try {
609
+ await fn()
610
+ } catch (err) {
611
+ setError(errorMessage(err))
612
+ } finally {
613
+ setBusy(false)
614
+ }
615
+ }
616
+
617
+ const start = () => run(async () => setEnroll(await api.mfaEnroll()))
618
+ const activate = (event: FormEvent<HTMLFormElement>) => {
619
+ event.preventDefault()
620
+ const code = String(new FormData(event.currentTarget).get('code'))
621
+ return run(async () => {
622
+ const res = await api.mfaActivate({ body: { code } })
623
+ setRecovery(res.recoveryCodes)
624
+ setEnroll(null)
625
+ onChange()
626
+ })
627
+ }
628
+ const disable = (event: FormEvent<HTMLFormElement>) => {
629
+ event.preventDefault()
630
+ const code = String(new FormData(event.currentTarget).get('code'))
631
+ return run(async () => {
632
+ await api.mfaDisable({ body: { code } })
633
+ onChange()
634
+ })
635
+ }
636
+
637
+ return (
638
+ <div className="rounded-lg border p-3">
639
+ <div className="mb-2 flex items-center justify-between">
640
+ <span className="text-sm font-medium">Two-factor authentication</span>
641
+ <Badge variant={status?.enabled ? 'secondary' : 'outline'}>{status?.enabled ? 'on' : 'off'}</Badge>
642
+ </div>
643
+ {error && <p className="mb-2 text-sm font-medium text-destructive">{error}</p>}
644
+
645
+ {recovery && (
646
+ <div className="space-y-2">
647
+ <p className="text-sm text-muted-foreground">Save these recovery codes — each works once:</p>
648
+ <ul className="grid grid-cols-2 gap-1">
649
+ {recovery.map((c) => (
650
+ <li key={c} className="rounded bg-muted px-2 py-1 font-mono text-xs">{c}</li>
651
+ ))}
652
+ </ul>
653
+ <Button size="sm" variant="outline" onClick={() => setRecovery(null)}>Done</Button>
654
+ </div>
655
+ )}
656
+
657
+ {!recovery && enroll && (
658
+ <form onSubmit={activate} className="space-y-2">
659
+ <p className="text-sm text-muted-foreground">Add this secret to your authenticator app, then enter a code:</p>
660
+ <code className="block break-all rounded bg-muted px-2 py-1 font-mono text-xs">{enroll.secret}</code>
661
+ <a className="block truncate text-xs text-primary underline" href={enroll.otpauthUri}>{enroll.otpauthUri}</a>
662
+ <Input name="code" inputMode="numeric" placeholder="123456" required />
663
+ <Button size="sm" type="submit" disabled={busy}>Activate</Button>
664
+ </form>
665
+ )}
666
+
667
+ {!recovery && !enroll && status?.enabled && (
668
+ <form onSubmit={disable} className="flex items-end gap-2">
669
+ <div className="flex-1">
670
+ <Label htmlFor="mfa-off" className="text-xs">Enter a code to turn off</Label>
671
+ <Input id="mfa-off" name="code" inputMode="numeric" placeholder="123456" required />
672
+ </div>
673
+ <Button size="sm" variant="destructive" type="submit" disabled={busy}>Disable</Button>
674
+ </form>
675
+ )}
676
+
677
+ {!recovery && !enroll && !status?.enabled && (
678
+ <Button size="sm" onClick={start} disabled={busy}>{busy ? '…' : 'Enable 2FA'}</Button>
679
+ )}
680
+ </div>
681
+ )
682
+ }
683
+ `;
684
+ }