@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,275 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { api, enterAfterSignIn, 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
|
+
interface AuthConfig {
|
|
9
|
+
passwordLogin: boolean
|
|
10
|
+
/** `loginUrl` is on the host the identity provider returns to, which may differ from the one this page is on. */
|
|
11
|
+
oidc: { label: string; loginUrl?: string } | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SSO_ERRORS: Record<string, string> = {
|
|
15
|
+
oidc_failed: 'Single sign-on failed. Try again or contact an administrator.',
|
|
16
|
+
oidc_no_account: 'No account matches your single sign-on identity. Ask an administrator to create one.',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the sign-in page says about the product beside the form. */
|
|
20
|
+
export interface LoginPageProps {
|
|
21
|
+
headline: ReactNode
|
|
22
|
+
tagline: string
|
|
23
|
+
features: ReadonlyArray<{ icon: string; label: string }>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function LoginPage({ headline, tagline, features }: LoginPageProps) {
|
|
27
|
+
const navigate = useNavigate()
|
|
28
|
+
const [isDark, toggleDark] = useDarkMode()
|
|
29
|
+
const [email, setEmail] = useState('')
|
|
30
|
+
const [password, setPassword] = useState('')
|
|
31
|
+
const [code, setCode] = useState('')
|
|
32
|
+
const [challengeToken, setChallengeToken] = useState('')
|
|
33
|
+
const [error, setError] = useState('')
|
|
34
|
+
const [loading, setLoading] = useState(false)
|
|
35
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
36
|
+
const [searchParams] = useSearchParams()
|
|
37
|
+
// An SSO sign-in of a user with 2FA comes back here for the code; the server holds the challenge in a cookie.
|
|
38
|
+
const [ssoChallenge, setSsoChallenge] = useState(searchParams.get('two-factor') === 'sso')
|
|
39
|
+
const codeStep = !!challengeToken || ssoChallenge
|
|
40
|
+
const [authConfig, setAuthConfig] = useState<AuthConfig>({ passwordLogin: true, oidc: null })
|
|
41
|
+
const ssoError = SSO_ERRORS[searchParams.get('error') ?? '']
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
api.get<AuthConfig>('/auth/config').then(setAuthConfig).catch(() => { /* keep password-only default */ })
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
48
|
+
e.preventDefault()
|
|
49
|
+
setError('')
|
|
50
|
+
setLoading(true)
|
|
51
|
+
try {
|
|
52
|
+
if (codeStep) {
|
|
53
|
+
const user = await api.post<AuthUser>('/auth/2fa/verify', challengeToken ? { challengeToken, code } : { code })
|
|
54
|
+
setSession(user)
|
|
55
|
+
enterAfterSignIn(user, navigate)
|
|
56
|
+
} else {
|
|
57
|
+
const res = await api.post<AuthUser | { requiresTwoFactor: true; challengeToken: string }>('/auth/login', { email, password })
|
|
58
|
+
if ('requiresTwoFactor' in res) {
|
|
59
|
+
setChallengeToken(res.challengeToken)
|
|
60
|
+
setPassword('')
|
|
61
|
+
} else {
|
|
62
|
+
setSession(res)
|
|
63
|
+
enterAfterSignIn(res, navigate)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err instanceof Error ? err.message : 'Login failed')
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="min-h-screen flex" style={{ background: 'var(--m3-surface)' }}>
|
|
75
|
+
{/* Left branding panel */}
|
|
76
|
+
<div
|
|
77
|
+
className="hidden lg:flex flex-col justify-between w-[400px] flex-shrink-0 p-12 relative overflow-hidden"
|
|
78
|
+
style={{
|
|
79
|
+
background: 'var(--m3-surface-container-low)',
|
|
80
|
+
borderRight: '1px solid var(--m3-outline-variant)',
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
{/* Decorative gradient blobs */}
|
|
84
|
+
<div
|
|
85
|
+
className="absolute -top-32 -left-32 w-80 h-80 rounded-full pointer-events-none"
|
|
86
|
+
style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 12%, transparent) 0%, transparent 70%)' }}
|
|
87
|
+
/>
|
|
88
|
+
<div
|
|
89
|
+
className="absolute bottom-0 right-0 w-96 h-96 rounded-full pointer-events-none"
|
|
90
|
+
style={{ background: 'radial-gradient(circle, color-mix(in srgb, var(--m3-primary) 6%, transparent) 0%, transparent 70%)' }}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
{/* Logo */}
|
|
94
|
+
<div className="relative flex justify-center">
|
|
95
|
+
<img
|
|
96
|
+
src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
|
|
97
|
+
alt={uiConfig().brand.name}
|
|
98
|
+
style={{ height: '160px', objectFit: 'contain' }}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Main copy */}
|
|
103
|
+
<div className="relative space-y-4">
|
|
104
|
+
<h2 className="font-headline font-extrabold text-4xl leading-[1.1] tracking-tight" style={{ color: 'var(--m3-on-surface)' }}>
|
|
105
|
+
{headline}
|
|
106
|
+
</h2>
|
|
107
|
+
<p className="text-base font-sans leading-relaxed" style={{ color: 'var(--m3-secondary)' }}>
|
|
108
|
+
{tagline}
|
|
109
|
+
</p>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Features */}
|
|
113
|
+
<ul className="relative space-y-3">
|
|
114
|
+
{features.map((feature) => (
|
|
115
|
+
<li key={feature.label} className="flex items-center gap-3 text-sm font-sans" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
116
|
+
<span
|
|
117
|
+
className="material-symbols-outlined rounded-lg p-1.5"
|
|
118
|
+
aria-hidden="true"
|
|
119
|
+
style={{ fontSize: '18px', background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}
|
|
120
|
+
>
|
|
121
|
+
{feature.icon}
|
|
122
|
+
</span>
|
|
123
|
+
{feature.label}
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Right form panel */}
|
|
130
|
+
<div className="flex-1 relative flex items-center justify-center px-6 py-12">
|
|
131
|
+
{/* Dark mode toggle */}
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
onClick={toggleDark}
|
|
135
|
+
className="btn-icon absolute top-5 right-5 w-9 h-9 rounded-full"
|
|
136
|
+
aria-label="Toggle dark mode"
|
|
137
|
+
>
|
|
138
|
+
<span className="material-symbols-outlined" aria-hidden="true">
|
|
139
|
+
{isDark ? 'light_mode' : 'dark_mode'}
|
|
140
|
+
</span>
|
|
141
|
+
</button>
|
|
142
|
+
|
|
143
|
+
<div className="w-full max-w-[400px]">
|
|
144
|
+
{/* Mobile logo */}
|
|
145
|
+
<div className="flex justify-center mb-8 lg:hidden">
|
|
146
|
+
<img
|
|
147
|
+
src={isDark ? uiConfig().brand.logoDark : uiConfig().brand.logoLight}
|
|
148
|
+
alt={uiConfig().brand.name}
|
|
149
|
+
style={{ height: '32px', objectFit: 'contain' }}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div
|
|
154
|
+
className="rounded-[20px] px-6 py-8 md:px-9 md:pt-9 md:pb-8"
|
|
155
|
+
style={{
|
|
156
|
+
background: 'var(--m3-surface-container-lowest)',
|
|
157
|
+
border: '1px solid var(--m3-outline-variant)',
|
|
158
|
+
boxShadow: '0 1px 2px rgba(19,27,46,0.04), 0 16px 40px rgba(19,27,46,0.06)',
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
|
|
162
|
+
<div className="mb-8">
|
|
163
|
+
<h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>
|
|
164
|
+
Welcome back
|
|
165
|
+
</h1>
|
|
166
|
+
<p className="text-sm font-sans mt-1.5" style={{ color: 'var(--m3-secondary)' }}>
|
|
167
|
+
Sign in to your admin panel
|
|
168
|
+
</p>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
172
|
+
{(error || ssoError) && <Alert tone="error">{error || ssoError}</Alert>}
|
|
173
|
+
|
|
174
|
+
{authConfig.oidc && !codeStep && (
|
|
175
|
+
<a href={authConfig.oidc.loginUrl ?? '/api/v1/auth/oidc/login'} className="btn btn-primary w-full h-12 font-headline font-bold text-[15px]">
|
|
176
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px' }}>key</span>
|
|
177
|
+
{authConfig.oidc.label}
|
|
178
|
+
</a>
|
|
179
|
+
)}
|
|
180
|
+
{authConfig.oidc && authConfig.passwordLogin && !codeStep && (
|
|
181
|
+
<div className="flex items-center gap-3 text-xs font-sans" style={{ color: 'var(--m3-secondary)' }}>
|
|
182
|
+
<span className="flex-1 h-px" style={{ background: 'var(--m3-outline-variant)' }} />
|
|
183
|
+
or with email
|
|
184
|
+
<span className="flex-1 h-px" style={{ background: 'var(--m3-outline-variant)' }} />
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{(authConfig.passwordLogin || codeStep) && <>
|
|
189
|
+
|
|
190
|
+
{!codeStep && <div>
|
|
191
|
+
<label htmlFor="login-email" className="block text-[13px] font-sans font-semibold mb-1.5" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
192
|
+
Email
|
|
193
|
+
</label>
|
|
194
|
+
<input
|
|
195
|
+
id="login-email"
|
|
196
|
+
type="email"
|
|
197
|
+
autoComplete="username"
|
|
198
|
+
value={email}
|
|
199
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
200
|
+
required
|
|
201
|
+
className="input-m3"
|
|
202
|
+
placeholder="name@company.com"
|
|
203
|
+
/>
|
|
204
|
+
</div>}
|
|
205
|
+
|
|
206
|
+
{!codeStep && <div>
|
|
207
|
+
<label htmlFor="login-password" className="block text-[13px] font-sans font-semibold mb-1.5" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
208
|
+
Password
|
|
209
|
+
</label>
|
|
210
|
+
<div className="relative">
|
|
211
|
+
<input
|
|
212
|
+
id="login-password"
|
|
213
|
+
type={showPassword ? 'text' : 'password'}
|
|
214
|
+
autoComplete="current-password"
|
|
215
|
+
value={password}
|
|
216
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
217
|
+
required
|
|
218
|
+
className="input-m3"
|
|
219
|
+
style={{ paddingRight: '44px' }}
|
|
220
|
+
/>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={() => setShowPassword((shown) => !shown)}
|
|
224
|
+
className="btn-icon absolute right-1 top-1/2 -translate-y-1/2 w-9 h-9 rounded-lg"
|
|
225
|
+
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
226
|
+
aria-pressed={showPassword}
|
|
227
|
+
>
|
|
228
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px' }}>{showPassword ? 'visibility_off' : 'visibility'}</span>
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>}
|
|
232
|
+
|
|
233
|
+
{codeStep && (
|
|
234
|
+
<div>
|
|
235
|
+
<label htmlFor="login-two-factor-code" className="block text-[13px] font-sans font-semibold mb-1.5" style={{ color: 'var(--m3-on-surface-variant)' }}>
|
|
236
|
+
Authentication code
|
|
237
|
+
</label>
|
|
238
|
+
<input
|
|
239
|
+
id="login-two-factor-code"
|
|
240
|
+
type="text"
|
|
241
|
+
autoComplete="one-time-code"
|
|
242
|
+
value={code}
|
|
243
|
+
onChange={(e) => setCode(e.target.value)}
|
|
244
|
+
required
|
|
245
|
+
autoFocus
|
|
246
|
+
className="input-m3"
|
|
247
|
+
placeholder="6-digit code or recovery code"
|
|
248
|
+
/>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
className="text-xs mt-2 rounded focus-ring hover:underline"
|
|
252
|
+
style={{ color: 'var(--m3-secondary)' }}
|
|
253
|
+
onClick={() => { setChallengeToken(''); setSsoChallenge(false); setCode('') }}
|
|
254
|
+
>
|
|
255
|
+
Back to sign-in
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{/* One filled button per screen: next to SSO the password form's button is outlined. */}
|
|
261
|
+
<button
|
|
262
|
+
type="submit"
|
|
263
|
+
disabled={loading}
|
|
264
|
+
className={`btn ${authConfig.oidc && !codeStep ? 'btn-secondary h-11' : 'btn-primary h-12'} w-full mt-2 font-headline font-bold`}
|
|
265
|
+
>
|
|
266
|
+
{loading ? 'Signing in…' : codeStep ? 'Verify & sign in' : 'Sign in'}
|
|
267
|
+
</button>
|
|
268
|
+
</>}
|
|
269
|
+
</form>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { api, clearSession, getCurrentUser, setSession, type AuthUser } from '../api/client'
|
|
4
|
+
import { uiConfig } from '../config'
|
|
5
|
+
import { ConfirmModal } from '../components/ConfirmModal'
|
|
6
|
+
import { CopyButton } from '../components/CopyButton'
|
|
7
|
+
import { SsoConfirmationNote, isSsoSession, useSsoConfirmation } from '../components/SsoConfirmation'
|
|
8
|
+
import { Alert, PageContainer, PageHeader } from '../components/ui'
|
|
9
|
+
import { DATE_FORMATS, TIME_FORMATS, formatDateTime, setDatePrefs, useDatePrefs, type DateFormat, type TimeFormat } from '../lib/dateFormat'
|
|
10
|
+
|
|
11
|
+
interface TwoFactorSetup {
|
|
12
|
+
secret: string
|
|
13
|
+
uri: string
|
|
14
|
+
qrDataUrl: string
|
|
15
|
+
setupToken: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function SettingsPage() {
|
|
19
|
+
const datePrefs = useDatePrefs()
|
|
20
|
+
const navigate = useNavigate()
|
|
21
|
+
const currentUser = getCurrentUser()
|
|
22
|
+
// An SSO session confirms these actions at the identity provider instead of with a password.
|
|
23
|
+
const sso = isSsoSession()
|
|
24
|
+
const confirmation = useSsoConfirmation()
|
|
25
|
+
const [current, setCurrent] = useState('')
|
|
26
|
+
const [password, setPassword] = useState('')
|
|
27
|
+
const [confirm, setConfirm] = useState('')
|
|
28
|
+
const [securityPassword, setSecurityPassword] = useState('')
|
|
29
|
+
const [code, setCode] = useState('')
|
|
30
|
+
const [twoFactorEnabled, setTwoFactorEnabled] = useState(!!currentUser?.twoFactorEnabled)
|
|
31
|
+
const [setup, setSetup] = useState<TwoFactorSetup | null>(null)
|
|
32
|
+
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
|
33
|
+
const [loading, setLoading] = useState(false)
|
|
34
|
+
const [error, setError] = useState('')
|
|
35
|
+
const [message, setMessage] = useState('')
|
|
36
|
+
const [confirmLogoutAll, setConfirmLogoutAll] = useState(false)
|
|
37
|
+
|
|
38
|
+
function resetFeedback() {
|
|
39
|
+
setError('')
|
|
40
|
+
setMessage('')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function fail(err: unknown, fallback: string) {
|
|
44
|
+
setError(err instanceof Error ? err.message : fallback)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function changePassword() {
|
|
48
|
+
if (password !== confirm) { setError('Passwords do not match'); return }
|
|
49
|
+
if (password.length < 8) { setError('Password must be at least 8 characters'); return }
|
|
50
|
+
resetFeedback()
|
|
51
|
+
setLoading(true)
|
|
52
|
+
try {
|
|
53
|
+
const user = await confirmation.run(() => api.post<AuthUser>('/auth/change-password', sso ? { newPassword: password } : { currentPassword: current, newPassword: password }))
|
|
54
|
+
setSession(user)
|
|
55
|
+
setCurrent('')
|
|
56
|
+
setPassword('')
|
|
57
|
+
setConfirm('')
|
|
58
|
+
setMessage('Password changed. Other active sessions were signed out.')
|
|
59
|
+
} catch (err) {
|
|
60
|
+
fail(err, 'Failed to change password')
|
|
61
|
+
} finally { setLoading(false) }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function beginTwoFactorSetup() {
|
|
65
|
+
resetFeedback()
|
|
66
|
+
setLoading(true)
|
|
67
|
+
try {
|
|
68
|
+
const result = await confirmation.run(() => api.post<TwoFactorSetup>('/auth/2fa/setup', sso ? {} : { currentPassword: securityPassword }))
|
|
69
|
+
setSetup(result)
|
|
70
|
+
setCode('')
|
|
71
|
+
} catch (err) {
|
|
72
|
+
fail(err, 'Failed to start two-factor setup')
|
|
73
|
+
} finally { setLoading(false) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function enableTwoFactor() {
|
|
77
|
+
if (!setup) return
|
|
78
|
+
resetFeedback()
|
|
79
|
+
setLoading(true)
|
|
80
|
+
try {
|
|
81
|
+
const result = await api.post<{ recoveryCodes: string[] }>('/auth/2fa/enable', { setupToken: setup.setupToken, code })
|
|
82
|
+
setRecoveryCodes(result.recoveryCodes)
|
|
83
|
+
setTwoFactorEnabled(true)
|
|
84
|
+
if (currentUser) setSession({ ...currentUser, twoFactorEnabled: true })
|
|
85
|
+
setSetup(null)
|
|
86
|
+
setSecurityPassword('')
|
|
87
|
+
setCode('')
|
|
88
|
+
setMessage('Two-factor authentication is enabled. Save the recovery codes now.')
|
|
89
|
+
} catch (err) {
|
|
90
|
+
setError(err instanceof Error ? err.message : 'Failed to enable two-factor authentication')
|
|
91
|
+
} finally { setLoading(false) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function disableTwoFactor() {
|
|
95
|
+
resetFeedback()
|
|
96
|
+
setLoading(true)
|
|
97
|
+
try {
|
|
98
|
+
await confirmation.run(() => api.post('/auth/2fa/disable', sso ? { code } : { currentPassword: securityPassword, code }))
|
|
99
|
+
setTwoFactorEnabled(false)
|
|
100
|
+
if (currentUser) setSession({ ...currentUser, twoFactorEnabled: false })
|
|
101
|
+
setSecurityPassword('')
|
|
102
|
+
setCode('')
|
|
103
|
+
setRecoveryCodes([])
|
|
104
|
+
setMessage('Two-factor authentication is disabled.')
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fail(err, 'Failed to disable two-factor authentication')
|
|
107
|
+
} finally { setLoading(false) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function logoutEverywhere() {
|
|
111
|
+
resetFeedback()
|
|
112
|
+
setLoading(true)
|
|
113
|
+
try { await api.post('/auth/logout-all') }
|
|
114
|
+
finally {
|
|
115
|
+
clearSession()
|
|
116
|
+
navigate(`${uiConfig().basePath}/login`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<PageContainer>
|
|
122
|
+
<PageHeader title="Settings" subtitle="Manage your account and sign-in security" />
|
|
123
|
+
|
|
124
|
+
{(error || message) && (
|
|
125
|
+
<Alert tone={error ? 'error' : 'success'} className="max-w-2xl">{error || message}</Alert>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<div className="grid gap-6 xl:grid-cols-3 max-w-7xl">
|
|
129
|
+
<section className="rounded-2xl p-6" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
130
|
+
<h2 className="font-headline font-semibold text-lg mb-1">Change password</h2>
|
|
131
|
+
<p className="text-sm mb-5" style={{ color: 'var(--m3-secondary)' }}>Changing your password signs out every other active session.</p>
|
|
132
|
+
<form onSubmit={(event) => { event.preventDefault(); void changePassword() }} className="space-y-4">
|
|
133
|
+
{sso
|
|
134
|
+
? <SsoConfirmationNote confirmation={confirmation} />
|
|
135
|
+
: <PasswordField label="Current password" value={current} onChange={setCurrent} placeholder="Enter current password" />}
|
|
136
|
+
<PasswordField label="New password" value={password} onChange={setPassword} placeholder="Minimum 8 characters" minLength={8} />
|
|
137
|
+
<PasswordField label="Confirm new password" value={confirm} onChange={setConfirm} placeholder="Repeat the new password" />
|
|
138
|
+
<button type="submit" disabled={loading} className="btn btn-primary">
|
|
139
|
+
{loading ? 'Saving…' : 'Update password'}
|
|
140
|
+
</button>
|
|
141
|
+
</form>
|
|
142
|
+
</section>
|
|
143
|
+
<section className="rounded-2xl p-6" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
144
|
+
<div className="flex items-center justify-between gap-3 mb-1">
|
|
145
|
+
<h2 className="font-headline font-semibold text-lg">Two-factor authentication</h2>
|
|
146
|
+
<span className="text-xs font-semibold px-2.5 py-1 rounded-full" style={{
|
|
147
|
+
background: twoFactorEnabled ? 'var(--m3-up-bg)' : 'var(--m3-surface-container-high)',
|
|
148
|
+
color: twoFactorEnabled ? 'var(--m3-up)' : 'var(--m3-secondary)',
|
|
149
|
+
}}>{twoFactorEnabled ? 'Enabled' : 'Disabled'}</span>
|
|
150
|
+
</div>
|
|
151
|
+
<p className="text-sm mb-5" style={{ color: 'var(--m3-secondary)' }}>Use any TOTP authenticator application. Recovery codes work once each.</p>
|
|
152
|
+
|
|
153
|
+
{!twoFactorEnabled && !setup && recoveryCodes.length === 0 && (
|
|
154
|
+
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); void beginTwoFactorSetup() }}>
|
|
155
|
+
{sso
|
|
156
|
+
? <SsoConfirmationNote confirmation={confirmation} />
|
|
157
|
+
: <PasswordField label="Current password" value={securityPassword} onChange={setSecurityPassword} placeholder="Confirm your password" />}
|
|
158
|
+
<button type="submit" disabled={loading || (!sso && !securityPassword)} className="btn btn-primary">Set up 2FA</button>
|
|
159
|
+
</form>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{setup && (
|
|
163
|
+
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); void enableTwoFactor() }}>
|
|
164
|
+
<div className="rounded-xl p-5 flex flex-col items-center text-center" style={{ background: 'var(--m3-surface-container)' }}>
|
|
165
|
+
<p className="text-sm font-semibold mb-3">Scan this QR code with your authenticator app</p>
|
|
166
|
+
<img
|
|
167
|
+
src={setup.qrDataUrl}
|
|
168
|
+
alt="QR code for two-factor authentication setup"
|
|
169
|
+
width={240}
|
|
170
|
+
height={240}
|
|
171
|
+
className="rounded-xl"
|
|
172
|
+
style={{ background: '#ffffff' }}
|
|
173
|
+
/>
|
|
174
|
+
<details className="w-full mt-4 text-left">
|
|
175
|
+
<summary className="text-sm cursor-pointer" style={{ color: 'var(--m3-secondary)' }}>Cannot scan the QR code?</summary>
|
|
176
|
+
<p className="text-xs mt-3 mb-2" style={{ color: 'var(--m3-secondary)' }}>Enter this setup key manually:</p>
|
|
177
|
+
<code className="block text-sm break-all select-all">{setup.secret}</code>
|
|
178
|
+
<a href={setup.uri} className="inline-block text-sm mt-3 underline">Open in authenticator app</a>
|
|
179
|
+
</details>
|
|
180
|
+
</div>
|
|
181
|
+
<TextField label="Authentication code" value={code} onChange={setCode} placeholder="123456" autoComplete="one-time-code" inputMode="numeric" />
|
|
182
|
+
<button type="submit" disabled={loading || !code} className="btn btn-primary">Verify and enable</button>
|
|
183
|
+
</form>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{recoveryCodes.length > 0 && (
|
|
187
|
+
<div className="space-y-4">
|
|
188
|
+
<div className="rounded-xl p-4" style={{ background: 'var(--m3-surface-container)' }}>
|
|
189
|
+
<p className="text-sm font-semibold mb-3">Recovery codes — store them somewhere safe</p>
|
|
190
|
+
<div className="grid grid-cols-2 gap-2 font-mono text-sm select-all">
|
|
191
|
+
{recoveryCodes.map((recoveryCode) => <code key={recoveryCode}>{recoveryCode}</code>)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<CopyButton value={recoveryCodes.join('\n')} label="Copy codes" />
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
|
|
198
|
+
{twoFactorEnabled && recoveryCodes.length === 0 && (
|
|
199
|
+
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); void disableTwoFactor() }}>
|
|
200
|
+
{sso
|
|
201
|
+
? <SsoConfirmationNote confirmation={confirmation} />
|
|
202
|
+
: <PasswordField label="Current password" value={securityPassword} onChange={setSecurityPassword} placeholder="Confirm your password" />}
|
|
203
|
+
<TextField label="Authentication or recovery code" value={code} onChange={setCode} placeholder="Code" autoComplete="one-time-code" />
|
|
204
|
+
<button type="submit" disabled={loading || (!sso && !securityPassword) || !code} className="btn btn-danger-outline">Disable 2FA</button>
|
|
205
|
+
</form>
|
|
206
|
+
)}
|
|
207
|
+
</section>
|
|
208
|
+
<section className="rounded-2xl p-6" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
209
|
+
<h2 className="font-headline font-semibold text-lg">Date and time format</h2>
|
|
210
|
+
<p className="text-sm mt-1 mb-4" style={{ color: 'var(--m3-secondary)' }}>Applies to this browser only. Now: {formatDateTime(Date.now())}</p>
|
|
211
|
+
<div className="space-y-4">
|
|
212
|
+
<div className="flex flex-col gap-1">
|
|
213
|
+
<label htmlFor="date-format" className="text-sm font-medium">Date</label>
|
|
214
|
+
<select id="date-format" className="input-sig" value={datePrefs.date} onChange={(e) => setDatePrefs({ ...datePrefs, date: e.target.value as DateFormat })}>
|
|
215
|
+
{DATE_FORMATS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
|
216
|
+
</select>
|
|
217
|
+
</div>
|
|
218
|
+
<div className="flex flex-col gap-1">
|
|
219
|
+
<label htmlFor="time-format" className="text-sm font-medium">Time</label>
|
|
220
|
+
<select id="time-format" className="input-sig" value={datePrefs.time} onChange={(e) => setDatePrefs({ ...datePrefs, time: e.target.value as TimeFormat })}>
|
|
221
|
+
{TIME_FORMATS.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
|
|
222
|
+
</select>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</section>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<section className="rounded-2xl p-6 max-w-5xl" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
|
|
229
|
+
<h2 className="font-headline font-semibold text-lg">Active sessions</h2>
|
|
230
|
+
<p className="text-sm mt-1 mb-4" style={{ color: 'var(--m3-secondary)' }}>Sign out this browser and every other active session for your account.</p>
|
|
231
|
+
<button type="button" disabled={loading} onClick={() => setConfirmLogoutAll(true)} className="btn btn-danger-outline">Sign out everywhere</button>
|
|
232
|
+
</section>
|
|
233
|
+
{confirmLogoutAll && (
|
|
234
|
+
<ConfirmModal
|
|
235
|
+
title="Sign out everywhere"
|
|
236
|
+
message="Sign out this browser and every other active session for your account? You will need to sign in again."
|
|
237
|
+
confirmLabel="Sign out everywhere"
|
|
238
|
+
pending={loading}
|
|
239
|
+
pendingLabel="Signing out…"
|
|
240
|
+
onConfirm={() => void logoutEverywhere()}
|
|
241
|
+
onCancel={() => setConfirmLogoutAll(false)}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
</PageContainer>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function PasswordField({ label, value, onChange, placeholder, minLength }: { label: string; value: string; onChange: (value: string) => void; placeholder: string; minLength?: number }) {
|
|
249
|
+
return (
|
|
250
|
+
<label className="block">
|
|
251
|
+
<span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>{label}</span>
|
|
252
|
+
<input type="password" value={value} onChange={(event) => onChange(event.target.value)} required minLength={minLength} className="input-sig w-full" placeholder={placeholder} />
|
|
253
|
+
</label>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function TextField({ label, value, onChange, placeholder, autoComplete, inputMode }: { label: string; value: string; onChange: (value: string) => void; placeholder: string; autoComplete?: string; inputMode?: 'numeric' }) {
|
|
258
|
+
return (
|
|
259
|
+
<label className="block">
|
|
260
|
+
<span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>{label}</span>
|
|
261
|
+
<input type="text" inputMode={inputMode} value={value} onChange={(event) => onChange(event.target.value)} required className="input-sig w-full" placeholder={placeholder} autoComplete={autoComplete} />
|
|
262
|
+
</label>
|
|
263
|
+
)
|
|
264
|
+
}
|