@better-forge/ui 0.0.0-stage → 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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -2
  3. package/dist/api/client.d.ts +42 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +130 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/components/ConfirmIdentityModal.d.ts +18 -0
  8. package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
  9. package/dist/components/ConfirmIdentityModal.js +30 -0
  10. package/dist/components/ConfirmIdentityModal.js.map +1 -0
  11. package/dist/components/ConfirmModal.d.ts +18 -0
  12. package/dist/components/ConfirmModal.d.ts.map +1 -0
  13. package/dist/components/ConfirmModal.js +11 -0
  14. package/dist/components/ConfirmModal.js.map +1 -0
  15. package/dist/components/CopyButton.d.ts +11 -0
  16. package/dist/components/CopyButton.d.ts.map +1 -0
  17. package/dist/components/CopyButton.js +44 -0
  18. package/dist/components/CopyButton.js.map +1 -0
  19. package/dist/components/CredentialSection.d.ts +38 -0
  20. package/dist/components/CredentialSection.d.ts.map +1 -0
  21. package/dist/components/CredentialSection.js +69 -0
  22. package/dist/components/CredentialSection.js.map +1 -0
  23. package/dist/components/ErrorBoundary.d.ts +18 -0
  24. package/dist/components/ErrorBoundary.d.ts.map +1 -0
  25. package/dist/components/ErrorBoundary.js +25 -0
  26. package/dist/components/ErrorBoundary.js.map +1 -0
  27. package/dist/components/ModalShell.d.ts +58 -0
  28. package/dist/components/ModalShell.d.ts.map +1 -0
  29. package/dist/components/ModalShell.js +92 -0
  30. package/dist/components/ModalShell.js.map +1 -0
  31. package/dist/components/ResetTwoFactorModal.d.ts +14 -0
  32. package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
  33. package/dist/components/ResetTwoFactorModal.js +16 -0
  34. package/dist/components/ResetTwoFactorModal.js.map +1 -0
  35. package/dist/components/SidePanel.d.ts +35 -0
  36. package/dist/components/SidePanel.d.ts.map +1 -0
  37. package/dist/components/SidePanel.js +19 -0
  38. package/dist/components/SidePanel.js.map +1 -0
  39. package/dist/components/SingleSignOnModal.d.ts +4 -0
  40. package/dist/components/SingleSignOnModal.d.ts.map +1 -0
  41. package/dist/components/SingleSignOnModal.js +64 -0
  42. package/dist/components/SingleSignOnModal.js.map +1 -0
  43. package/dist/components/SsoConfirmation.d.ts +22 -0
  44. package/dist/components/SsoConfirmation.d.ts.map +1 -0
  45. package/dist/components/SsoConfirmation.js +87 -0
  46. package/dist/components/SsoConfirmation.js.map +1 -0
  47. package/dist/components/VaultTypePicker.d.ts +13 -0
  48. package/dist/components/VaultTypePicker.d.ts.map +1 -0
  49. package/dist/components/VaultTypePicker.js +62 -0
  50. package/dist/components/VaultTypePicker.js.map +1 -0
  51. package/dist/components/WhatsNew.d.ts +18 -0
  52. package/dist/components/WhatsNew.d.ts.map +1 -0
  53. package/dist/components/WhatsNew.js +50 -0
  54. package/dist/components/WhatsNew.js.map +1 -0
  55. package/dist/components/formParts.d.ts +40 -0
  56. package/dist/components/formParts.d.ts.map +1 -0
  57. package/dist/components/formParts.js +31 -0
  58. package/dist/components/formParts.js.map +1 -0
  59. package/dist/components/ui/Alert.d.ts +15 -0
  60. package/dist/components/ui/Alert.d.ts.map +1 -0
  61. package/dist/components/ui/Alert.js +14 -0
  62. package/dist/components/ui/Alert.js.map +1 -0
  63. package/dist/components/ui/DataTable.d.ts +52 -0
  64. package/dist/components/ui/DataTable.d.ts.map +1 -0
  65. package/dist/components/ui/DataTable.js +112 -0
  66. package/dist/components/ui/DataTable.js.map +1 -0
  67. package/dist/components/ui/Field.d.ts +27 -0
  68. package/dist/components/ui/Field.d.ts.map +1 -0
  69. package/dist/components/ui/Field.js +25 -0
  70. package/dist/components/ui/Field.js.map +1 -0
  71. package/dist/components/ui/PageHeader.d.ts +18 -0
  72. package/dist/components/ui/PageHeader.d.ts.map +1 -0
  73. package/dist/components/ui/PageHeader.js +10 -0
  74. package/dist/components/ui/PageHeader.js.map +1 -0
  75. package/dist/components/ui/Pagination.d.ts +21 -0
  76. package/dist/components/ui/Pagination.d.ts.map +1 -0
  77. package/dist/components/ui/Pagination.js +10 -0
  78. package/dist/components/ui/Pagination.js.map +1 -0
  79. package/dist/components/ui/QuickStartPanel.d.ts +21 -0
  80. package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
  81. package/dist/components/ui/QuickStartPanel.js +11 -0
  82. package/dist/components/ui/QuickStartPanel.js.map +1 -0
  83. package/dist/components/ui/StateViews.d.ts +50 -0
  84. package/dist/components/ui/StateViews.d.ts.map +1 -0
  85. package/dist/components/ui/StateViews.js +33 -0
  86. package/dist/components/ui/StateViews.js.map +1 -0
  87. package/dist/components/ui/Switch.d.ts +16 -0
  88. package/dist/components/ui/Switch.d.ts.map +1 -0
  89. package/dist/components/ui/Switch.js +15 -0
  90. package/dist/components/ui/Switch.js.map +1 -0
  91. package/dist/components/ui/Toast.d.ts +20 -0
  92. package/dist/components/ui/Toast.d.ts.map +1 -0
  93. package/dist/components/ui/Toast.js +46 -0
  94. package/dist/components/ui/Toast.js.map +1 -0
  95. package/dist/components/ui/index.d.ts +10 -0
  96. package/dist/components/ui/index.d.ts.map +1 -0
  97. package/dist/components/ui/index.js +10 -0
  98. package/dist/components/ui/index.js.map +1 -0
  99. package/dist/config.d.ts +46 -0
  100. package/dist/config.d.ts.map +1 -0
  101. package/dist/config.js +12 -0
  102. package/dist/config.js.map +1 -0
  103. package/dist/hooks/useDarkMode.d.ts +2 -0
  104. package/dist/hooks/useDarkMode.d.ts.map +1 -0
  105. package/dist/hooks/useDarkMode.js +37 -0
  106. package/dist/hooks/useDarkMode.js.map +1 -0
  107. package/dist/hooks/useDebouncedValue.d.ts +6 -0
  108. package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
  109. package/dist/hooks/useDebouncedValue.js +19 -0
  110. package/dist/hooks/useDebouncedValue.js.map +1 -0
  111. package/dist/index.d.ts +36 -0
  112. package/dist/index.d.ts.map +1 -0
  113. package/dist/index.js +36 -0
  114. package/dist/index.js.map +1 -0
  115. package/dist/lib/changelog.d.ts +32 -0
  116. package/dist/lib/changelog.d.ts.map +1 -0
  117. package/dist/lib/changelog.js +71 -0
  118. package/dist/lib/changelog.js.map +1 -0
  119. package/dist/lib/dateFormat.d.ts +24 -0
  120. package/dist/lib/dateFormat.d.ts.map +1 -0
  121. package/dist/lib/dateFormat.js +67 -0
  122. package/dist/lib/dateFormat.js.map +1 -0
  123. package/dist/navigation.d.ts +5 -0
  124. package/dist/navigation.d.ts.map +1 -0
  125. package/dist/navigation.js +7 -0
  126. package/dist/navigation.js.map +1 -0
  127. package/dist/pages/ApiTokens.d.ts +9 -0
  128. package/dist/pages/ApiTokens.d.ts.map +1 -0
  129. package/dist/pages/ApiTokens.js +116 -0
  130. package/dist/pages/ApiTokens.js.map +1 -0
  131. package/dist/pages/AuditLog.d.ts +14 -0
  132. package/dist/pages/AuditLog.d.ts.map +1 -0
  133. package/dist/pages/AuditLog.js +104 -0
  134. package/dist/pages/AuditLog.js.map +1 -0
  135. package/dist/pages/Backups.d.ts +2 -0
  136. package/dist/pages/Backups.d.ts.map +1 -0
  137. package/dist/pages/Backups.js +118 -0
  138. package/dist/pages/Backups.js.map +1 -0
  139. package/dist/pages/ChangePassword.d.ts +2 -0
  140. package/dist/pages/ChangePassword.d.ts.map +1 -0
  141. package/dist/pages/ChangePassword.js +40 -0
  142. package/dist/pages/ChangePassword.js.map +1 -0
  143. package/dist/pages/Login.d.ts +12 -0
  144. package/dist/pages/Login.d.ts.map +1 -0
  145. package/dist/pages/Login.js +69 -0
  146. package/dist/pages/Login.js.map +1 -0
  147. package/dist/pages/Settings.d.ts +2 -0
  148. package/dist/pages/Settings.d.ts.map +1 -0
  149. package/dist/pages/Settings.js +149 -0
  150. package/dist/pages/Settings.js.map +1 -0
  151. package/dist/pages/Setup.d.ts +2 -0
  152. package/dist/pages/Setup.d.ts.map +1 -0
  153. package/dist/pages/Setup.js +113 -0
  154. package/dist/pages/Setup.js.map +1 -0
  155. package/dist/pages/SsoConfirm.d.ts +3 -0
  156. package/dist/pages/SsoConfirm.d.ts.map +1 -0
  157. package/dist/pages/SsoConfirm.js +20 -0
  158. package/dist/pages/SsoConfirm.js.map +1 -0
  159. package/dist/pages/SsoTestResult.d.ts +2 -0
  160. package/dist/pages/SsoTestResult.d.ts.map +1 -0
  161. package/dist/pages/SsoTestResult.js +49 -0
  162. package/dist/pages/SsoTestResult.js.map +1 -0
  163. package/dist/pages/Users.d.ts +22 -0
  164. package/dist/pages/Users.d.ts.map +1 -0
  165. package/dist/pages/Users.js +124 -0
  166. package/dist/pages/Users.js.map +1 -0
  167. package/dist/pages/Vault.d.ts +2 -0
  168. package/dist/pages/Vault.d.ts.map +1 -0
  169. package/dist/pages/Vault.js +372 -0
  170. package/dist/pages/Vault.js.map +1 -0
  171. package/dist/shell/AdminShell.d.ts +46 -0
  172. package/dist/shell/AdminShell.d.ts.map +1 -0
  173. package/dist/shell/AdminShell.js +76 -0
  174. package/dist/shell/AdminShell.js.map +1 -0
  175. package/dist/shell/guards.d.ts +28 -0
  176. package/dist/shell/guards.d.ts.map +1 -0
  177. package/dist/shell/guards.js +71 -0
  178. package/dist/shell/guards.js.map +1 -0
  179. package/icons.json +170 -0
  180. package/package.json +69 -4
  181. package/src/api/client.ts +139 -0
  182. package/src/components/ConfirmIdentityModal.tsx +82 -0
  183. package/src/components/ConfirmModal.tsx +83 -0
  184. package/src/components/CopyButton.tsx +71 -0
  185. package/src/components/CredentialSection.tsx +270 -0
  186. package/src/components/ErrorBoundary.tsx +47 -0
  187. package/src/components/ModalShell.tsx +160 -0
  188. package/src/components/ResetTwoFactorModal.tsx +74 -0
  189. package/src/components/SidePanel.tsx +96 -0
  190. package/src/components/SingleSignOnModal.tsx +161 -0
  191. package/src/components/SsoConfirmation.tsx +96 -0
  192. package/src/components/VaultTypePicker.tsx +131 -0
  193. package/src/components/WhatsNew.tsx +84 -0
  194. package/src/components/formParts.tsx +59 -0
  195. package/src/components/ui/Alert.tsx +45 -0
  196. package/src/components/ui/DataTable.tsx +225 -0
  197. package/src/components/ui/Field.tsx +63 -0
  198. package/src/components/ui/PageHeader.tsx +36 -0
  199. package/src/components/ui/Pagination.tsx +37 -0
  200. package/src/components/ui/QuickStartPanel.tsx +57 -0
  201. package/src/components/ui/StateViews.tsx +135 -0
  202. package/src/components/ui/Switch.tsx +65 -0
  203. package/src/components/ui/Toast.tsx +104 -0
  204. package/src/components/ui/index.ts +9 -0
  205. package/src/config.ts +46 -0
  206. package/src/hooks/useDarkMode.ts +38 -0
  207. package/src/hooks/useDebouncedValue.ts +22 -0
  208. package/src/index.ts +42 -0
  209. package/src/lib/changelog.ts +93 -0
  210. package/src/lib/dateFormat.ts +90 -0
  211. package/src/navigation.ts +6 -0
  212. package/src/pages/ApiTokens.tsx +361 -0
  213. package/src/pages/AuditLog.tsx +388 -0
  214. package/src/pages/Backups.tsx +170 -0
  215. package/src/pages/ChangePassword.tsx +84 -0
  216. package/src/pages/Login.tsx +275 -0
  217. package/src/pages/Settings.tsx +264 -0
  218. package/src/pages/Setup.tsx +438 -0
  219. package/src/pages/SsoConfirm.tsx +26 -0
  220. package/src/pages/SsoTestResult.tsx +104 -0
  221. package/src/pages/Users.tsx +426 -0
  222. package/src/pages/Vault.tsx +1036 -0
  223. package/src/shell/AdminShell.tsx +256 -0
  224. package/src/shell/guards.tsx +88 -0
  225. package/styles/base.css +521 -0
  226. package/styles/fonts/inter-latin-1.woff2 +0 -0
  227. package/styles/fonts/inter-latin-ext.woff2 +0 -0
  228. package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
  229. package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
  230. package/styles/fonts/manrope-latin-5.woff2 +0 -0
  231. package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
  232. package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
  233. package/styles/icons.css +4 -0
  234. package/tailwind-preset.js +79 -0
