@better-forge/ui 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- package/tailwind-preset.js +79 -79
package/src/pages/Login.tsx
CHANGED
|
@@ -1,275 +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
|
-
}
|
|
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
|
+
}
|