@better-forge/ui 0.0.0-stage → 0.1.0

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 +13 -3
  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 +71 -6
  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,388 @@
1
+ import { useId, useMemo, useState } from 'react'
2
+ import { formatDateTime } from '../lib/dateFormat'
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { api } from '../api/client'
5
+ import { EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Pagination, ResizeHandle } from '../components/ui'
6
+ import { useDebouncedValue } from '../hooks/useDebouncedValue'
7
+ import type { AuditAction, AuditLogEntry } from '@better-forge/contracts'
8
+
9
+ /** How an application names the things its audit log is about: labels and icons by type, and the sections of the filter. */
10
+ export interface AuditEntities {
11
+ labels: Readonly<Record<string, string>>
12
+ icons: Readonly<Record<string, string>>
13
+ /** Filter sections, most used first. */
14
+ groups: ReadonlyArray<{ label: string; types: readonly string[] }>
15
+ }
16
+
17
+ interface AuditPage {
18
+ entries: AuditLogEntry[]
19
+ total: number
20
+ page: number
21
+ limit: number
22
+ pages: number
23
+ }
24
+
25
+ const ACTION_COLORS: Record<AuditAction, { bg: string; color: string; label: string }> = {
26
+ create: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Create' },
27
+ update: { bg: 'var(--audit-update-bg)', color: 'var(--audit-update-text)', label: 'Update' },
28
+ delete: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Delete' },
29
+ allow: { bg: 'var(--audit-create-bg)', color: 'var(--audit-create-text)', label: 'Allowed' },
30
+ deny: { bg: 'var(--audit-delete-bg)', color: 'var(--audit-delete-text)', label: 'Denied' },
31
+ }
32
+
33
+ function formatTs(ms: number) {
34
+ return formatDateTime(ms, true)
35
+ }
36
+
37
+ function DiffView({ diff }: { diff: Record<string, unknown> }) {
38
+ const entries = Object.entries(diff)
39
+ if (entries.length === 0) return <span style={{ color: 'var(--m3-outline)' }}>—</span>
40
+
41
+ // Detect if values are {from, to} pairs (update diff) or a flat snapshot
42
+ const isUpdateDiff = entries.every(([, v]) =>
43
+ v !== null && typeof v === 'object' && 'from' in (v as object) && 'to' in (v as object),
44
+ )
45
+
46
+ if (isUpdateDiff) {
47
+ return (
48
+ <table style={{ borderCollapse: 'collapse', fontSize: '11px', width: '100%' }}>
49
+ <thead>
50
+ <tr>
51
+ <th style={{ textAlign: 'left', padding: '2px 8px 2px 0', color: 'var(--m3-outline)', fontWeight: 600, whiteSpace: 'nowrap' }}>Field</th>
52
+ <th style={{ textAlign: 'left', padding: '2px 8px', color: 'var(--m3-outline)', fontWeight: 600 }}>Before</th>
53
+ <th style={{ textAlign: 'left', padding: '2px 0', color: 'var(--m3-outline)', fontWeight: 600 }}>After</th>
54
+ </tr>
55
+ </thead>
56
+ <tbody>
57
+ {entries.map(([key, val]) => {
58
+ const { from, to } = val as { from: unknown; to: unknown }
59
+ return (
60
+ <tr key={key}>
61
+ <td style={{ padding: '2px 8px 2px 0', color: 'var(--m3-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top' }}>{key}</td>
62
+ <td style={{ padding: '2px 8px', verticalAlign: 'top' }}>
63
+ <span style={{ color: 'var(--audit-delete-text)', background: 'var(--audit-delete-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
64
+ {from === null ? 'null' : String(from)}
65
+ </span>
66
+ </td>
67
+ <td style={{ padding: '2px 0', verticalAlign: 'top' }}>
68
+ <span style={{ color: 'var(--audit-create-text)', background: 'var(--audit-create-bg)', borderRadius: 4, padding: '0 4px', display: 'inline-block', wordBreak: 'break-all' }}>
69
+ {to === null ? 'null' : String(to)}
70
+ </span>
71
+ </td>
72
+ </tr>
73
+ )
74
+ })}
75
+ </tbody>
76
+ </table>
77
+ )
78
+ }
79
+
80
+ // Flat snapshot (create/delete)
81
+ return (
82
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px 12px' }}>
83
+ {entries.map(([key, val]) => (
84
+ <span key={key} style={{ fontSize: '11px' }}>
85
+ <span style={{ color: 'var(--m3-outline)' }}>{key}: </span>
86
+ <span style={{ color: 'var(--m3-on-surface)' }}>{val === null ? 'null' : String(val)}</span>
87
+ </span>
88
+ ))}
89
+ </div>
90
+ )
91
+ }
92
+
93
+ const HEADERS = ['Timestamp', 'User', 'Action', 'Entity'] as const
94
+ const DEFAULT_WIDTHS = [168, 220, 100, 200]
95
+ const DETAILS_MIN = 160
96
+ const FILTER_LABEL = 'font-mono text-[10px] uppercase tracking-widest'
97
+
98
+ export default function AuditLogPage({ entities }: { entities: AuditEntities }) {
99
+ const [page, setPage] = useState(1)
100
+ const [emailInput, setEmailInput] = useState('')
101
+ const [userEmail, flushEmail] = useDebouncedValue(emailInput, 300)
102
+ const [entityType, setEntityType] = useState('')
103
+ const [action, setAction] = useState('')
104
+ const [fromDate, setFromDate] = useState('')
105
+ const [toDate, setToDate] = useState('')
106
+ const [expanded, setExpanded] = useState<number | null>(null)
107
+ const [widths, setWidths] = useState(DEFAULT_WIDTHS)
108
+ const idPrefix = useId()
109
+ const gridColumns = `${widths.map((w) => `${w}px`).join(' ')} minmax(${DETAILS_MIN}px, 1fr)`
110
+ const tableMinWidth = widths.reduce((a, b) => a + b, 0) + DETAILS_MIN + 40
111
+
112
+ function resizeColumn(index: number, width: number) {
113
+ setWidths((prev) => prev.map((w, i) => (i === index ? width : w)))
114
+ }
115
+
116
+ // Reset to page 1 when filters change
117
+ const filters = useMemo(() => ({
118
+ page,
119
+ limit: 50,
120
+ ...(userEmail ? { userEmail } : {}),
121
+ ...(entityType ? { entityType } : {}),
122
+ ...(action ? { action } : {}),
123
+ ...(fromDate ? { from: String(new Date(fromDate).getTime()) } : {}),
124
+ ...(toDate ? { to: String(new Date(toDate + 'T23:59:59').getTime()) } : {}),
125
+ }), [page, userEmail, entityType, action, fromDate, toDate])
126
+
127
+ const qs = new URLSearchParams(
128
+ Object.fromEntries(Object.entries(filters).map(([k, v]) => [k, String(v)])),
129
+ ).toString()
130
+
131
+ const { data, isFetching, isLoading, isError, refetch } = useQuery<AuditPage>({
132
+ queryKey: ['audit', qs],
133
+ queryFn: () => api.get(`/admin/audit?${qs}`),
134
+ refetchOnMount: 'always',
135
+ })
136
+
137
+ function clearFilters() {
138
+ setEmailInput(''); flushEmail(''); setEntityType(''); setAction(''); setFromDate(''); setToDate(''); setPage(1)
139
+ }
140
+
141
+ const entries = data?.entries ?? []
142
+ const filtered = !!(emailInput || entityType || action || fromDate || toDate)
143
+ const totalPages = data?.pages ?? 1
144
+
145
+ return (
146
+ <PageContainer>
147
+ <PageHeader title="Audit Log" subtitle={data ? `${data.total} entries` : undefined} />
148
+
149
+ {/* Filters */}
150
+ <div
151
+ className="rounded-2xl p-4 flex flex-wrap gap-3 items-end"
152
+ style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
153
+ >
154
+ <div className="flex flex-col gap-1 min-w-[180px] flex-1">
155
+ <label htmlFor={`${idPrefix}-user`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>User</label>
156
+ <input
157
+ id={`${idPrefix}-user`}
158
+ value={emailInput}
159
+ onChange={(e) => { setEmailInput(e.target.value); setPage(1) }}
160
+ onKeyDown={(e) => { if (e.key === 'Enter') flushEmail() }}
161
+ placeholder="Filter by email…"
162
+ className="input-sig"
163
+ style={{ height: '36px', fontSize: '13px' }}
164
+ />
165
+ </div>
166
+
167
+ <div className="flex flex-col gap-1">
168
+ <label htmlFor={`${idPrefix}-entity`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Entity</label>
169
+ <select
170
+ id={`${idPrefix}-entity`}
171
+ value={entityType}
172
+ onChange={(e) => { setEntityType(e.target.value); setPage(1) }}
173
+ className="input-sig"
174
+ style={{ height: '36px', fontSize: '13px', width: '180px' }}
175
+ >
176
+ <option value="">All entities</option>
177
+ {entities.groups.map((group) => (
178
+ <optgroup key={group.label} label={group.label}>
179
+ {group.types.map((t) => <option key={t} value={t}>{entities.labels[t]}</option>)}
180
+ </optgroup>
181
+ ))}
182
+ </select>
183
+ </div>
184
+
185
+ <div className="flex flex-col gap-1">
186
+ <label htmlFor={`${idPrefix}-action`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>Action</label>
187
+ <select
188
+ id={`${idPrefix}-action`}
189
+ value={action}
190
+ onChange={(e) => { setAction(e.target.value); setPage(1) }}
191
+ className="input-sig"
192
+ style={{ height: '36px', fontSize: '13px', width: '130px' }}
193
+ >
194
+ <option value="">All actions</option>
195
+ <option value="create">Create</option>
196
+ <option value="update">Update</option>
197
+ <option value="delete">Delete</option>
198
+ <option value="allow">Allowed</option>
199
+ <option value="deny">Denied</option>
200
+ </select>
201
+ </div>
202
+
203
+ <div className="flex flex-col gap-1">
204
+ <label htmlFor={`${idPrefix}-from`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>From</label>
205
+ <input
206
+ id={`${idPrefix}-from`}
207
+ type="date"
208
+ value={fromDate}
209
+ onChange={(e) => { setFromDate(e.target.value); setPage(1) }}
210
+ className="input-sig"
211
+ style={{ height: '36px', fontSize: '13px', width: '150px' }}
212
+ />
213
+ </div>
214
+
215
+ <div className="flex flex-col gap-1">
216
+ <label htmlFor={`${idPrefix}-to`} className={FILTER_LABEL} style={{ color: 'var(--m3-outline)' }}>To</label>
217
+ <input
218
+ id={`${idPrefix}-to`}
219
+ type="date"
220
+ value={toDate}
221
+ onChange={(e) => { setToDate(e.target.value); setPage(1) }}
222
+ className="input-sig"
223
+ style={{ height: '36px', fontSize: '13px', width: '150px' }}
224
+ />
225
+ </div>
226
+
227
+ {(emailInput || entityType || action || fromDate || toDate) && (
228
+ <button type="button" onClick={clearFilters} className="btn btn-ghost btn-sm self-end" style={{ height: '36px' }}>
229
+ Clear
230
+ </button>
231
+ )}
232
+ </div>
233
+
234
+ {/* Table */}
235
+ {isLoading ? (
236
+ <LoadingState label="Loading audit log…" />
237
+ ) : isError ? (
238
+ <ErrorState message="Could not load the audit log." onRetry={() => void refetch()} />
239
+ ) : (
240
+ <div
241
+ className="rounded-2xl overflow-x-auto"
242
+ style={{ border: '1px solid var(--m3-outline-variant)', opacity: isFetching ? 0.7 : 1, transition: 'opacity 0.15s' }}
243
+ >
244
+ <div style={{ minWidth: tableMinWidth }}>
245
+ {/* Header */}
246
+ <div
247
+ className="grid font-mono text-xs uppercase tracking-wider px-5 py-2.5"
248
+ style={{
249
+ gridTemplateColumns: gridColumns,
250
+ color: 'var(--m3-outline)',
251
+ borderBottom: '1px solid var(--m3-outline-variant)',
252
+ background: 'var(--m3-surface-container)',
253
+ }}
254
+ >
255
+ {HEADERS.map((label, i) => (
256
+ <span key={label} className="relative -my-2.5 py-2.5 pr-3">
257
+ {label}
258
+ <ResizeHandle
259
+ label={label}
260
+ width={widths[i]}
261
+ measure={() => widths[i] ?? 0}
262
+ onResize={(w) => resizeColumn(i, w)}
263
+ onReset={() => resizeColumn(i, DEFAULT_WIDTHS[i] ?? 0)}
264
+ />
265
+ </span>
266
+ ))}
267
+ <span>Details</span>
268
+ </div>
269
+
270
+ {entries.map((entry) => {
271
+ const ac = ACTION_COLORS[entry.action]
272
+ const entityLabel = entities.labels[entry.entityType] ?? entry.entityType
273
+ const entityIcon = entities.icons[entry.entityType] ?? 'article'
274
+ const isExpanded = expanded === entry.id
275
+ const hasDiff = entry.diff && Object.keys(entry.diff).length > 0
276
+ const diffId = `${idPrefix}-diff-${entry.id}`
277
+ const fieldCount = hasDiff ? Object.keys(entry.diff!).length : 0
278
+
279
+ return (
280
+ <div
281
+ key={entry.id}
282
+ style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}
283
+ >
284
+ {/* Main row */}
285
+ <div
286
+ className="grid items-center px-5 py-3"
287
+ style={{ gridTemplateColumns: gridColumns }}
288
+ >
289
+ {/* Timestamp */}
290
+ <span className="font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
291
+ {formatTs(entry.timestamp)}
292
+ </span>
293
+
294
+ {/* User */}
295
+ <span className="text-sm truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.userEmail}>
296
+ {entry.userEmail}
297
+ </span>
298
+
299
+ {/* Action badge */}
300
+ <span
301
+ className="text-xs font-bold px-2 py-0.5 rounded-full w-fit"
302
+ style={{ background: ac.bg, color: ac.color }}
303
+ >
304
+ {ac.label}
305
+ </span>
306
+
307
+ {/* Entity */}
308
+ <div className="flex items-center gap-1.5 min-w-0">
309
+ <span className="material-symbols-outlined flex-shrink-0" aria-hidden="true" style={{ fontSize: '14px', color: 'var(--m3-secondary)' }}>
310
+ {entityIcon}
311
+ </span>
312
+ <div className="min-w-0">
313
+ <div className="font-mono text-[10px] uppercase tracking-wider" style={{ color: 'var(--m3-outline)' }}>
314
+ {entityLabel}
315
+ </div>
316
+ <div className="text-xs truncate" style={{ color: 'var(--m3-on-surface)' }} title={entry.entityName}>
317
+ {entry.entityName}
318
+ </div>
319
+ </div>
320
+ </div>
321
+
322
+ {/* Details / expand */}
323
+ {hasDiff ? (
324
+ <button
325
+ type="button"
326
+ onClick={() => setExpanded(isExpanded ? null : entry.id)}
327
+ aria-expanded={isExpanded}
328
+ aria-controls={isExpanded ? diffId : undefined}
329
+ className="focus-ring flex items-center justify-between gap-2 min-w-0 -mx-2 px-2 py-1.5 rounded-lg text-left transition-colors hover:bg-[var(--m3-surface-container-low)]"
330
+ >
331
+ <span className="text-xs" style={{ color: 'var(--m3-secondary)' }}>
332
+ {isExpanded ? 'Hide details' : entry.action === 'deny' ? 'Show reason' : entry.action === 'allow' ? 'Show details' : `${fieldCount} field${fieldCount !== 1 ? 's' : ''} changed`}
333
+ </span>
334
+ <span
335
+ className="material-symbols-outlined"
336
+ aria-hidden="true"
337
+ style={{
338
+ fontSize: '16px', color: 'var(--m3-secondary)',
339
+ transform: isExpanded ? 'rotate(180deg)' : 'none',
340
+ transition: 'transform 0.2s',
341
+ }}
342
+ >
343
+ expand_more
344
+ </span>
345
+ </button>
346
+ ) : (
347
+ <span className="text-xs" style={{ color: 'var(--m3-outline)' }}>—</span>
348
+ )}
349
+ </div>
350
+
351
+ {/* Expanded diff */}
352
+ {isExpanded && hasDiff && (
353
+ <div
354
+ id={diffId}
355
+ className="px-5 py-3"
356
+ style={{ borderTop: '1px solid var(--m3-outline-variant)', background: 'var(--m3-surface-container-lowest)' }}
357
+ >
358
+ <DiffView diff={entry.diff!} />
359
+ </div>
360
+ )}
361
+ </div>
362
+ )
363
+ })}
364
+
365
+ {entries.length === 0 && (
366
+ <EmptyState
367
+ variant="row"
368
+ className="px-5 py-7"
369
+ icon="history"
370
+ title="No audit entries found."
371
+ description={filtered
372
+ ? <>Try widening the date range, or <EmptyStateLink onClick={clearFilters}>clear all filters</EmptyStateLink>.</>
373
+ : 'Changes made in the admin panel and every sign-in, allowed or denied, are recorded here.'}
374
+ />
375
+ )}
376
+ </div>
377
+ </div>
378
+ )}
379
+
380
+ <Pagination
381
+ page={page}
382
+ pageCount={totalPages}
383
+ onPageChange={setPage}
384
+ summary={`Page ${page} of ${totalPages} · ${data?.total ?? 0} entries`}
385
+ />
386
+ </PageContainer>
387
+ )
388
+ }
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react'
2
+ import { api } from '../api/client'
3
+ import { ConfirmModal } from '../components/ConfirmModal'
4
+ import { Alert, EmptyState, EmptyStateLink, ErrorState, LoadingState, PageContainer, PageHeader, Switch } from '../components/ui'
5
+ import { formatDateTime } from '../lib/dateFormat'
6
+
7
+ interface BackupInfo { filename: string; size: number; createdAt: number }
8
+ interface BackupConfig { enabled: boolean; frequency: 'daily' | 'weekly'; hour: number; minute: number; weekday: number; retention: number }
9
+ interface BackupStatus { state: string; lastCompletedAt: number | null; lastFilename: string | null; lastError: string | null }
10
+ interface State { backups: BackupInfo[]; config: BackupConfig; status: BackupStatus }
11
+ type BusyAction = 'create' | 'validate' | 'save' | 'delete'
12
+ interface Message { tone: 'success' | 'error' | 'warning'; text: string }
13
+ const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
14
+
15
+ const errorText = (e: unknown) => e instanceof Error ? e.message : String(e)
16
+
17
+ export default function BackupsPage() {
18
+ const [state, setState] = useState<State | null>(null)
19
+ const [loadError, setLoadError] = useState('')
20
+ const [busy, setBusy] = useState<BusyAction | null>(null)
21
+ const [message, setMessage] = useState<Message | null>(null)
22
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
23
+ const [savedConfig, setSavedConfig] = useState<BackupConfig | null>(null)
24
+ const file = useRef<HTMLInputElement>(null)
25
+ const scheduleSwitchId = useId()
26
+ const load = useCallback(() => api.get<State>('/admin/backups')
27
+ .then((data) => { setState(data); setSavedConfig(data.config); setLoadError('') })
28
+ .catch((e: unknown) => setLoadError(errorText(e))), [])
29
+ useEffect(() => { void load() }, [load])
30
+
31
+ async function create() {
32
+ setBusy('create'); setMessage(null)
33
+ try { await api.post('/admin/backups', {}); await load(); setMessage({ tone: 'success', text: 'Backup created successfully.' }) }
34
+ catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
35
+ }
36
+ async function saveConfig(config: BackupConfig) {
37
+ setBusy('save'); setMessage(null)
38
+ try { const saved = await api.put<BackupConfig>('/admin/backups/config', config); setState((s) => s && ({ ...s, config: saved })); setSavedConfig(saved); setMessage({ tone: 'success', text: 'Schedule saved.' }) }
39
+ catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
40
+ }
41
+ async function validate(selected: File) {
42
+ setBusy('validate'); setMessage(null)
43
+ const form = new FormData(); form.append('file', selected)
44
+ try {
45
+ const result = await api.upload<{ manifest: { createdAt: number }; vaultKeyMatches: boolean | null }>('/admin/backups/validate', form)
46
+ const keyMismatch = result.vaultKeyMatches === false
47
+ const key = keyMismatch ? ' Warning: VAULT_ENCRYPTION_KEY does not match.' : ''
48
+ setMessage({ tone: keyMismatch ? 'warning' : 'success', text: `Backup is valid (${formatDateTime(result.manifest.createdAt)}).${key} Stop the app and run: npm run restore -- --input <file>` })
49
+ } catch (e) { setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null); if (file.current) file.current.value = '' }
50
+ }
51
+ async function removeBackup(filename: string) {
52
+ setBusy('delete'); setMessage(null)
53
+ try {
54
+ await api.delete(`/admin/backups/${encodeURIComponent(filename)}?confirm=${encodeURIComponent(filename)}`)
55
+ setDeleteTarget(null)
56
+ await load()
57
+ setMessage({ tone: 'success', text: 'Backup deleted.' })
58
+ } catch (e) { setDeleteTarget(null); setMessage({ tone: 'error', text: errorText(e) }) } finally { setBusy(null) }
59
+ }
60
+
61
+ const header = <PageHeader title="Backups" subtitle="Create, download, validate and schedule complete application backups." />
62
+ if (!state && loadError) return <PageContainer>{header}<ErrorState message={loadError} onRetry={() => void load()} /></PageContainer>
63
+ if (!state) return <PageContainer>{header}<LoadingState label="Loading backups…" /></PageContainer>
64
+ const config = state.config
65
+ const scheduleChanged = JSON.stringify(config) !== JSON.stringify(savedConfig)
66
+ const scheduleSummary = config.enabled
67
+ ? `${config.frequency === 'daily' ? 'Every day' : `Every ${WEEKDAYS[config.weekday]}`} at ${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')} · keep ${config.retention} ${config.retention === 1 ? 'backup' : 'backups'}`
68
+ : 'Automatic backups are disabled'
69
+ const updateConfig = (updates: Partial<BackupConfig>) => setState({ ...state, config: { ...config, ...updates } })
70
+ // Turns the switch on and moves focus to it; the schedule is still saved with "Save schedule".
71
+ const enableAutomaticBackups = () => {
72
+ updateConfig({ enabled: true })
73
+ document.getElementById(scheduleSwitchId)?.focus()
74
+ }
75
+ return <PageContainer>
76
+ {header}
77
+ {message && <Alert tone={message.tone} onDismiss={() => setMessage(null)}>{message.text}</Alert>}
78
+ {state.status.lastCompletedAt && <div className="text-sm" style={{ color: state.status.state === 'error' ? 'var(--m3-down)' : 'var(--m3-secondary)' }}>Last run: {formatDateTime(state.status.lastCompletedAt)} · {state.status.state}{state.status.lastError ? ` · ${state.status.lastError}` : ''}</div>}
79
+ <section className="rounded-2xl p-6 space-y-4" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
80
+ <div className="flex flex-wrap gap-3">
81
+ <button type="button" disabled={busy !== null} onClick={create} className="btn btn-primary">{busy === 'create' ? 'Creating backup…' : 'Create backup'}</button>
82
+ <button type="button" disabled={busy !== null} onClick={() => file.current?.click()} className="btn btn-secondary">{busy === 'validate' ? 'Validating…' : 'Validate restore file'}</button>
83
+ <input ref={file} className="hidden" type="file" accept=".backup" aria-label="Restore file" onChange={(e) => { const selected = e.target.files?.[0]; if (selected) void validate(selected) }} />
84
+ </div>
85
+ <p className="text-sm" style={{ color: 'var(--m3-secondary)' }}>Restore is intentionally offline. The encryption key is never included; keep VAULT_ENCRYPTION_KEY separately.</p>
86
+ </section>
87
+ <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', border: '1px solid var(--m3-outline-variant)', color: 'var(--m3-on-surface)' }}>
88
+ <div className="p-5 md:p-6 flex items-start justify-between gap-5">
89
+ <div className="flex gap-4 min-w-0">
90
+ <span className="material-symbols-outlined rounded-xl p-2.5 h-fit" aria-hidden="true" style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}>schedule</span>
91
+ <div>
92
+ <h2 className="font-headline text-xl font-semibold">Automatic backups</h2>
93
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>Create backups on a recurring schedule using the server&apos;s local time.</p>
94
+ </div>
95
+ </div>
96
+ <Switch id={scheduleSwitchId} checked={config.enabled} onChange={(enabled) => updateConfig({ enabled })} aria-label="Automatic backups" />
97
+ </div>
98
+
99
+ <div className="px-5 pb-5 md:px-6 md:pb-6">
100
+ {config.enabled ? (
101
+ <div className="rounded-2xl p-4 md:p-5 grid gap-5 md:grid-cols-3" style={{ background: 'var(--m3-surface-container-low)' }}>
102
+ <fieldset>
103
+ <legend className="font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Frequency</legend>
104
+ <div className="grid grid-cols-2 rounded-xl overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)' }}>
105
+ {(['daily', 'weekly'] as const).map((frequency) => {
106
+ const active = config.frequency === frequency
107
+ return (
108
+ <button
109
+ key={frequency}
110
+ type="button"
111
+ aria-pressed={active}
112
+ onClick={() => updateConfig({ frequency })}
113
+ className={`px-3 py-2 text-sm font-semibold transition-colors focus-ring ${active ? 'selection-active' : ''}`}
114
+ style={{ background: 'transparent', color: 'var(--m3-secondary)' }}
115
+ >
116
+ {frequency === 'daily' ? 'Daily' : 'Weekly'}
117
+ </button>
118
+ )
119
+ })}
120
+ </div>
121
+ </fieldset>
122
+
123
+ {config.frequency === 'weekly' && <label>
124
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Day of week</span>
125
+ <select className="input-sig w-full" value={config.weekday} onChange={(e) => updateConfig({ weekday: Number(e.target.value) })}>{WEEKDAYS.map((day, index) => <option key={day} value={index}>{day}</option>)}</select>
126
+ </label>}
127
+
128
+ <label>
129
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Start time</span>
130
+ <input className="input-sig w-full" type="time" step="60" value={`${String(config.hour).padStart(2, '0')}:${String(config.minute).padStart(2, '0')}`} onChange={(e) => { const parts = e.target.value.split(':'); const hour = Number(parts[0]); const minute = Number(parts[1]); if (parts.length === 2 && Number.isInteger(hour) && Number.isInteger(minute)) updateConfig({ hour, minute }) }} />
131
+ <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Server local time</span>
132
+ </label>
133
+
134
+ <label>
135
+ <span className="block font-mono text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--m3-secondary)' }}>Retention</span>
136
+ <div className="relative"><input className="input-sig w-full pr-24" type="number" min="1" max="365" value={config.retention} onChange={(e) => updateConfig({ retention: Number(e.target.value) })} /><span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm" aria-hidden="true" style={{ color: 'var(--m3-secondary)' }}>backups</span></div>
137
+ <span className="block text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>Older automatic backups are removed after a successful run.</span>
138
+ </label>
139
+ </div>
140
+ ) : <div className="rounded-xl px-4 py-3 text-sm flex items-center gap-3" style={{ background: 'var(--m3-surface-container-low)', color: 'var(--m3-secondary)' }}><span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '20px' }}>info</span>Enable scheduling to configure recurring backups.</div>}
141
+
142
+ <div className="mt-5 pt-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
143
+ <div><p className="text-xs font-mono uppercase tracking-wider" style={{ color: 'var(--m3-secondary)' }}>Current schedule</p><p className="text-sm font-medium mt-1">{scheduleSummary}</p></div>
144
+ <button type="button" disabled={busy !== null || !scheduleChanged} onClick={() => saveConfig(config)} className="btn btn-primary">{busy === 'save' ? 'Saving…' : scheduleChanged ? 'Save schedule' : 'Saved'}</button>
145
+ </div>
146
+ </div>
147
+ </section>
148
+ <section className="rounded-2xl overflow-hidden" style={{ background: 'var(--m3-surface-container-lowest)', color: 'var(--m3-on-surface)' }}>
149
+ <h2 className="font-headline text-xl font-semibold p-6 pb-3">Available backups</h2>
150
+ {state.backups.length === 0
151
+ ? <EmptyState
152
+ variant="inset"
153
+ icon="cloud_off"
154
+ title="No backups yet"
155
+ description={savedConfig?.enabled
156
+ ? 'Automatic backups are on; the first one will appear here after it runs.'
157
+ : 'Automatic backups are off. The database is not protected until the first backup exists.'}
158
+ action={config.enabled ? undefined : <EmptyStateLink onClick={enableAutomaticBackups}>Enable automatic backups</EmptyStateLink>}
159
+ />
160
+ : state.backups.map((item) => <div key={item.filename} className="p-4 px-6 flex flex-wrap items-center justify-between gap-3" style={{ borderTop: '1px solid var(--m3-outline-variant)' }}>
161
+ <div className="min-w-0"><div className="font-mono text-sm break-all">{item.filename}</div><div className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{formatDateTime(item.createdAt)} · {(item.size / 1024 / 1024).toFixed(2)} MB</div></div>
162
+ <div className="flex gap-2">
163
+ <button type="button" onClick={() => api.download(`/admin/backups/${encodeURIComponent(item.filename)}/download`, item.filename)} aria-label={`Download ${item.filename}`} className="btn btn-secondary btn-sm">Download</button>
164
+ <button type="button" onClick={() => setDeleteTarget(item.filename)} aria-label={`Delete ${item.filename}`} className="btn btn-danger-outline btn-sm">Delete</button>
165
+ </div>
166
+ </div>)}
167
+ </section>
168
+ {deleteTarget && <ConfirmModal title="Delete backup" message="This permanently deletes the backup archive. This action cannot be undone." confirmLabel="Delete backup" confirmationText={deleteTarget} pending={busy === 'delete'} pendingLabel="Deleting…" onConfirm={() => { if (!busy) void removeBackup(deleteTarget) }} onCancel={() => setDeleteTarget(null)} />}
169
+ </PageContainer>
170
+ }
@@ -0,0 +1,84 @@
1
+ import { useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { api, enterAfterSignIn, getCurrentUser, setSession, type AuthUser } from '../api/client'
4
+ import { Alert, Field } from '../components/ui'
5
+
6
+ export default function ChangePasswordPage() {
7
+ const navigate = useNavigate()
8
+ const [password, setPassword] = useState('')
9
+ const [confirm, setConfirm] = useState('')
10
+ const [loading, setLoading] = useState(false)
11
+ const [error, setError] = useState('')
12
+
13
+ async function handleSubmit(e: React.FormEvent) {
14
+ e.preventDefault()
15
+ if (password !== confirm) { setError('Passwords do not match'); return }
16
+ if (password.length < 8) { setError('Password must be at least 8 characters'); return }
17
+ setError('')
18
+ setLoading(true)
19
+ try {
20
+ const res = await api.post<AuthUser>('/auth/change-password', { newPassword: password })
21
+ setSession(res)
22
+ enterAfterSignIn(res, navigate)
23
+ } catch (err) {
24
+ setError(err instanceof Error ? err.message : 'Failed to change password')
25
+ } finally {
26
+ setLoading(false)
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div className="min-h-screen flex items-center justify-center p-4" style={{ background: 'var(--m3-surface)' }}>
32
+ <div className="w-full max-w-sm">
33
+ <div className="rounded-2xl p-8" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
34
+ <div className="mb-6">
35
+ <div className="w-12 h-12 rounded-2xl flex items-center justify-center mb-4" style={{ background: 'var(--m3-on-surface)' }}>
36
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ color: 'var(--m3-surface)', fontSize: '22px' }}>lock_reset</span>
37
+ </div>
38
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>Set your password</h1>
39
+ <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>
40
+ {getCurrentUser()?.authMethod === 'oidc'
41
+ ? 'Password sign-in is enabled, so set your own password to replace the temporary one before continuing.'
42
+ : 'You must change your temporary password before continuing.'}
43
+ </p>
44
+ </div>
45
+
46
+ <form onSubmit={handleSubmit} className="space-y-4">
47
+ {error && <Alert tone="error">{error}</Alert>}
48
+ <Field label="New Password" hint="Minimum 8 characters">
49
+ <input
50
+ type="password"
51
+ autoComplete="new-password"
52
+ value={password}
53
+ onChange={(e) => setPassword(e.target.value)}
54
+ required
55
+ minLength={8}
56
+ className="input-sig w-full"
57
+ placeholder="Minimum 8 characters"
58
+ autoFocus
59
+ />
60
+ </Field>
61
+ <Field label="Confirm Password">
62
+ <input
63
+ type="password"
64
+ autoComplete="new-password"
65
+ value={confirm}
66
+ onChange={(e) => setConfirm(e.target.value)}
67
+ required
68
+ className="input-sig w-full"
69
+ placeholder="Repeat the password"
70
+ />
71
+ </Field>
72
+ <button
73
+ type="submit"
74
+ disabled={loading}
75
+ className="btn btn-primary w-full py-3 font-headline font-bold"
76
+ >
77
+ {loading ? 'Saving…' : 'Set Password & Continue'}
78
+ </button>
79
+ </form>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ )
84
+ }