@@ -0,0 +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
+ }
@@ -0,0 +1,26 @@
1
+ import { useEffect } from 'react'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { ssoConfirmChannel } from '../components/SsoConfirmation'
4
+
5
+ /** Where the SSO confirmation popup lands: tells the window that opened it how it went, then closes. */
6
+ export default function SsoConfirmPage() {
7
+ const [params] = useSearchParams()
8
+ const ok = params.get('status') === 'ok'
9
+
10
+ useEffect(() => {
11
+ const channel = new BroadcastChannel(ssoConfirmChannel())
12
+ channel.postMessage({ ok })
13
+ channel.close()
14
+ if (ok) window.close()
15
+ }, [ok])
16
+
17
+ return (
18
+ <div className="min-h-screen flex items-center justify-center px-6" style={{ background: 'var(--m3-surface)', color: 'var(--m3-on-surface)' }}>
19
+ <p className="text-sm text-center max-w-sm">
20
+ {ok
21
+ ? 'Confirmed. You can close this window.'
22
+ : 'The confirmation did not succeed. Close this window and try again.'}
23
+ </p>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,104 @@
1
+ import { useQuery } from '@tanstack/react-query'
2
+ import { useSearchParams } from 'react-router-dom'
3
+ import { api } from '../api/client'
4
+ import { uiConfig } from '../config'
5
+ import { Alert, ErrorState, LoadingState, PageContainer, PageHeader, DataTable, DataTh } from '../components/ui'
6
+ import { formatDateTime } from '../lib/dateFormat'
7
+
8
+ interface TestResult {
9
+ issuer: string
10
+ testedAt: number
11
+ claims: Record<string, unknown> | null
12
+ outcome: 'sign_in' | 'link' | 'provision' | 'deny'
13
+ user: string | null
14
+ denial: { code: string; reason: string; email?: string } | null
15
+ }
16
+
17
+ // Claims that decide the outcome, shown first.
18
+ const KEY_CLAIMS = ['sub', 'email', 'email_verified', 'xms_edov', 'preferred_username', 'name']
19
+
20
+ function formatClaim(value: unknown) {
21
+ return typeof value === 'string' ? value : JSON.stringify(value)
22
+ }
23
+
24
+ function Outcome({ result }: { result: TestResult }) {
25
+ if (result.outcome === 'sign_in') {
26
+ return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by their linked identity-provider account.</Alert>
27
+ }
28
+ if (result.outcome === 'link') {
29
+ return <Alert tone="success" title="Sign-in would succeed">Signs in as <strong>{result.user}</strong>, matched by email. The first real sign-in links this identity-provider account to the user.</Alert>
30
+ }
31
+ if (result.outcome === 'provision') {
32
+ return <Alert tone="success" title="Sign-in would succeed">{newAccountNote(result.user)}</Alert>
33
+ }
34
+ return (
35
+ <Alert tone="error" title={`Sign-in would be refused (${result.denial?.code})`}>
36
+ {result.denial?.reason}
37
+ </Alert>
38
+ )
39
+ }
40
+
41
+ export default function SsoTestResultPage() {
42
+ const [params] = useSearchParams()
43
+ const id = params.get('result') ?? ''
44
+ const { data, isLoading, error } = useQuery<TestResult>({
45
+ queryKey: ['sso-test', id],
46
+ queryFn: () => api.get(`/admin/oidc/test-sign-in/${encodeURIComponent(id)}`),
47
+ enabled: !!id,
48
+ retry: false,
49
+ })
50
+
51
+ const claims = data?.claims ? Object.entries(data.claims) : []
52
+ const ordered = [
53
+ ...KEY_CLAIMS.flatMap((key) => claims.filter(([name]) => name === key)),
54
+ ...claims.filter(([name]) => !KEY_CLAIMS.includes(name)).sort(([a], [b]) => a.localeCompare(b)),
55
+ ]
56
+
57
+ return (
58
+ <PageContainer className="max-w-3xl">
59
+ <PageHeader
60
+ title="Single sign-on test"
61
+ subtitle="Nobody was signed in and nothing was saved. Close this tab when you are done."
62
+ />
63
+ {!id ? <ErrorState message="No test result to show. Start a test sign-in from Users → Single sign-on." />
64
+ : isLoading ? <LoadingState label="Loading the test result…" />
65
+ : error || !data ? <ErrorState message={error instanceof Error ? error.message : 'Could not load the test result.'} />
66
+ : (
67
+ <>
68
+ <Outcome result={data} />
69
+ <p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>
70
+ Issuer <code>{data.issuer}</code> · tested {formatDateTime(data.testedAt)}
71
+ </p>
72
+ {ordered.length > 0 && (
73
+ <div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
74
+ <DataTable className="w-full text-sm">
75
+ <caption className="sr-only">ID token claims</caption>
76
+ <thead>
77
+ <tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
78
+ {['Claim', 'Value'].map((h) => (
79
+ <DataTh key={h} className="px-4 py-3 font-mono text-xs uppercase tracking-wider text-left" style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}>{h}</DataTh>
80
+ ))}
81
+ </tr>
82
+ </thead>
83
+ <tbody>
84
+ {ordered.map(([name, value], i) => (
85
+ <tr key={name} style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}>
86
+ <td className="px-4 py-2 font-mono text-xs whitespace-nowrap align-top" style={{ color: 'var(--m3-secondary)' }}>{name}</td>
87
+ <td className="px-4 py-2 font-mono text-xs break-all" style={{ color: 'var(--m3-on-surface)' }}>{formatClaim(value)}</td>
88
+ </tr>
89
+ ))}
90
+ </tbody>
91
+ </DataTable>
92
+ </div>
93
+ )}
94
+ </>
95
+ )}
96
+ </PageContainer>
97
+ )
98
+ }
99
+
100
+ /** The note for a test sign-in that would create an account, worded by the application. */
101
+ function newAccountNote(user: string | null) {
102
+ const [before, after] = uiConfig().texts.ssoNewAccount.split('{user}')
103
+ return <>{before}<strong>{user}</strong>{after}</>
104
+ }