@better-forge/ui 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,426 +1,426 @@
1
- import { useId, useState, type ComponentProps, type ReactNode } from 'react'
2
- import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
- import { api, getCurrentUser } from '../api/client'
4
- import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
5
- import { ConfirmModal } from '../components/ConfirmModal'
6
- import { CopyButton } from '../components/CopyButton'
7
- import { ResetTwoFactorModal } from '../components/ResetTwoFactorModal'
8
- import { SingleSignOnModal } from '../components/SingleSignOnModal'
9
- import { useSsoConfirmation } from '../components/SsoConfirmation'
10
- import { Alert, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState, PageContainer, PageHeader, useToast, DataTable, DataTh, useTableSort } from '../components/ui'
11
- import { formatDate } from '../lib/dateFormat'
12
-
13
- interface User {
14
- id: number
15
- email: string
16
- role: string
17
- pendingTemporaryPassword: boolean
18
- ssoLinked: boolean
19
- twoFactorEnabled: number
20
- createdAt: number
21
- }
22
-
23
- export interface UserRole { value: string; label: string; desc: string }
24
-
25
- /** A button in the header of the page that opens a dialog of the application's own (say, access settings). */
26
- export interface UsersPageAction { label: string; icon: string; dialog: (close: () => void) => ReactNode }
27
-
28
- export interface UsersPageProps {
29
- roles: readonly UserRole[]
30
- /** Higher number = more access; moving a user to a lower rank is a demotion and needs confirmation. */
31
- roleRank: Readonly<Record<string, number>>
32
- /** The role a new user starts with. */
33
- defaultRole: string
34
- actions?: readonly UsersPageAction[]
35
- }
36
-
37
- function errorMessage(err: unknown, fallback: string) {
38
- return err instanceof Error && err.message ? err.message : fallback
39
- }
40
-
41
- const ROW_ACTION = 'w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring'
42
-
43
- export default function UsersPage({ roles: ROLES, roleRank: ROLE_RANK, defaultRole, actions = [] }: UsersPageProps) {
44
- const roleLabel = (role: string) => ROLES.find((r) => r.value === role)?.label ?? role
45
- const qc = useQueryClient()
46
- const ssoConfirmation = useSsoConfirmation()
47
- const toast = useToast()
48
- const emailInputId = useId()
49
- const [showCreate, setShowCreate] = useState(false)
50
- const [showSso, setShowSso] = useState(false)
51
- const [openAction, setOpenAction] = useState<number | null>(null)
52
- const [newEmail, setNewEmail] = useState('')
53
- const [newRole, setNewRole] = useState(defaultRole)
54
- const [createdUser, setCreatedUser] = useState<{ email: string; temporaryPassword: string } | null>(null)
55
- const [resetResult, setResetResult] = useState<{ email: string; temporaryPassword: string } | null>(null)
56
- const [identityPrompt, setIdentityPrompt] = useState<Omit<ComponentProps<typeof ConfirmIdentityModal>, 'onClose'> | null>(null)
57
- const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
58
- const [twoFactorResetTarget, setTwoFactorResetTarget] = useState<User | null>(null)
59
- const [message, setMessage] = useState('')
60
-
61
- const { data: users = [], isLoading, isError, refetch } = useQuery<User[]>({
62
- queryKey: ['users'],
63
- queryFn: () => api.get('/admin/users'),
64
- })
65
- const { rows: sortedUsers, sort } = useTableSort(users, {
66
- email: (u) => u.email,
67
- role: (u) => ROLE_RANK[u.role] ?? 0,
68
- status: (u) => u.pendingTemporaryPassword,
69
- created: (u) => u.createdAt,
70
- })
71
-
72
- function confirmCreateUser() {
73
- const email = newEmail
74
- setIdentityPrompt({
75
- title: 'Create user',
76
- message: `Create ${email} as ${roleLabel(newRole)}? They will be able to sign in with a temporary password.`,
77
- confirmLabel: 'Create user',
78
- pendingLabel: 'Creating…',
79
- action: async (currentPassword) => {
80
- const created = await api.post<{ email: string; temporaryPassword: string }>('/admin/users', { email, role: newRole, currentPassword })
81
- qc.invalidateQueries({ queryKey: ['users'] })
82
- setCreatedUser(created)
83
- setShowCreate(false)
84
- setNewEmail('')
85
- },
86
- })
87
- }
88
-
89
- function confirmResetPassword(user: User) {
90
- setIdentityPrompt({
91
- title: 'Reset password',
92
- message: `Reset password for ${user.email}? Their current password will stop working.`,
93
- confirmLabel: 'Reset password',
94
- pendingLabel: 'Resetting…',
95
- danger: true,
96
- action: async (currentPassword) => {
97
- const reset = await api.post<{ temporaryPassword: string }>(`/admin/users/${user.id}/reset-password`, { currentPassword })
98
- setResetResult({ email: user.email, temporaryPassword: reset.temporaryPassword })
99
- qc.invalidateQueries({ queryKey: ['users'] })
100
- },
101
- })
102
- }
103
-
104
- const deleteMutation = useMutation({
105
- mutationFn: (id: number) => api.delete(`/admin/users/${id}`),
106
- onSuccess: (_data, id) => {
107
- const user = users.find((u) => u.id === id)
108
- toast.success(user ? `Deleted ${user.email}.` : 'User deleted.')
109
- setDeleteTarget(null)
110
- qc.invalidateQueries({ queryKey: ['users'] })
111
- },
112
- onError: (err) => {
113
- setDeleteTarget(null)
114
- toast.error(errorMessage(err, 'Failed to delete user'))
115
- },
116
- })
117
-
118
- const resetTwoFactorMutation = useMutation({
119
- mutationFn: ({ id, currentPassword }: { id: number; currentPassword: string | undefined }) =>
120
- ssoConfirmation.run(() => api.post<{ twoFactorEnabled: false }>(`/admin/users/${id}/reset-2fa`, currentPassword === undefined ? {} : { currentPassword })),
121
- onSuccess: (_data, variables) => {
122
- const user = users.find((candidate) => candidate.id === variables.id)
123
- setMessage(user ? `Two-factor authentication reset for ${user.email}.` : 'Two-factor authentication reset.')
124
- setTwoFactorResetTarget(null)
125
- qc.invalidateQueries({ queryKey: ['users'] })
126
- },
127
- })
128
-
129
- function changeRole(user: User, role: string) {
130
- if (user.role === role) return
131
- const demotion = (ROLE_RANK[role] ?? 0) < (ROLE_RANK[user.role] ?? 0)
132
- setIdentityPrompt({
133
- title: 'Change role',
134
- message: `Change ${user.email} from ${roleLabel(user.role)} to ${roleLabel(role)}?${demotion ? ' They will lose access to some areas immediately.' : ''}`,
135
- confirmLabel: `Make ${roleLabel(role)}`,
136
- pendingLabel: 'Saving…',
137
- danger: demotion,
138
- action: async (currentPassword) => {
139
- await api.patch(`/admin/users/${user.id}/role`, { role, currentPassword })
140
- toast.success(`${user.email} is now ${roleLabel(role)}.`)
141
- qc.invalidateQueries({ queryKey: ['users'] })
142
- },
143
- })
144
- }
145
-
146
- const currentUser = getCurrentUser()
147
-
148
- return (
149
- <PageContainer>
150
- <PageHeader
151
- title="Users"
152
- subtitle={isLoading || isError ? undefined : `${users.length} user${users.length !== 1 ? 's' : ''}`}
153
- actions={
154
- <>
155
- {actions.map((action, index) => (
156
- <button key={action.label} type="button" onClick={() => setOpenAction(index)} className="btn btn-secondary">
157
- <span className="material-symbols-outlined" aria-hidden="true">{action.icon}</span>
158
- {action.label}
159
- </button>
160
- ))}
161
- <button type="button" onClick={() => setShowSso(true)} className="btn btn-secondary">
162
- <span className="material-symbols-outlined" aria-hidden="true">passkey</span>
163
- Single sign-on
164
- </button>
165
- <button type="button" onClick={() => setShowCreate(true)} className="btn btn-primary">
166
- <span className="material-symbols-outlined" aria-hidden="true">person_add</span>
167
- New User
168
- </button>
169
- </>
170
- }
171
- />
172
-
173
- {showSso && <SingleSignOnModal onClose={() => setShowSso(false)} />}
174
- {openAction !== null && actions[openAction]?.dialog(() => setOpenAction(null))}
175
-
176
- {message && (
177
- <Alert tone="success" onDismiss={() => setMessage('')} className="max-w-2xl">{message}</Alert>
178
- )}
179
-
180
- {/* Create user form */}
181
- {showCreate && (
182
- <div className="rounded-2xl p-5" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
183
- <p className="font-headline font-semibold text-sm mb-3" style={{ color: 'var(--m3-on-surface)' }}>New User</p>
184
- <label htmlFor={emailInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Email</label>
185
- <div className="flex flex-wrap gap-3">
186
- <input
187
- id={emailInputId}
188
- type="email"
189
- value={newEmail}
190
- onChange={(e) => setNewEmail(e.target.value)}
191
- placeholder="user@example.com"
192
- className="input-sig flex-1 min-w-[200px]"
193
- onKeyDown={(e) => e.key === 'Enter' && newEmail && confirmCreateUser()}
194
- />
195
- <select aria-label="Role" value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-sig w-auto">
196
- {ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
197
- </select>
198
- <button
199
- type="button"
200
- onClick={() => newEmail && confirmCreateUser()}
201
- disabled={!newEmail}
202
- className="btn btn-primary"
203
- >
204
- Create
205
- </button>
206
- <button type="button" onClick={() => setShowCreate(false)} className="btn btn-ghost">
207
- Cancel
208
- </button>
209
- </div>
210
- <p className="text-xs mt-2" style={{ color: 'var(--m3-secondary)' }}>
211
- A temporary password will be generated. Share it with the user — they will be required to change it on first login.
212
- </p>
213
- </div>
214
- )}
215
-
216
- {/* Temp password display after creation */}
217
- {createdUser && (
218
- <Alert
219
- tone="success"
220
- title={<>User <strong>{createdUser.email}</strong> created</>}
221
- onDismiss={() => setCreatedUser(null)}
222
- >
223
- <p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
224
- Share this temporary password. It will not be shown again.
225
- </p>
226
- <div className="flex flex-wrap items-center gap-3">
227
- <code
228
- className="font-mono text-base px-4 py-2 rounded-xl select-all"
229
- style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
230
- >
231
- {createdUser.temporaryPassword}
232
- </code>
233
- <CopyButton value={createdUser.temporaryPassword} />
234
- </div>
235
- </Alert>
236
- )}
237
-
238
- {/* Reset password result */}
239
- {resetResult && (
240
- <Alert
241
- tone="info"
242
- title={<>Password reset for <strong>{resetResult.email}</strong></>}
243
- onDismiss={() => setResetResult(null)}
244
- >
245
- <p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
246
- New temporary password (shown once):
247
- </p>
248
- <div className="flex flex-wrap items-center gap-3">
249
- <code
250
- className="font-mono text-base px-4 py-2 rounded-xl select-all"
251
- style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
252
- >
253
- {resetResult.temporaryPassword}
254
- </code>
255
- <CopyButton value={resetResult.temporaryPassword} />
256
- </div>
257
- </Alert>
258
- )}
259
-
260
- {/* Users table */}
261
- {isLoading ? (
262
- <LoadingState label="Loading users…" />
263
- ) : isError ? (
264
- <ErrorState message="Could not load users." onRetry={() => void refetch()} />
265
- ) : (
266
- <div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
267
- <DataTable sort={sort} className="w-full min-w-[720px] text-sm">
268
- <thead>
269
- <tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
270
- {['Email', 'Role', 'Status', 'Created', ''].map((h) => (
271
- <DataTh
272
- key={h}
273
- resizable={h !== ''}
274
- {...(h !== '' && { sortKey: h.toLowerCase() })}
275
- className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
276
- style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
277
- >
278
- {h === '' ? <span className="sr-only">Actions</span> : h}
279
- </DataTh>
280
- ))}
281
- </tr>
282
- </thead>
283
- <tbody>
284
- {sortedUsers.map((user, i) => (
285
- <tr
286
- key={user.id}
287
- style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}
288
- onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--m3-surface-container)')}
289
- onMouseLeave={(e) => (e.currentTarget.style.background = '')}
290
- >
291
- <td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{user.email}</td>
292
- <td className="px-4 py-3">
293
- {currentUser?.userId === user.id ? (
294
- <span className="text-xs font-semibold px-2.5 py-1 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
295
- {roleLabel(user.role)}
296
- </span>
297
- ) : (
298
- <div role="group" aria-label={`Role for ${user.email}`} className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
299
- {ROLES.map((r) => {
300
- const active = user.role === r.value
301
- return (
302
- <button
303
- key={r.value}
304
- type="button"
305
- title={r.desc}
306
- aria-pressed={active}
307
- onClick={() => changeRole(user, r.value)}
308
- className={`text-xs font-medium px-3 py-1.5 transition-colors focus-ring ${active ? 'selection-active' : ''}`}
309
- style={{
310
- background: 'transparent',
311
- color: 'var(--m3-secondary)',
312
- cursor: active ? 'default' : 'pointer',
313
- }}
314
- >
315
- {r.label}
316
- </button>
317
- )
318
- })}
319
- </div>
320
- )}
321
- </td>
322
- <td className="px-4 py-3">
323
- <div className="flex items-center gap-2 flex-wrap">
324
- {user.pendingTemporaryPassword ? (
325
- <span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-degraded-bg)', color: 'var(--m3-degraded)' }}>
326
- Temp password
327
- </span>
328
- ) : (
329
- <span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-up-bg)', color: 'var(--m3-up)' }}>
330
- Active
331
- </span>
332
- )}
333
- {!!user.twoFactorEnabled && (
334
- <span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>2FA</span>
335
- )}
336
- {user.ssoLinked && (
337
- <span className="text-xs font-medium px-2 py-0.5 rounded-full" title="Signed in through single sign-on; matched by their identity-provider account" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>SSO</span>
338
- )}
339
- </div>
340
- </td>
341
- <td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
342
- {formatDate(user.createdAt)}
343
- </td>
344
- <td className="px-4 py-3">
345
- <div className="flex items-center justify-end gap-1">
346
- {!!user.twoFactorEnabled && currentUser?.userId !== user.id && (
347
- <button
348
- type="button"
349
- onClick={() => { resetTwoFactorMutation.reset(); setTwoFactorResetTarget(user); setMessage('') }}
350
- title="Reset 2FA"
351
- aria-label={`Reset 2FA for ${user.email}`}
352
- className={ROW_ACTION}
353
- style={{ color: 'var(--m3-secondary)' }}
354
- onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--m3-down-bg)'; event.currentTarget.style.color = 'var(--m3-down)' }}
355
- onMouseLeave={(event) => { event.currentTarget.style.background = ''; event.currentTarget.style.color = 'var(--m3-secondary)' }}
356
- >
357
- <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>no_encryption</span>
358
- </button>
359
- )}
360
- <button
361
- type="button"
362
- onClick={() => confirmResetPassword(user)}
363
- title="Reset password"
364
- aria-label={`Reset password for ${user.email}`}
365
- className={ROW_ACTION}
366
- style={{ color: 'var(--m3-secondary)' }}
367
- onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-surface-container-high)'; (e.currentTarget).style.color = 'var(--m3-on-surface)' }}
368
- onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
369
- >
370
- <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>lock_reset</span>
371
- </button>
372
- {currentUser?.userId !== user.id && (
373
- <button
374
- type="button"
375
- onClick={() => setDeleteTarget(user)}
376
- title="Delete user"
377
- aria-label={`Delete ${user.email}`}
378
- className={ROW_ACTION}
379
- style={{ color: 'var(--m3-secondary)' }}
380
- onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-error-container)'; (e.currentTarget).style.color = 'var(--m3-on-error-container)' }}
381
- onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
382
- >
383
- <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
384
- </button>
385
- )}
386
- </div>
387
- </td>
388
- </tr>
389
- ))}
390
- {users.length === 0 && (
391
- <EmptyTableRow
392
- colSpan={5}
393
- icon="group"
394
- title="No users yet."
395
- description={<><EmptyStateLink onClick={() => setShowCreate(true)}>Add a user</EmptyStateLink> to share access to the admin panel.</>}
396
- />
397
- )}
398
- </tbody>
399
- </DataTable>
400
- </div>
401
- )}
402
- {identityPrompt && <ConfirmIdentityModal {...identityPrompt} onClose={() => setIdentityPrompt(null)} />}
403
- {deleteTarget && (
404
- <ConfirmModal
405
- title="Delete user"
406
- message={`Delete user "${deleteTarget.email}"? This action cannot be undone.`}
407
- confirmLabel="Delete"
408
- pending={deleteMutation.isPending}
409
- pendingLabel="Deleting…"
410
- onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
411
- onCancel={() => setDeleteTarget(null)}
412
- />
413
- )}
414
- {twoFactorResetTarget && (
415
- <ResetTwoFactorModal
416
- email={twoFactorResetTarget.email}
417
- pending={resetTwoFactorMutation.isPending}
418
- {...(resetTwoFactorMutation.error ? { error: errorMessage(resetTwoFactorMutation.error, 'Failed to reset two-factor authentication') } : {})}
419
- ssoConfirmation={ssoConfirmation}
420
- onConfirm={(currentPassword) => resetTwoFactorMutation.mutate({ id: twoFactorResetTarget.id, currentPassword })}
421
- onCancel={() => { if (!resetTwoFactorMutation.isPending) setTwoFactorResetTarget(null) }}
422
- />
423
- )}
424
- </PageContainer>
425
- )
426
- }
1
+ import { useId, useState, type ComponentProps, type ReactNode } from 'react'
2
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { api, getCurrentUser } from '../api/client'
4
+ import { ConfirmIdentityModal } from '../components/ConfirmIdentityModal'
5
+ import { ConfirmModal } from '../components/ConfirmModal'
6
+ import { CopyButton } from '../components/CopyButton'
7
+ import { ResetTwoFactorModal } from '../components/ResetTwoFactorModal'
8
+ import { SingleSignOnModal } from '../components/SingleSignOnModal'
9
+ import { useSsoConfirmation } from '../components/SsoConfirmation'
10
+ import { Alert, EmptyStateLink, EmptyTableRow, ErrorState, LoadingState, PageContainer, PageHeader, useToast, DataTable, DataTh, useTableSort } from '../components/ui'
11
+ import { formatDate } from '../lib/dateFormat'
12
+
13
+ interface User {
14
+ id: number
15
+ email: string
16
+ role: string
17
+ pendingTemporaryPassword: boolean
18
+ ssoLinked: boolean
19
+ twoFactorEnabled: number
20
+ createdAt: number
21
+ }
22
+
23
+ export interface UserRole { value: string; label: string; desc: string }
24
+
25
+ /** A button in the header of the page that opens a dialog of the application's own (say, access settings). */
26
+ export interface UsersPageAction { label: string; icon: string; dialog: (close: () => void) => ReactNode }
27
+
28
+ export interface UsersPageProps {
29
+ roles: readonly UserRole[]
30
+ /** Higher number = more access; moving a user to a lower rank is a demotion and needs confirmation. */
31
+ roleRank: Readonly<Record<string, number>>
32
+ /** The role a new user starts with. */
33
+ defaultRole: string
34
+ actions?: readonly UsersPageAction[]
35
+ }
36
+
37
+ function errorMessage(err: unknown, fallback: string) {
38
+ return err instanceof Error && err.message ? err.message : fallback
39
+ }
40
+
41
+ const ROW_ACTION = 'w-8 h-8 flex items-center justify-center rounded-lg transition-colors text-xs focus-ring'
42
+
43
+ export default function UsersPage({ roles: ROLES, roleRank: ROLE_RANK, defaultRole, actions = [] }: UsersPageProps) {
44
+ const roleLabel = (role: string) => ROLES.find((r) => r.value === role)?.label ?? role
45
+ const qc = useQueryClient()
46
+ const ssoConfirmation = useSsoConfirmation()
47
+ const toast = useToast()
48
+ const emailInputId = useId()
49
+ const [showCreate, setShowCreate] = useState(false)
50
+ const [showSso, setShowSso] = useState(false)
51
+ const [openAction, setOpenAction] = useState<number | null>(null)
52
+ const [newEmail, setNewEmail] = useState('')
53
+ const [newRole, setNewRole] = useState(defaultRole)
54
+ const [createdUser, setCreatedUser] = useState<{ email: string; temporaryPassword: string } | null>(null)
55
+ const [resetResult, setResetResult] = useState<{ email: string; temporaryPassword: string } | null>(null)
56
+ const [identityPrompt, setIdentityPrompt] = useState<Omit<ComponentProps<typeof ConfirmIdentityModal>, 'onClose'> | null>(null)
57
+ const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
58
+ const [twoFactorResetTarget, setTwoFactorResetTarget] = useState<User | null>(null)
59
+ const [message, setMessage] = useState('')
60
+
61
+ const { data: users = [], isLoading, isError, refetch } = useQuery<User[]>({
62
+ queryKey: ['users'],
63
+ queryFn: () => api.get('/admin/users'),
64
+ })
65
+ const { rows: sortedUsers, sort } = useTableSort(users, {
66
+ email: (u) => u.email,
67
+ role: (u) => ROLE_RANK[u.role] ?? 0,
68
+ status: (u) => u.pendingTemporaryPassword,
69
+ created: (u) => u.createdAt,
70
+ })
71
+
72
+ function confirmCreateUser() {
73
+ const email = newEmail
74
+ setIdentityPrompt({
75
+ title: 'Create user',
76
+ message: `Create ${email} as ${roleLabel(newRole)}? They will be able to sign in with a temporary password.`,
77
+ confirmLabel: 'Create user',
78
+ pendingLabel: 'Creating…',
79
+ action: async (currentPassword) => {
80
+ const created = await api.post<{ email: string; temporaryPassword: string }>('/admin/users', { email, role: newRole, currentPassword })
81
+ qc.invalidateQueries({ queryKey: ['users'] })
82
+ setCreatedUser(created)
83
+ setShowCreate(false)
84
+ setNewEmail('')
85
+ },
86
+ })
87
+ }
88
+
89
+ function confirmResetPassword(user: User) {
90
+ setIdentityPrompt({
91
+ title: 'Reset password',
92
+ message: `Reset password for ${user.email}? Their current password will stop working.`,
93
+ confirmLabel: 'Reset password',
94
+ pendingLabel: 'Resetting…',
95
+ danger: true,
96
+ action: async (currentPassword) => {
97
+ const reset = await api.post<{ temporaryPassword: string }>(`/admin/users/${user.id}/reset-password`, { currentPassword })
98
+ setResetResult({ email: user.email, temporaryPassword: reset.temporaryPassword })
99
+ qc.invalidateQueries({ queryKey: ['users'] })
100
+ },
101
+ })
102
+ }
103
+
104
+ const deleteMutation = useMutation({
105
+ mutationFn: (id: number) => api.delete(`/admin/users/${id}`),
106
+ onSuccess: (_data, id) => {
107
+ const user = users.find((u) => u.id === id)
108
+ toast.success(user ? `Deleted ${user.email}.` : 'User deleted.')
109
+ setDeleteTarget(null)
110
+ qc.invalidateQueries({ queryKey: ['users'] })
111
+ },
112
+ onError: (err) => {
113
+ setDeleteTarget(null)
114
+ toast.error(errorMessage(err, 'Failed to delete user'))
115
+ },
116
+ })
117
+
118
+ const resetTwoFactorMutation = useMutation({
119
+ mutationFn: ({ id, currentPassword }: { id: number; currentPassword: string | undefined }) =>
120
+ ssoConfirmation.run(() => api.post<{ twoFactorEnabled: false }>(`/admin/users/${id}/reset-2fa`, currentPassword === undefined ? {} : { currentPassword })),
121
+ onSuccess: (_data, variables) => {
122
+ const user = users.find((candidate) => candidate.id === variables.id)
123
+ setMessage(user ? `Two-factor authentication reset for ${user.email}.` : 'Two-factor authentication reset.')
124
+ setTwoFactorResetTarget(null)
125
+ qc.invalidateQueries({ queryKey: ['users'] })
126
+ },
127
+ })
128
+
129
+ function changeRole(user: User, role: string) {
130
+ if (user.role === role) return
131
+ const demotion = (ROLE_RANK[role] ?? 0) < (ROLE_RANK[user.role] ?? 0)
132
+ setIdentityPrompt({
133
+ title: 'Change role',
134
+ message: `Change ${user.email} from ${roleLabel(user.role)} to ${roleLabel(role)}?${demotion ? ' They will lose access to some areas immediately.' : ''}`,
135
+ confirmLabel: `Make ${roleLabel(role)}`,
136
+ pendingLabel: 'Saving…',
137
+ danger: demotion,
138
+ action: async (currentPassword) => {
139
+ await api.patch(`/admin/users/${user.id}/role`, { role, currentPassword })
140
+ toast.success(`${user.email} is now ${roleLabel(role)}.`)
141
+ qc.invalidateQueries({ queryKey: ['users'] })
142
+ },
143
+ })
144
+ }
145
+
146
+ const currentUser = getCurrentUser()
147
+
148
+ return (
149
+ <PageContainer>
150
+ <PageHeader
151
+ title="Users"
152
+ subtitle={isLoading || isError ? undefined : `${users.length} user${users.length !== 1 ? 's' : ''}`}
153
+ actions={
154
+ <>
155
+ {actions.map((action, index) => (
156
+ <button key={action.label} type="button" onClick={() => setOpenAction(index)} className="btn btn-secondary">
157
+ <span className="material-symbols-outlined" aria-hidden="true">{action.icon}</span>
158
+ {action.label}
159
+ </button>
160
+ ))}
161
+ <button type="button" onClick={() => setShowSso(true)} className="btn btn-secondary">
162
+ <span className="material-symbols-outlined" aria-hidden="true">passkey</span>
163
+ Single sign-on
164
+ </button>
165
+ <button type="button" onClick={() => setShowCreate(true)} className="btn btn-primary">
166
+ <span className="material-symbols-outlined" aria-hidden="true">person_add</span>
167
+ New User
168
+ </button>
169
+ </>
170
+ }
171
+ />
172
+
173
+ {showSso && <SingleSignOnModal onClose={() => setShowSso(false)} />}
174
+ {openAction !== null && actions[openAction]?.dialog(() => setOpenAction(null))}
175
+
176
+ {message && (
177
+ <Alert tone="success" onDismiss={() => setMessage('')} className="max-w-2xl">{message}</Alert>
178
+ )}
179
+
180
+ {/* Create user form */}
181
+ {showCreate && (
182
+ <div className="rounded-2xl p-5" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
183
+ <p className="font-headline font-semibold text-sm mb-3" style={{ color: 'var(--m3-on-surface)' }}>New User</p>
184
+ <label htmlFor={emailInputId} className="block text-xs mb-1.5" style={{ color: 'var(--m3-secondary)' }}>Email</label>
185
+ <div className="flex flex-wrap gap-3">
186
+ <input
187
+ id={emailInputId}
188
+ type="email"
189
+ value={newEmail}
190
+ onChange={(e) => setNewEmail(e.target.value)}
191
+ placeholder="user@example.com"
192
+ className="input-sig flex-1 min-w-[200px]"
193
+ onKeyDown={(e) => e.key === 'Enter' && newEmail && confirmCreateUser()}
194
+ />
195
+ <select aria-label="Role" value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-sig w-auto">
196
+ {ROLES.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
197
+ </select>
198
+ <button
199
+ type="button"
200
+ onClick={() => newEmail && confirmCreateUser()}
201
+ disabled={!newEmail}
202
+ className="btn btn-primary"
203
+ >
204
+ Create
205
+ </button>
206
+ <button type="button" onClick={() => setShowCreate(false)} className="btn btn-ghost">
207
+ Cancel
208
+ </button>
209
+ </div>
210
+ <p className="text-xs mt-2" style={{ color: 'var(--m3-secondary)' }}>
211
+ A temporary password will be generated. Share it with the user — they will be required to change it on first login.
212
+ </p>
213
+ </div>
214
+ )}
215
+
216
+ {/* Temp password display after creation */}
217
+ {createdUser && (
218
+ <Alert
219
+ tone="success"
220
+ title={<>User <strong>{createdUser.email}</strong> created</>}
221
+ onDismiss={() => setCreatedUser(null)}
222
+ >
223
+ <p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
224
+ Share this temporary password. It will not be shown again.
225
+ </p>
226
+ <div className="flex flex-wrap items-center gap-3">
227
+ <code
228
+ className="font-mono text-base px-4 py-2 rounded-xl select-all"
229
+ style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
230
+ >
231
+ {createdUser.temporaryPassword}
232
+ </code>
233
+ <CopyButton value={createdUser.temporaryPassword} />
234
+ </div>
235
+ </Alert>
236
+ )}
237
+
238
+ {/* Reset password result */}
239
+ {resetResult && (
240
+ <Alert
241
+ tone="info"
242
+ title={<>Password reset for <strong>{resetResult.email}</strong></>}
243
+ onDismiss={() => setResetResult(null)}
244
+ >
245
+ <p className="text-xs mb-3" style={{ color: 'var(--m3-secondary)' }}>
246
+ New temporary password (shown once):
247
+ </p>
248
+ <div className="flex flex-wrap items-center gap-3">
249
+ <code
250
+ className="font-mono text-base px-4 py-2 rounded-xl select-all"
251
+ style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)', letterSpacing: '0.05em' }}
252
+ >
253
+ {resetResult.temporaryPassword}
254
+ </code>
255
+ <CopyButton value={resetResult.temporaryPassword} />
256
+ </div>
257
+ </Alert>
258
+ )}
259
+
260
+ {/* Users table */}
261
+ {isLoading ? (
262
+ <LoadingState label="Loading users…" />
263
+ ) : isError ? (
264
+ <ErrorState message="Could not load users." onRetry={() => void refetch()} />
265
+ ) : (
266
+ <div className="rounded-2xl overflow-x-auto" style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}>
267
+ <DataTable sort={sort} className="w-full min-w-[720px] text-sm">
268
+ <thead>
269
+ <tr style={{ borderBottom: '1px solid var(--m3-outline-variant)' }}>
270
+ {['Email', 'Role', 'Status', 'Created', ''].map((h) => (
271
+ <DataTh
272
+ key={h}
273
+ resizable={h !== ''}
274
+ {...(h !== '' && { sortKey: h.toLowerCase() })}
275
+ className={`px-4 py-3 font-mono text-xs uppercase tracking-wider ${h === '' ? 'text-right' : 'text-left'}`}
276
+ style={{ color: 'var(--m3-secondary)', background: 'var(--m3-surface-container)' }}
277
+ >
278
+ {h === '' ? <span className="sr-only">Actions</span> : h}
279
+ </DataTh>
280
+ ))}
281
+ </tr>
282
+ </thead>
283
+ <tbody>
284
+ {sortedUsers.map((user, i) => (
285
+ <tr
286
+ key={user.id}
287
+ style={{ borderTop: i > 0 ? '1px solid var(--m3-outline-variant)' : 'none' }}
288
+ onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--m3-surface-container)')}
289
+ onMouseLeave={(e) => (e.currentTarget.style.background = '')}
290
+ >
291
+ <td className="px-4 py-3 font-medium" style={{ color: 'var(--m3-on-surface)' }}>{user.email}</td>
292
+ <td className="px-4 py-3">
293
+ {currentUser?.userId === user.id ? (
294
+ <span className="text-xs font-semibold px-2.5 py-1 rounded-lg" style={{ background: 'var(--m3-surface-container)', color: 'var(--m3-on-surface)' }}>
295
+ {roleLabel(user.role)}
296
+ </span>
297
+ ) : (
298
+ <div role="group" aria-label={`Role for ${user.email}`} className="flex rounded-lg overflow-hidden" style={{ border: '1px solid var(--m3-outline-variant)', width: 'fit-content' }}>
299
+ {ROLES.map((r) => {
300
+ const active = user.role === r.value
301
+ return (
302
+ <button
303
+ key={r.value}
304
+ type="button"
305
+ title={r.desc}
306
+ aria-pressed={active}
307
+ onClick={() => changeRole(user, r.value)}
308
+ className={`text-xs font-medium px-3 py-1.5 transition-colors focus-ring ${active ? 'selection-active' : ''}`}
309
+ style={{
310
+ background: 'transparent',
311
+ color: 'var(--m3-secondary)',
312
+ cursor: active ? 'default' : 'pointer',
313
+ }}
314
+ >
315
+ {r.label}
316
+ </button>
317
+ )
318
+ })}
319
+ </div>
320
+ )}
321
+ </td>
322
+ <td className="px-4 py-3">
323
+ <div className="flex items-center gap-2 flex-wrap">
324
+ {user.pendingTemporaryPassword ? (
325
+ <span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-degraded-bg)', color: 'var(--m3-degraded)' }}>
326
+ Temp password
327
+ </span>
328
+ ) : (
329
+ <span className="text-xs font-medium px-2 py-0.5 rounded-full whitespace-nowrap" style={{ background: 'var(--m3-up-bg)', color: 'var(--m3-up)' }}>
330
+ Active
331
+ </span>
332
+ )}
333
+ {!!user.twoFactorEnabled && (
334
+ <span className="text-xs font-medium px-2 py-0.5 rounded-full" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>2FA</span>
335
+ )}
336
+ {user.ssoLinked && (
337
+ <span className="text-xs font-medium px-2 py-0.5 rounded-full" title="Signed in through single sign-on; matched by their identity-provider account" style={{ background: 'var(--m3-surface-container-high)', color: 'var(--m3-on-surface)' }}>SSO</span>
338
+ )}
339
+ </div>
340
+ </td>
341
+ <td className="px-4 py-3 font-mono text-xs" style={{ color: 'var(--m3-secondary)' }}>
342
+ {formatDate(user.createdAt)}
343
+ </td>
344
+ <td className="px-4 py-3">
345
+ <div className="flex items-center justify-end gap-1">
346
+ {!!user.twoFactorEnabled && currentUser?.userId !== user.id && (
347
+ <button
348
+ type="button"
349
+ onClick={() => { resetTwoFactorMutation.reset(); setTwoFactorResetTarget(user); setMessage('') }}
350
+ title="Reset 2FA"
351
+ aria-label={`Reset 2FA for ${user.email}`}
352
+ className={ROW_ACTION}
353
+ style={{ color: 'var(--m3-secondary)' }}
354
+ onMouseEnter={(event) => { event.currentTarget.style.background = 'var(--m3-down-bg)'; event.currentTarget.style.color = 'var(--m3-down)' }}
355
+ onMouseLeave={(event) => { event.currentTarget.style.background = ''; event.currentTarget.style.color = 'var(--m3-secondary)' }}
356
+ >
357
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>no_encryption</span>
358
+ </button>
359
+ )}
360
+ <button
361
+ type="button"
362
+ onClick={() => confirmResetPassword(user)}
363
+ title="Reset password"
364
+ aria-label={`Reset password for ${user.email}`}
365
+ className={ROW_ACTION}
366
+ style={{ color: 'var(--m3-secondary)' }}
367
+ onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-surface-container-high)'; (e.currentTarget).style.color = 'var(--m3-on-surface)' }}
368
+ onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
369
+ >
370
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>lock_reset</span>
371
+ </button>
372
+ {currentUser?.userId !== user.id && (
373
+ <button
374
+ type="button"
375
+ onClick={() => setDeleteTarget(user)}
376
+ title="Delete user"
377
+ aria-label={`Delete ${user.email}`}
378
+ className={ROW_ACTION}
379
+ style={{ color: 'var(--m3-secondary)' }}
380
+ onMouseEnter={(e) => { (e.currentTarget).style.background = 'var(--m3-error-container)'; (e.currentTarget).style.color = 'var(--m3-on-error-container)' }}
381
+ onMouseLeave={(e) => { (e.currentTarget).style.background = ''; (e.currentTarget).style.color = 'var(--m3-secondary)' }}
382
+ >
383
+ <span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '16px' }}>delete</span>
384
+ </button>
385
+ )}
386
+ </div>
387
+ </td>
388
+ </tr>
389
+ ))}
390
+ {users.length === 0 && (
391
+ <EmptyTableRow
392
+ colSpan={5}
393
+ icon="group"
394
+ title="No users yet."
395
+ description={<><EmptyStateLink onClick={() => setShowCreate(true)}>Add a user</EmptyStateLink> to share access to the admin panel.</>}
396
+ />
397
+ )}
398
+ </tbody>
399
+ </DataTable>
400
+ </div>
401
+ )}
402
+ {identityPrompt && <ConfirmIdentityModal {...identityPrompt} onClose={() => setIdentityPrompt(null)} />}
403
+ {deleteTarget && (
404
+ <ConfirmModal
405
+ title="Delete user"
406
+ message={`Delete user "${deleteTarget.email}"? This action cannot be undone.`}
407
+ confirmLabel="Delete"
408
+ pending={deleteMutation.isPending}
409
+ pendingLabel="Deleting…"
410
+ onConfirm={() => deleteMutation.mutate(deleteTarget.id)}
411
+ onCancel={() => setDeleteTarget(null)}
412
+ />
413
+ )}
414
+ {twoFactorResetTarget && (
415
+ <ResetTwoFactorModal
416
+ email={twoFactorResetTarget.email}
417
+ pending={resetTwoFactorMutation.isPending}
418
+ {...(resetTwoFactorMutation.error ? { error: errorMessage(resetTwoFactorMutation.error, 'Failed to reset two-factor authentication') } : {})}
419
+ ssoConfirmation={ssoConfirmation}
420
+ onConfirm={(currentPassword) => resetTwoFactorMutation.mutate({ id: twoFactorResetTarget.id, currentPassword })}
421
+ onCancel={() => { if (!resetTwoFactorMutation.isPending) setTwoFactorResetTarget(null) }}
422
+ />
423
+ )}
424
+ </PageContainer>
425
+ )
426
+ }