@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,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
+ }