@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.
- package/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/api/client.d.ts +42 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +130 -0
- package/dist/api/client.js.map +1 -0
- package/dist/components/ConfirmIdentityModal.d.ts +18 -0
- package/dist/components/ConfirmIdentityModal.d.ts.map +1 -0
- package/dist/components/ConfirmIdentityModal.js +30 -0
- package/dist/components/ConfirmIdentityModal.js.map +1 -0
- package/dist/components/ConfirmModal.d.ts +18 -0
- package/dist/components/ConfirmModal.d.ts.map +1 -0
- package/dist/components/ConfirmModal.js +11 -0
- package/dist/components/ConfirmModal.js.map +1 -0
- package/dist/components/CopyButton.d.ts +11 -0
- package/dist/components/CopyButton.d.ts.map +1 -0
- package/dist/components/CopyButton.js +44 -0
- package/dist/components/CopyButton.js.map +1 -0
- package/dist/components/CredentialSection.d.ts +38 -0
- package/dist/components/CredentialSection.d.ts.map +1 -0
- package/dist/components/CredentialSection.js +69 -0
- package/dist/components/CredentialSection.js.map +1 -0
- package/dist/components/ErrorBoundary.d.ts +18 -0
- package/dist/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/components/ErrorBoundary.js +25 -0
- package/dist/components/ErrorBoundary.js.map +1 -0
- package/dist/components/ModalShell.d.ts +58 -0
- package/dist/components/ModalShell.d.ts.map +1 -0
- package/dist/components/ModalShell.js +92 -0
- package/dist/components/ModalShell.js.map +1 -0
- package/dist/components/ResetTwoFactorModal.d.ts +14 -0
- package/dist/components/ResetTwoFactorModal.d.ts.map +1 -0
- package/dist/components/ResetTwoFactorModal.js +16 -0
- package/dist/components/ResetTwoFactorModal.js.map +1 -0
- package/dist/components/SidePanel.d.ts +35 -0
- package/dist/components/SidePanel.d.ts.map +1 -0
- package/dist/components/SidePanel.js +19 -0
- package/dist/components/SidePanel.js.map +1 -0
- package/dist/components/SingleSignOnModal.d.ts +4 -0
- package/dist/components/SingleSignOnModal.d.ts.map +1 -0
- package/dist/components/SingleSignOnModal.js +64 -0
- package/dist/components/SingleSignOnModal.js.map +1 -0
- package/dist/components/SsoConfirmation.d.ts +22 -0
- package/dist/components/SsoConfirmation.d.ts.map +1 -0
- package/dist/components/SsoConfirmation.js +87 -0
- package/dist/components/SsoConfirmation.js.map +1 -0
- package/dist/components/VaultTypePicker.d.ts +13 -0
- package/dist/components/VaultTypePicker.d.ts.map +1 -0
- package/dist/components/VaultTypePicker.js +62 -0
- package/dist/components/VaultTypePicker.js.map +1 -0
- package/dist/components/WhatsNew.d.ts +18 -0
- package/dist/components/WhatsNew.d.ts.map +1 -0
- package/dist/components/WhatsNew.js +50 -0
- package/dist/components/WhatsNew.js.map +1 -0
- package/dist/components/formParts.d.ts +40 -0
- package/dist/components/formParts.d.ts.map +1 -0
- package/dist/components/formParts.js +31 -0
- package/dist/components/formParts.js.map +1 -0
- package/dist/components/ui/Alert.d.ts +15 -0
- package/dist/components/ui/Alert.d.ts.map +1 -0
- package/dist/components/ui/Alert.js +14 -0
- package/dist/components/ui/Alert.js.map +1 -0
- package/dist/components/ui/DataTable.d.ts +52 -0
- package/dist/components/ui/DataTable.d.ts.map +1 -0
- package/dist/components/ui/DataTable.js +112 -0
- package/dist/components/ui/DataTable.js.map +1 -0
- package/dist/components/ui/Field.d.ts +27 -0
- package/dist/components/ui/Field.d.ts.map +1 -0
- package/dist/components/ui/Field.js +25 -0
- package/dist/components/ui/Field.js.map +1 -0
- package/dist/components/ui/PageHeader.d.ts +18 -0
- package/dist/components/ui/PageHeader.d.ts.map +1 -0
- package/dist/components/ui/PageHeader.js +10 -0
- package/dist/components/ui/PageHeader.js.map +1 -0
- package/dist/components/ui/Pagination.d.ts +21 -0
- package/dist/components/ui/Pagination.d.ts.map +1 -0
- package/dist/components/ui/Pagination.js +10 -0
- package/dist/components/ui/Pagination.js.map +1 -0
- package/dist/components/ui/QuickStartPanel.d.ts +21 -0
- package/dist/components/ui/QuickStartPanel.d.ts.map +1 -0
- package/dist/components/ui/QuickStartPanel.js +11 -0
- package/dist/components/ui/QuickStartPanel.js.map +1 -0
- package/dist/components/ui/StateViews.d.ts +50 -0
- package/dist/components/ui/StateViews.d.ts.map +1 -0
- package/dist/components/ui/StateViews.js +33 -0
- package/dist/components/ui/StateViews.js.map +1 -0
- package/dist/components/ui/Switch.d.ts +16 -0
- package/dist/components/ui/Switch.d.ts.map +1 -0
- package/dist/components/ui/Switch.js +15 -0
- package/dist/components/ui/Switch.js.map +1 -0
- package/dist/components/ui/Toast.d.ts +20 -0
- package/dist/components/ui/Toast.d.ts.map +1 -0
- package/dist/components/ui/Toast.js +46 -0
- package/dist/components/ui/Toast.js.map +1 -0
- package/dist/components/ui/index.d.ts +10 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/index.js +10 -0
- package/dist/components/ui/index.js.map +1 -0
- package/dist/config.d.ts +46 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/useDarkMode.d.ts +2 -0
- package/dist/hooks/useDarkMode.d.ts.map +1 -0
- package/dist/hooks/useDarkMode.js +37 -0
- package/dist/hooks/useDarkMode.js.map +1 -0
- package/dist/hooks/useDebouncedValue.d.ts +6 -0
- package/dist/hooks/useDebouncedValue.d.ts.map +1 -0
- package/dist/hooks/useDebouncedValue.js +19 -0
- package/dist/hooks/useDebouncedValue.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/changelog.d.ts +32 -0
- package/dist/lib/changelog.d.ts.map +1 -0
- package/dist/lib/changelog.js +71 -0
- package/dist/lib/changelog.js.map +1 -0
- package/dist/lib/dateFormat.d.ts +24 -0
- package/dist/lib/dateFormat.d.ts.map +1 -0
- package/dist/lib/dateFormat.js +67 -0
- package/dist/lib/dateFormat.js.map +1 -0
- package/dist/navigation.d.ts +5 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +7 -0
- package/dist/navigation.js.map +1 -0
- package/dist/pages/ApiTokens.d.ts +9 -0
- package/dist/pages/ApiTokens.d.ts.map +1 -0
- package/dist/pages/ApiTokens.js +116 -0
- package/dist/pages/ApiTokens.js.map +1 -0
- package/dist/pages/AuditLog.d.ts +14 -0
- package/dist/pages/AuditLog.d.ts.map +1 -0
- package/dist/pages/AuditLog.js +104 -0
- package/dist/pages/AuditLog.js.map +1 -0
- package/dist/pages/Backups.d.ts +2 -0
- package/dist/pages/Backups.d.ts.map +1 -0
- package/dist/pages/Backups.js +118 -0
- package/dist/pages/Backups.js.map +1 -0
- package/dist/pages/ChangePassword.d.ts +2 -0
- package/dist/pages/ChangePassword.d.ts.map +1 -0
- package/dist/pages/ChangePassword.js +40 -0
- package/dist/pages/ChangePassword.js.map +1 -0
- package/dist/pages/Login.d.ts +12 -0
- package/dist/pages/Login.d.ts.map +1 -0
- package/dist/pages/Login.js +69 -0
- package/dist/pages/Login.js.map +1 -0
- package/dist/pages/Settings.d.ts +2 -0
- package/dist/pages/Settings.d.ts.map +1 -0
- package/dist/pages/Settings.js +149 -0
- package/dist/pages/Settings.js.map +1 -0
- package/dist/pages/Setup.d.ts +2 -0
- package/dist/pages/Setup.d.ts.map +1 -0
- package/dist/pages/Setup.js +113 -0
- package/dist/pages/Setup.js.map +1 -0
- package/dist/pages/SsoConfirm.d.ts +3 -0
- package/dist/pages/SsoConfirm.d.ts.map +1 -0
- package/dist/pages/SsoConfirm.js +20 -0
- package/dist/pages/SsoConfirm.js.map +1 -0
- package/dist/pages/SsoTestResult.d.ts +2 -0
- package/dist/pages/SsoTestResult.d.ts.map +1 -0
- package/dist/pages/SsoTestResult.js +49 -0
- package/dist/pages/SsoTestResult.js.map +1 -0
- package/dist/pages/Users.d.ts +22 -0
- package/dist/pages/Users.d.ts.map +1 -0
- package/dist/pages/Users.js +124 -0
- package/dist/pages/Users.js.map +1 -0
- package/dist/pages/Vault.d.ts +2 -0
- package/dist/pages/Vault.d.ts.map +1 -0
- package/dist/pages/Vault.js +372 -0
- package/dist/pages/Vault.js.map +1 -0
- package/dist/shell/AdminShell.d.ts +46 -0
- package/dist/shell/AdminShell.d.ts.map +1 -0
- package/dist/shell/AdminShell.js +76 -0
- package/dist/shell/AdminShell.js.map +1 -0
- package/dist/shell/guards.d.ts +28 -0
- package/dist/shell/guards.d.ts.map +1 -0
- package/dist/shell/guards.js +71 -0
- package/dist/shell/guards.js.map +1 -0
- package/icons.json +170 -0
- package/package.json +69 -4
- package/src/api/client.ts +139 -0
- package/src/components/ConfirmIdentityModal.tsx +82 -0
- package/src/components/ConfirmModal.tsx +83 -0
- package/src/components/CopyButton.tsx +71 -0
- package/src/components/CredentialSection.tsx +270 -0
- package/src/components/ErrorBoundary.tsx +47 -0
- package/src/components/ModalShell.tsx +160 -0
- package/src/components/ResetTwoFactorModal.tsx +74 -0
- package/src/components/SidePanel.tsx +96 -0
- package/src/components/SingleSignOnModal.tsx +161 -0
- package/src/components/SsoConfirmation.tsx +96 -0
- package/src/components/VaultTypePicker.tsx +131 -0
- package/src/components/WhatsNew.tsx +84 -0
- package/src/components/formParts.tsx +59 -0
- package/src/components/ui/Alert.tsx +45 -0
- package/src/components/ui/DataTable.tsx +225 -0
- package/src/components/ui/Field.tsx +63 -0
- package/src/components/ui/PageHeader.tsx +36 -0
- package/src/components/ui/Pagination.tsx +37 -0
- package/src/components/ui/QuickStartPanel.tsx +57 -0
- package/src/components/ui/StateViews.tsx +135 -0
- package/src/components/ui/Switch.tsx +65 -0
- package/src/components/ui/Toast.tsx +104 -0
- package/src/components/ui/index.ts +9 -0
- package/src/config.ts +46 -0
- package/src/hooks/useDarkMode.ts +38 -0
- package/src/hooks/useDebouncedValue.ts +22 -0
- package/src/index.ts +42 -0
- package/src/lib/changelog.ts +93 -0
- package/src/lib/dateFormat.ts +90 -0
- package/src/navigation.ts +6 -0
- package/src/pages/ApiTokens.tsx +361 -0
- package/src/pages/AuditLog.tsx +388 -0
- package/src/pages/Backups.tsx +170 -0
- package/src/pages/ChangePassword.tsx +84 -0
- package/src/pages/Login.tsx +275 -0
- package/src/pages/Settings.tsx +264 -0
- package/src/pages/Setup.tsx +438 -0
- package/src/pages/SsoConfirm.tsx +26 -0
- package/src/pages/SsoTestResult.tsx +104 -0
- package/src/pages/Users.tsx +426 -0
- package/src/pages/Vault.tsx +1036 -0
- package/src/shell/AdminShell.tsx +256 -0
- package/src/shell/guards.tsx +88 -0
- package/styles/base.css +521 -0
- package/styles/fonts/inter-latin-1.woff2 +0 -0
- package/styles/fonts/inter-latin-ext.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-3.woff2 +0 -0
- package/styles/fonts/jetbrains-mono-latin-ext-2.woff2 +0 -0
- package/styles/fonts/manrope-latin-5.woff2 +0 -0
- package/styles/fonts/manrope-latin-ext-4.woff2 +0 -0
- package/styles/fonts/material-symbols-outlined-fill.woff2 +0 -0
- package/styles/icons.css +4 -0
- 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
|
+
}
|