@better-forge/ui 0.0.0-stage → 0.1.1

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