@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,264 +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
- }
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
+ }