@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/Settings.tsx
CHANGED
|
@@ -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
|
+
}
|