@better-forge/ui 0.1.0 → 0.1.1

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,438 +1,438 @@
1
- import { useState, useEffect } from 'react'
2
- import { useNavigate } from 'react-router-dom'
3
- import { api, isAuthenticated, setSession, type AuthUser } from '../api/client'
4
- import { uiConfig } from '../config'
5
- import { useDarkMode } from '../hooks/useDarkMode'
6
- import { Alert } from '../components/ui'
7
-
8
- const STEPS = [
9
- { n: 1, label: 'Database' },
10
- { n: 2, label: 'Admin Account' },
11
- { n: 3, label: 'Done' },
12
- ]
13
-
14
- const LABEL_CLASS = 'block text-xs font-sans font-semibold mb-1.5 uppercase tracking-wider'
15
-
16
- export default function SetupPage() {
17
- const navigate = useNavigate()
18
- const [isDark, toggleDark] = useDarkMode()
19
- const [step, setStep] = useState<1 | 2 | 3>(1)
20
- const [checking, setChecking] = useState(true)
21
-
22
- useEffect(() => {
23
- fetch('/api/v1/setup/status')
24
- .then((r) => r.json())
25
- .then((data: { needsSetup: boolean }) => {
26
- if (!data.needsSetup) navigate(isAuthenticated() ? `${uiConfig().basePath}/` : `${uiConfig().basePath}/login`, { replace: true })
27
- else setChecking(false)
28
- })
29
- .catch(() => setChecking(false))
30
- }, [navigate])
31
-
32
- // Step 2 fields
33
- const [email, setEmail] = useState('')
34
- const [password, setPassword] = useState('')
35
- const [confirm, setConfirm] = useState('')
36
- const [error, setError] = useState('')
37
- const [loading, setLoading] = useState(false)
38
- const [pgTooltip, setPgTooltip] = useState(false)
39
-
40
- async function handleSubmit(e: React.FormEvent) {
41
- e.preventDefault()
42
- setError('')
43
- if (password !== confirm) { setError('Passwords do not match'); return }
44
- if (password.length < 8) { setError('Password must be at least 8 characters'); return }
45
- setLoading(true)
46
- try {
47
- const res = await api.post<AuthUser>('/setup/complete', { email, password })
48
- setSession(res)
49
- setStep(3)
50
- } catch (err) {
51
- setError(err instanceof Error ? err.message : 'Setup failed')
52
- } finally {
53
- setLoading(false)
54
- }
55
- }
56
-
57
- if (checking) return null
58
-
59
- return (
60
- <div className="min-h-screen flex" style={{ background: 'var(--m3-surface)' }}>
61
-
62
- {/* ── Left branding panel ── */}
63
- <div
64
- className="hidden lg:flex flex-col justify-between w-[400px] flex-shrink-0 p-12 relative overflow-hidden"
65
- style={{
66
- background: 'var(--m3-surface-container-low)',
67
- borderRight: '1px solid var(--m3-outline-variant)',
68
- }}
69
- >
70
- {/* Decorative blobs */}
71
- <div className="absolute -top-32 -left-32 w-80 h-80 rounded-full pointer-events-none"
72
- style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 12%, transparent) 0%, transparent 70%)' }} />
73
- <div className="absolute bottom-0 right-0 w-96 h-96 rounded-full pointer-events-none"
74
- style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 6%, transparent) 0%, transparent 70%)' }} />
75
-
76
- {/* Logo */}
77
- <div className="relative flex justify-center">
78
- <img
79
- src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
80
- alt={uiConfig().brand.name}
81
- style={{ height: '160px', objectFit: 'contain' }}
82
- />
83
- </div>
84
-
85
- {/* Headline */}
86
- <div className="relative space-y-3">
87
- <p className="font-label text-xs uppercase tracking-widest" style={{ color: 'var(--m3-secondary)' }}>
88
- First-time setup
89
- </p>
90
- <h2 className="font-headline font-extrabold text-4xl leading-[1.1] tracking-tight" style={{ color: 'var(--m3-on-surface)' }}>
91
- Set up your<br />
92
- <span style={{ color: 'var(--m3-primary)' }}>instance.</span>
93
- </h2>
94
- <p className="text-sm font-sans leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
95
- Configure your database and create your administrator account to get started.
96
- </p>
97
- </div>
98
-
99
- {/* Progress steps */}
100
- <ol className="relative flex flex-col gap-0" aria-label="Setup progress">
101
- {STEPS.map((s, i) => {
102
- const isDone = step > s.n
103
- const isActive = step === s.n
104
- const isPending = step < s.n
105
-
106
- return (
107
- <li key={s.n} aria-current={isActive ? 'step' : undefined}>
108
- <div className="flex items-center gap-4">
109
- {/* Circle */}
110
- <div
111
- className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-sm font-bold transition-all duration-300"
112
- style={{
113
- background: isDone
114
- ? 'var(--m3-primary)'
115
- : isActive
116
- ? 'var(--m3-primary)'
117
- : 'transparent',
118
- border: isPending
119
- ? '2px solid var(--m3-outline-variant)'
120
- : '2px solid var(--m3-primary)',
121
- color: isDone || isActive
122
- ? 'var(--m3-on-primary)'
123
- : 'var(--m3-secondary)',
124
- }}
125
- >
126
- {isDone ? (
127
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
128
- <path d="M2.5 7L5.5 10L11.5 4" stroke="var(--m3-on-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
129
- </svg>
130
- ) : s.n}
131
- </div>
132
-
133
- {/* Label */}
134
- <span
135
- className="font-sans text-sm transition-all duration-300"
136
- style={{
137
- fontWeight: isActive ? 700 : 400,
138
- color: isActive
139
- ? 'var(--m3-on-surface)'
140
- : isDone
141
- ? 'var(--m3-primary)'
142
- : 'var(--m3-secondary)',
143
- }}
144
- >
145
- {s.label}
146
- </span>
147
- </div>
148
-
149
- {/* Connector */}
150
- {i < STEPS.length - 1 && (
151
- <div className="ml-4 w-px h-6 my-1" aria-hidden="true"
152
- style={{ background: step > s.n ? 'var(--m3-primary)' : 'var(--m3-outline-variant)' }} />
153
- )}
154
- </li>
155
- )
156
- })}
157
- </ol>
158
- </div>
159
-
160
- {/* ── Right panel ── */}
161
- <div className="flex-1 relative flex items-center justify-center px-6 py-12">
162
- {/* Dark mode toggle */}
163
- <button
164
- type="button"
165
- onClick={toggleDark}
166
- className="btn-icon absolute top-5 right-5 w-9 h-9 rounded-full"
167
- aria-label="Toggle dark mode"
168
- >
169
- <span className="material-symbols-outlined" aria-hidden="true">
170
- {isDark ? 'light_mode' : 'dark_mode'}
171
- </span>
172
- </button>
173
-
174
- <div className="w-full max-w-md">
175
-
176
- {/* Mobile logo */}
177
- <div className="flex items-center mb-10 lg:hidden">
178
- <img
179
- src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
180
- alt={uiConfig().brand.name}
181
- style={{ height: '32px', objectFit: 'contain' }}
182
- />
183
- </div>
184
-
185
- {/* ── STEP 1: Database ── */}
186
- {step === 1 && (
187
- <div>
188
- <div className="mb-8">
189
- <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 1 of 3</p>
190
- <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>
191
- Choose a database
192
- </h1>
193
- <p className="text-sm font-sans mt-1.5" style={{ color: 'var(--m3-secondary)' }}>
194
- Select where your data will be stored.
195
- </p>
196
- </div>
197
-
198
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
199
- {/* SQLite card — always selected */}
200
- <div
201
- className="relative rounded-2xl p-5 cursor-pointer transition-all duration-200"
202
- style={{
203
- background: 'var(--m3-surface-container-low)',
204
- border: '2px solid var(--m3-primary)',
205
- boxShadow: '0 0 0 4px color-mix(in srgb, var(--m3-primary) 12%, transparent)',
206
- }}
207
- >
208
- {/* Selected checkmark */}
209
- <div
210
- className="absolute top-3 right-3 w-5 h-5 rounded-full flex items-center justify-center"
211
- style={{ background: 'var(--m3-primary)' }}
212
- >
213
- <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
214
- <path d="M2 5.5L4.5 8L9 3" stroke="var(--m3-on-primary)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
215
- </svg>
216
- </div>
217
-
218
- {/* DB icon */}
219
- <div className="mb-4">
220
- <svg width="36" height="36" viewBox="0 0 36 36" fill="none">
221
- <ellipse cx="18" cy="10" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-primary) 20%, transparent)" stroke="var(--m3-primary)" strokeWidth="1.5" />
222
- <path d="M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-primary)" strokeWidth="1.5" fill="none" />
223
- <path d="M6 18v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-primary)" strokeWidth="1.5" fill="none" />
224
- <ellipse cx="18" cy="26" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-primary) 10%, transparent)" />
225
- </svg>
226
- </div>
227
-
228
- <h3 className="font-headline font-bold text-base mb-1" style={{ color: 'var(--m3-on-surface)' }}>SQLite</h3>
229
- <p className="font-sans text-xs leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
230
- Built-in · No configuration needed
231
- </p>
232
- </div>
233
-
234
- {/* PostgreSQL card — disabled */}
235
- <div
236
- className="relative rounded-2xl p-5"
237
- style={{
238
- background: 'var(--m3-surface-container-low)',
239
- border: '2px solid var(--m3-outline-variant)',
240
- opacity: 0.45,
241
- cursor: 'not-allowed',
242
- }}
243
- onMouseEnter={() => setPgTooltip(true)}
244
- onMouseLeave={() => setPgTooltip(false)}
245
- >
246
- {/* Soon badge */}
247
- <div
248
- className="absolute top-3 right-3 px-2 py-0.5 rounded-full font-mono text-[10px] font-bold uppercase tracking-wider"
249
- style={{ background: 'var(--m3-surface-container-highest)', color: 'var(--m3-secondary)' }}
250
- >
251
- Soon
252
- </div>
253
-
254
- {/* DB icon */}
255
- <div className="mb-4">
256
- <svg width="36" height="36" viewBox="0 0 36 36" fill="none">
257
- <ellipse cx="18" cy="10" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-secondary) 15%, transparent)" stroke="var(--m3-secondary)" strokeWidth="1.5" />
258
- <path d="M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-secondary)" strokeWidth="1.5" fill="none" />
259
- <path d="M6 18v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-secondary)" strokeWidth="1.5" fill="none" />
260
- <ellipse cx="18" cy="26" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-secondary) 8%, transparent)" />
261
- </svg>
262
- </div>
263
-
264
- <h3 className="font-headline font-bold text-base mb-1" style={{ color: 'var(--m3-on-surface)' }}>PostgreSQL</h3>
265
- <p className="font-sans text-xs leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
266
- External database
267
- </p>
268
-
269
- {/* Tooltip */}
270
- {pgTooltip && (
271
- <div
272
- className="absolute -top-9 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg text-xs font-sans font-medium whitespace-nowrap pointer-events-none z-10"
273
- style={{
274
- background: 'var(--m3-on-surface)',
275
- color: 'var(--m3-surface)',
276
- opacity: 1,
277
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
278
- }}
279
- >
280
- Coming soon
281
- <div
282
- className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0"
283
- style={{ borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderTop: '5px solid var(--m3-on-surface)' }}
284
- />
285
- </div>
286
- )}
287
- </div>
288
- </div>
289
-
290
- <button type="button" onClick={() => setStep(2)} className="btn btn-primary w-full py-3">
291
- Continue
292
- </button>
293
- </div>
294
- )}
295
-
296
- {/* ── STEP 2: Admin account ── */}
297
- {step === 2 && (
298
- <div>
299
- <div className="mb-8">
300
- <button
301
- type="button"
302
- onClick={() => { setError(''); setStep(1) }}
303
- className="btn btn-ghost btn-sm -ml-3 mb-4"
304
- >
305
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
306
- <path d="M9 2L4 7l5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
307
- </svg>
308
- Back
309
- </button>
310
- <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 2 of 3</p>
311
- <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>
312
- Create admin account
313
- </h1>
314
- <p className="text-sm font-sans mt-1.5" style={{ color: 'var(--m3-secondary)' }}>
315
- This will be the primary administrator of your instance.
316
- </p>
317
- </div>
318
-
319
- <form onSubmit={handleSubmit} className="space-y-4">
320
- {error && <Alert tone="error">{error}</Alert>}
321
-
322
- <div>
323
- <label htmlFor="setup-email" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
324
- Email
325
- </label>
326
- <input
327
- id="setup-email"
328
- type="email"
329
- autoComplete="username"
330
- value={email}
331
- onChange={(e) => setEmail(e.target.value)}
332
- required
333
- className="input-m3"
334
- placeholder="admin@example.com"
335
- autoFocus
336
- />
337
- </div>
338
-
339
- <div>
340
- <label htmlFor="setup-password" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
341
- Password
342
- </label>
343
- <input
344
- id="setup-password"
345
- type="password"
346
- autoComplete="new-password"
347
- value={password}
348
- onChange={(e) => setPassword(e.target.value)}
349
- required
350
- className="input-m3"
351
- placeholder="Min. 8 characters"
352
- />
353
- </div>
354
-
355
- <div>
356
- <label htmlFor="setup-confirm-password" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
357
- Confirm Password
358
- </label>
359
- <input
360
- id="setup-confirm-password"
361
- type="password"
362
- autoComplete="new-password"
363
- value={confirm}
364
- onChange={(e) => setConfirm(e.target.value)}
365
- required
366
- className="input-m3"
367
- placeholder="Repeat password"
368
- />
369
- </div>
370
-
371
- <button type="submit" disabled={loading} className="btn btn-primary w-full py-3 mt-2">
372
- {loading ? 'Creating account…' : 'Create Account'}
373
- </button>
374
- </form>
375
- </div>
376
- )}
377
-
378
- {/* ── STEP 3: Done ── */}
379
- {step === 3 && (
380
- <div className="text-center">
381
- {/* Success ring */}
382
- <div className="flex justify-center mb-8">
383
- <div
384
- className="w-20 h-20 rounded-full flex items-center justify-center"
385
- style={{
386
- background: 'color-mix(in srgb, var(--m3-primary) 12%, transparent)',
387
- border: '2px solid color-mix(in srgb, var(--m3-primary) 40%, transparent)',
388
- }}
389
- >
390
- <svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
391
- <path d="M8 18L15 25L28 11" stroke="var(--m3-primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
392
- </svg>
393
- </div>
394
- </div>
395
-
396
- <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 3 of 3</p>
397
- <h1 className="font-headline font-bold text-2xl mb-3" style={{ color: 'var(--m3-on-surface)' }}>
398
- You're all set.
399
- </h1>
400
- <p className="font-sans text-sm leading-relaxed mb-2" style={{ color: 'var(--m3-secondary)' }}>
401
- Your {uiConfig().brand.name} instance is ready.
402
- </p>
403
- <p className="font-mono text-xs mb-10" style={{ color: 'var(--m3-secondary)' }}>
404
- {email}
405
- </p>
406
-
407
- {/* Summary */}
408
- <div
409
- className="rounded-2xl p-5 mb-8 text-left space-y-3"
410
- style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
411
- >
412
- {[
413
- { icon: 'M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8', label: 'Database', value: 'SQLite' },
414
- { icon: 'M18 4a7 7 0 100 14A7 7 0 0018 4zm-2 18h4m-2-4v4', label: 'Admin account', value: email },
415
- { icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5', label: 'Role', value: 'Administrator' },
416
- ].map((row) => (
417
- <div key={row.label} className="flex items-center justify-between gap-4">
418
- <span className="font-sans text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>
419
- {row.label}
420
- </span>
421
- <span className="font-mono text-xs" style={{ color: 'var(--m3-on-surface)' }}>
422
- {row.value}
423
- </span>
424
- </div>
425
- ))}
426
- </div>
427
-
428
- <button type="button" onClick={() => navigate(`${uiConfig().basePath}/`)} className="btn btn-primary w-full py-3">
429
- Go to Dashboard
430
- </button>
431
- </div>
432
- )}
433
-
434
- </div>
435
- </div>
436
- </div>
437
- )
438
- }
1
+ import { useState, useEffect } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { api, isAuthenticated, setSession, type AuthUser } from '../api/client'
4
+ import { uiConfig } from '../config'
5
+ import { useDarkMode } from '../hooks/useDarkMode'
6
+ import { Alert } from '../components/ui'
7
+
8
+ const STEPS = [
9
+ { n: 1, label: 'Database' },
10
+ { n: 2, label: 'Admin Account' },
11
+ { n: 3, label: 'Done' },
12
+ ]
13
+
14
+ const LABEL_CLASS = 'block text-xs font-sans font-semibold mb-1.5 uppercase tracking-wider'
15
+
16
+ export default function SetupPage() {
17
+ const navigate = useNavigate()
18
+ const [isDark, toggleDark] = useDarkMode()
19
+ const [step, setStep] = useState<1 | 2 | 3>(1)
20
+ const [checking, setChecking] = useState(true)
21
+
22
+ useEffect(() => {
23
+ fetch('/api/v1/setup/status')
24
+ .then((r) => r.json())
25
+ .then((data: { needsSetup: boolean }) => {
26
+ if (!data.needsSetup) navigate(isAuthenticated() ? `${uiConfig().basePath}/` : `${uiConfig().basePath}/login`, { replace: true })
27
+ else setChecking(false)
28
+ })
29
+ .catch(() => setChecking(false))
30
+ }, [navigate])
31
+
32
+ // Step 2 fields
33
+ const [email, setEmail] = useState('')
34
+ const [password, setPassword] = useState('')
35
+ const [confirm, setConfirm] = useState('')
36
+ const [error, setError] = useState('')
37
+ const [loading, setLoading] = useState(false)
38
+ const [pgTooltip, setPgTooltip] = useState(false)
39
+
40
+ async function handleSubmit(e: React.FormEvent) {
41
+ e.preventDefault()
42
+ setError('')
43
+ if (password !== confirm) { setError('Passwords do not match'); return }
44
+ if (password.length < 8) { setError('Password must be at least 8 characters'); return }
45
+ setLoading(true)
46
+ try {
47
+ const res = await api.post<AuthUser>('/setup/complete', { email, password })
48
+ setSession(res)
49
+ setStep(3)
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : 'Setup failed')
52
+ } finally {
53
+ setLoading(false)
54
+ }
55
+ }
56
+
57
+ if (checking) return null
58
+
59
+ return (
60
+ <div className="min-h-screen flex" style={{ background: 'var(--m3-surface)' }}>
61
+
62
+ {/* ── Left branding panel ── */}
63
+ <div
64
+ className="hidden lg:flex flex-col justify-between w-[400px] flex-shrink-0 p-12 relative overflow-hidden"
65
+ style={{
66
+ background: 'var(--m3-surface-container-low)',
67
+ borderRight: '1px solid var(--m3-outline-variant)',
68
+ }}
69
+ >
70
+ {/* Decorative blobs */}
71
+ <div className="absolute -top-32 -left-32 w-80 h-80 rounded-full pointer-events-none"
72
+ style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 12%, transparent) 0%, transparent 70%)' }} />
73
+ <div className="absolute bottom-0 right-0 w-96 h-96 rounded-full pointer-events-none"
74
+ style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 6%, transparent) 0%, transparent 70%)' }} />
75
+
76
+ {/* Logo */}
77
+ <div className="relative flex justify-center">
78
+ <img
79
+ src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
80
+ alt={uiConfig().brand.name}
81
+ style={{ height: '160px', objectFit: 'contain' }}
82
+ />
83
+ </div>
84
+
85
+ {/* Headline */}
86
+ <div className="relative space-y-3">
87
+ <p className="font-label text-xs uppercase tracking-widest" style={{ color: 'var(--m3-secondary)' }}>
88
+ First-time setup
89
+ </p>
90
+ <h2 className="font-headline font-extrabold text-4xl leading-[1.1] tracking-tight" style={{ color: 'var(--m3-on-surface)' }}>
91
+ Set up your<br />
92
+ <span style={{ color: 'var(--m3-primary)' }}>instance.</span>
93
+ </h2>
94
+ <p className="text-sm font-sans leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
95
+ Configure your database and create your administrator account to get started.
96
+ </p>
97
+ </div>
98
+
99
+ {/* Progress steps */}
100
+ <ol className="relative flex flex-col gap-0" aria-label="Setup progress">
101
+ {STEPS.map((s, i) => {
102
+ const isDone = step > s.n
103
+ const isActive = step === s.n
104
+ const isPending = step < s.n
105
+
106
+ return (
107
+ <li key={s.n} aria-current={isActive ? 'step' : undefined}>
108
+ <div className="flex items-center gap-4">
109
+ {/* Circle */}
110
+ <div
111
+ className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-sm font-bold transition-all duration-300"
112
+ style={{
113
+ background: isDone
114
+ ? 'var(--m3-primary)'
115
+ : isActive
116
+ ? 'var(--m3-primary)'
117
+ : 'transparent',
118
+ border: isPending
119
+ ? '2px solid var(--m3-outline-variant)'
120
+ : '2px solid var(--m3-primary)',
121
+ color: isDone || isActive
122
+ ? 'var(--m3-on-primary)'
123
+ : 'var(--m3-secondary)',
124
+ }}
125
+ >
126
+ {isDone ? (
127
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
128
+ <path d="M2.5 7L5.5 10L11.5 4" stroke="var(--m3-on-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
129
+ </svg>
130
+ ) : s.n}
131
+ </div>
132
+
133
+ {/* Label */}
134
+ <span
135
+ className="font-sans text-sm transition-all duration-300"
136
+ style={{
137
+ fontWeight: isActive ? 700 : 400,
138
+ color: isActive
139
+ ? 'var(--m3-on-surface)'
140
+ : isDone
141
+ ? 'var(--m3-primary)'
142
+ : 'var(--m3-secondary)',
143
+ }}
144
+ >
145
+ {s.label}
146
+ </span>
147
+ </div>
148
+
149
+ {/* Connector */}
150
+ {i < STEPS.length - 1 && (
151
+ <div className="ml-4 w-px h-6 my-1" aria-hidden="true"
152
+ style={{ background: step > s.n ? 'var(--m3-primary)' : 'var(--m3-outline-variant)' }} />
153
+ )}
154
+ </li>
155
+ )
156
+ })}
157
+ </ol>
158
+ </div>
159
+
160
+ {/* ── Right panel ── */}
161
+ <div className="flex-1 relative flex items-center justify-center px-6 py-12">
162
+ {/* Dark mode toggle */}
163
+ <button
164
+ type="button"
165
+ onClick={toggleDark}
166
+ className="btn-icon absolute top-5 right-5 w-9 h-9 rounded-full"
167
+ aria-label="Toggle dark mode"
168
+ >
169
+ <span className="material-symbols-outlined" aria-hidden="true">
170
+ {isDark ? 'light_mode' : 'dark_mode'}
171
+ </span>
172
+ </button>
173
+
174
+ <div className="w-full max-w-md">
175
+
176
+ {/* Mobile logo */}
177
+ <div className="flex items-center mb-10 lg:hidden">
178
+ <img
179
+ src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
180
+ alt={uiConfig().brand.name}
181
+ style={{ height: '32px', objectFit: 'contain' }}
182
+ />
183
+ </div>
184
+
185
+ {/* ── STEP 1: Database ── */}
186
+ {step === 1 && (
187
+ <div>
188
+ <div className="mb-8">
189
+ <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 1 of 3</p>
190
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>
191
+ Choose a database
192
+ </h1>
193
+ <p className="text-sm font-sans mt-1.5" style={{ color: 'var(--m3-secondary)' }}>
194
+ Select where your data will be stored.
195
+ </p>
196
+ </div>
197
+
198
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-8">
199
+ {/* SQLite card — always selected */}
200
+ <div
201
+ className="relative rounded-2xl p-5 cursor-pointer transition-all duration-200"
202
+ style={{
203
+ background: 'var(--m3-surface-container-low)',
204
+ border: '2px solid var(--m3-primary)',
205
+ boxShadow: '0 0 0 4px color-mix(in srgb, var(--m3-primary) 12%, transparent)',
206
+ }}
207
+ >
208
+ {/* Selected checkmark */}
209
+ <div
210
+ className="absolute top-3 right-3 w-5 h-5 rounded-full flex items-center justify-center"
211
+ style={{ background: 'var(--m3-primary)' }}
212
+ >
213
+ <svg width="11" height="11" viewBox="0 0 11 11" fill="none">
214
+ <path d="M2 5.5L4.5 8L9 3" stroke="var(--m3-on-primary)" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
215
+ </svg>
216
+ </div>
217
+
218
+ {/* DB icon */}
219
+ <div className="mb-4">
220
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none">
221
+ <ellipse cx="18" cy="10" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-primary) 20%, transparent)" stroke="var(--m3-primary)" strokeWidth="1.5" />
222
+ <path d="M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-primary)" strokeWidth="1.5" fill="none" />
223
+ <path d="M6 18v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-primary)" strokeWidth="1.5" fill="none" />
224
+ <ellipse cx="18" cy="26" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-primary) 10%, transparent)" />
225
+ </svg>
226
+ </div>
227
+
228
+ <h3 className="font-headline font-bold text-base mb-1" style={{ color: 'var(--m3-on-surface)' }}>SQLite</h3>
229
+ <p className="font-sans text-xs leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
230
+ Built-in · No configuration needed
231
+ </p>
232
+ </div>
233
+
234
+ {/* PostgreSQL card — disabled */}
235
+ <div
236
+ className="relative rounded-2xl p-5"
237
+ style={{
238
+ background: 'var(--m3-surface-container-low)',
239
+ border: '2px solid var(--m3-outline-variant)',
240
+ opacity: 0.45,
241
+ cursor: 'not-allowed',
242
+ }}
243
+ onMouseEnter={() => setPgTooltip(true)}
244
+ onMouseLeave={() => setPgTooltip(false)}
245
+ >
246
+ {/* Soon badge */}
247
+ <div
248
+ className="absolute top-3 right-3 px-2 py-0.5 rounded-full font-mono text-[10px] font-bold uppercase tracking-wider"
249
+ style={{ background: 'var(--m3-surface-container-highest)', color: 'var(--m3-secondary)' }}
250
+ >
251
+ Soon
252
+ </div>
253
+
254
+ {/* DB icon */}
255
+ <div className="mb-4">
256
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none">
257
+ <ellipse cx="18" cy="10" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-secondary) 15%, transparent)" stroke="var(--m3-secondary)" strokeWidth="1.5" />
258
+ <path d="M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-secondary)" strokeWidth="1.5" fill="none" />
259
+ <path d="M6 18v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8" stroke="var(--m3-secondary)" strokeWidth="1.5" fill="none" />
260
+ <ellipse cx="18" cy="26" rx="12" ry="5" fill="color-mix(in srgb, var(--m3-secondary) 8%, transparent)" />
261
+ </svg>
262
+ </div>
263
+
264
+ <h3 className="font-headline font-bold text-base mb-1" style={{ color: 'var(--m3-on-surface)' }}>PostgreSQL</h3>
265
+ <p className="font-sans text-xs leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
266
+ External database
267
+ </p>
268
+
269
+ {/* Tooltip */}
270
+ {pgTooltip && (
271
+ <div
272
+ className="absolute -top-9 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg text-xs font-sans font-medium whitespace-nowrap pointer-events-none z-10"
273
+ style={{
274
+ background: 'var(--m3-on-surface)',
275
+ color: 'var(--m3-surface)',
276
+ opacity: 1,
277
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
278
+ }}
279
+ >
280
+ Coming soon
281
+ <div
282
+ className="absolute top-full left-1/2 -translate-x-1/2 w-0 h-0"
283
+ style={{ borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderTop: '5px solid var(--m3-on-surface)' }}
284
+ />
285
+ </div>
286
+ )}
287
+ </div>
288
+ </div>
289
+
290
+ <button type="button" onClick={() => setStep(2)} className="btn btn-primary w-full py-3">
291
+ Continue
292
+ </button>
293
+ </div>
294
+ )}
295
+
296
+ {/* ── STEP 2: Admin account ── */}
297
+ {step === 2 && (
298
+ <div>
299
+ <div className="mb-8">
300
+ <button
301
+ type="button"
302
+ onClick={() => { setError(''); setStep(1) }}
303
+ className="btn btn-ghost btn-sm -ml-3 mb-4"
304
+ >
305
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
306
+ <path d="M9 2L4 7l5 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
307
+ </svg>
308
+ Back
309
+ </button>
310
+ <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 2 of 3</p>
311
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>
312
+ Create admin account
313
+ </h1>
314
+ <p className="text-sm font-sans mt-1.5" style={{ color: 'var(--m3-secondary)' }}>
315
+ This will be the primary administrator of your instance.
316
+ </p>
317
+ </div>
318
+
319
+ <form onSubmit={handleSubmit} className="space-y-4">
320
+ {error && <Alert tone="error">{error}</Alert>}
321
+
322
+ <div>
323
+ <label htmlFor="setup-email" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
324
+ Email
325
+ </label>
326
+ <input
327
+ id="setup-email"
328
+ type="email"
329
+ autoComplete="username"
330
+ value={email}
331
+ onChange={(e) => setEmail(e.target.value)}
332
+ required
333
+ className="input-m3"
334
+ placeholder="admin@example.com"
335
+ autoFocus
336
+ />
337
+ </div>
338
+
339
+ <div>
340
+ <label htmlFor="setup-password" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
341
+ Password
342
+ </label>
343
+ <input
344
+ id="setup-password"
345
+ type="password"
346
+ autoComplete="new-password"
347
+ value={password}
348
+ onChange={(e) => setPassword(e.target.value)}
349
+ required
350
+ className="input-m3"
351
+ placeholder="Min. 8 characters"
352
+ />
353
+ </div>
354
+
355
+ <div>
356
+ <label htmlFor="setup-confirm-password" className={LABEL_CLASS} style={{ color: 'var(--m3-secondary)' }}>
357
+ Confirm Password
358
+ </label>
359
+ <input
360
+ id="setup-confirm-password"
361
+ type="password"
362
+ autoComplete="new-password"
363
+ value={confirm}
364
+ onChange={(e) => setConfirm(e.target.value)}
365
+ required
366
+ className="input-m3"
367
+ placeholder="Repeat password"
368
+ />
369
+ </div>
370
+
371
+ <button type="submit" disabled={loading} className="btn btn-primary w-full py-3 mt-2">
372
+ {loading ? 'Creating account…' : 'Create Account'}
373
+ </button>
374
+ </form>
375
+ </div>
376
+ )}
377
+
378
+ {/* ── STEP 3: Done ── */}
379
+ {step === 3 && (
380
+ <div className="text-center">
381
+ {/* Success ring */}
382
+ <div className="flex justify-center mb-8">
383
+ <div
384
+ className="w-20 h-20 rounded-full flex items-center justify-center"
385
+ style={{
386
+ background: 'color-mix(in srgb, var(--m3-primary) 12%, transparent)',
387
+ border: '2px solid color-mix(in srgb, var(--m3-primary) 40%, transparent)',
388
+ }}
389
+ >
390
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" aria-hidden="true">
391
+ <path d="M8 18L15 25L28 11" stroke="var(--m3-primary)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
392
+ </svg>
393
+ </div>
394
+ </div>
395
+
396
+ <p className="font-label text-xs uppercase tracking-widest mb-1" style={{ color: 'var(--m3-secondary)' }}>Step 3 of 3</p>
397
+ <h1 className="font-headline font-bold text-2xl mb-3" style={{ color: 'var(--m3-on-surface)' }}>
398
+ You're all set.
399
+ </h1>
400
+ <p className="font-sans text-sm leading-relaxed mb-2" style={{ color: 'var(--m3-secondary)' }}>
401
+ Your {uiConfig().brand.name} instance is ready.
402
+ </p>
403
+ <p className="font-mono text-xs mb-10" style={{ color: 'var(--m3-secondary)' }}>
404
+ {email}
405
+ </p>
406
+
407
+ {/* Summary */}
408
+ <div
409
+ className="rounded-2xl p-5 mb-8 text-left space-y-3"
410
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
411
+ >
412
+ {[
413
+ { icon: 'M6 10v8c0 2.76 5.37 5 12 5s12-2.24 12-5v-8', label: 'Database', value: 'SQLite' },
414
+ { icon: 'M18 4a7 7 0 100 14A7 7 0 0018 4zm-2 18h4m-2-4v4', label: 'Admin account', value: email },
415
+ { icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5', label: 'Role', value: 'Administrator' },
416
+ ].map((row) => (
417
+ <div key={row.label} className="flex items-center justify-between gap-4">
418
+ <span className="font-sans text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>
419
+ {row.label}
420
+ </span>
421
+ <span className="font-mono text-xs" style={{ color: 'var(--m3-on-surface)' }}>
422
+ {row.value}
423
+ </span>
424
+ </div>
425
+ ))}
426
+ </div>
427
+
428
+ <button type="button" onClick={() => navigate(`${uiConfig().basePath}/`)} className="btn btn-primary w-full py-3">
429
+ Go to Dashboard
430
+ </button>
431
+ </div>
432
+ )}
433
+
434
+ </div>
435
+ </div>
436
+ </div>
437
+ )
438
+ }