@actuate-media/cms-admin 0.29.0 → 0.31.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 (46) hide show
  1. package/dist/AdminRoot.d.ts +6 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -3
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  8. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  9. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/user-detail-drawer.render.test.js +113 -0
  12. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
  13. package/dist/__tests__/views/users.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/users.render.test.js +146 -0
  16. package/dist/__tests__/views/users.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/layout/Header.d.ts +3 -1
  19. package/dist/layout/Header.d.ts.map +1 -1
  20. package/dist/layout/Header.js +2 -2
  21. package/dist/layout/Header.js.map +1 -1
  22. package/dist/layout/Layout.d.ts +2 -1
  23. package/dist/layout/Layout.d.ts.map +1 -1
  24. package/dist/layout/Layout.js +2 -2
  25. package/dist/layout/Layout.js.map +1 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +54 -51
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/Users.d.ts +1 -3
  30. package/dist/views/Users.d.ts.map +1 -1
  31. package/dist/views/Users.js +420 -56
  32. package/dist/views/Users.js.map +1 -1
  33. package/dist/views/users/UserDetailDrawer.d.ts +9 -0
  34. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
  35. package/dist/views/users/UserDetailDrawer.js +236 -0
  36. package/dist/views/users/UserDetailDrawer.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/AdminRoot.tsx +28 -2
  39. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  40. package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
  41. package/src/__tests__/views/users.render.test.tsx +155 -0
  42. package/src/layout/Header.tsx +10 -2
  43. package/src/layout/Layout.tsx +10 -1
  44. package/src/views/Settings.tsx +142 -263
  45. package/src/views/Users.tsx +1187 -271
  46. package/src/views/users/UserDetailDrawer.tsx +924 -0
@@ -0,0 +1,924 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * User detail drawer — opened from a Team members row. Five tabs:
5
+ *
6
+ * Overview — identity, role, status, lifecycle facts, content owned.
7
+ * Security — MFA / password / lock state + admin security actions.
8
+ * Sessions — active sessions with per-session + bulk revoke.
9
+ * Activity — recent audit events where the user is actor or target.
10
+ * Permissions — read-only effective role + permission scopes.
11
+ *
12
+ * Every mutating action is permission-checked and audited server-side. The
13
+ * high-impact ones (MFA reset, Admin/Owner password reset) return a 401
14
+ * "reauth required" which this component handles by prompting for the admin's
15
+ * password and retrying with the `x-reauth-password` header.
16
+ */
17
+ import * as Tabs from '@radix-ui/react-tabs'
18
+ import {
19
+ ShieldCheck,
20
+ ShieldAlert,
21
+ KeyRound,
22
+ Lock,
23
+ Unlock,
24
+ Ban,
25
+ Monitor,
26
+ LogOut,
27
+ Activity as ActivityIcon,
28
+ FileText,
29
+ AlertTriangle,
30
+ Check,
31
+ } from 'lucide-react'
32
+ import { useCallback, useState, type ReactNode } from 'react'
33
+ import { toast } from 'sonner'
34
+ import { cmsApi } from '../../lib/api.js'
35
+ import { useApiData } from '../../lib/useApiData.js'
36
+ import { Drawer } from '../../components/forms/Drawer.js'
37
+ import { Modal } from '../../components/ui/Modal.js'
38
+ import { Button } from '../../components/ui/Button.js'
39
+ import { Skeleton } from '../../components/ui/Skeleton.js'
40
+ import { EmptyState } from '../../components/ui/EmptyState.js'
41
+ import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Types (mirror cms-core serialized shapes)
45
+ // ---------------------------------------------------------------------------
46
+
47
+ interface UserDetail {
48
+ id: string
49
+ name: string
50
+ email: string
51
+ avatarUrl: string | null
52
+ initials: string
53
+ role: string
54
+ roleName: 'Owner' | 'Admin' | 'Editor' | 'Viewer'
55
+ status: 'active' | 'suspended' | 'locked' | 'deactivated'
56
+ mfaEnabled: boolean
57
+ mfaRequired: boolean
58
+ passwordSet: boolean
59
+ forcePasswordReset: boolean
60
+ failedLoginCount: number
61
+ lockedAt: string | null
62
+ suspendedAt: string | null
63
+ authProvider: string | null
64
+ oauthProviders: string[]
65
+ ssoOnly: boolean
66
+ emailVerified: boolean
67
+ lastLoginAt: string | null
68
+ lastActiveAt: string | null
69
+ acceptedInviteAt: string | null
70
+ createdAt: string
71
+ invitedBy: { id: string; name: string; email: string } | null
72
+ externalDomain: boolean
73
+ contentOwnedCount: number
74
+ isCurrentUser: boolean
75
+ }
76
+
77
+ interface SessionInfo {
78
+ id: string
79
+ deviceLabel: string
80
+ browser: string
81
+ os: string
82
+ ipCountry: string | null
83
+ createdAt: string
84
+ lastSeenAt: string
85
+ expiresAt: string
86
+ revokedAt: string | null
87
+ current: boolean
88
+ active: boolean
89
+ }
90
+
91
+ interface ActivityEvent {
92
+ id: string
93
+ event: string
94
+ role: 'actor' | 'target'
95
+ ipAddress: string | null
96
+ timestamp: string
97
+ details: Record<string, unknown> | null
98
+ }
99
+
100
+ interface PermissionView {
101
+ role: string
102
+ roleName: string
103
+ permissions: string[]
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Formatting helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ function relativeTime(iso: string | null): string {
111
+ if (!iso) return 'Never'
112
+ const then = new Date(iso).getTime()
113
+ if (Number.isNaN(then)) return '—'
114
+ const diffMs = Date.now() - then
115
+ const mins = Math.floor(diffMs / 60000)
116
+ if (mins < 5) return 'Just now'
117
+ if (mins < 60) return `${mins}m ago`
118
+ const hours = Math.floor(mins / 60)
119
+ if (hours < 24) return `${hours}h ago`
120
+ const days = Math.floor(hours / 24)
121
+ if (days === 1) return 'Yesterday'
122
+ return `${days} days ago`
123
+ }
124
+
125
+ function formatDate(iso: string | null): string {
126
+ if (!iso) return '—'
127
+ const d = new Date(iso)
128
+ if (Number.isNaN(d.getTime())) return '—'
129
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
130
+ }
131
+
132
+ function humanizeEvent(event: string): string {
133
+ return event.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase())
134
+ }
135
+
136
+ const ROLE_PILL: Record<string, string> = {
137
+ Owner: 'bg-brand/10 text-brand',
138
+ Admin: 'bg-info/10 text-info',
139
+ Editor: 'bg-accent text-accent-foreground',
140
+ Viewer: 'bg-muted text-muted-foreground',
141
+ }
142
+
143
+ const STATUS_META: Record<UserDetail['status'], { dot: string; text: string; label: string }> = {
144
+ active: { dot: 'bg-success', text: 'text-success', label: 'Active' },
145
+ suspended: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', label: 'Suspended' },
146
+ locked: { dot: 'bg-destructive', text: 'text-destructive', label: 'Locked' },
147
+ deactivated: {
148
+ dot: 'bg-muted-foreground',
149
+ text: 'text-muted-foreground',
150
+ label: 'Deactivated',
151
+ },
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Reauth dialog
156
+ // ---------------------------------------------------------------------------
157
+
158
+ function ReauthDialog({
159
+ open,
160
+ onClose,
161
+ onSubmit,
162
+ }: {
163
+ open: boolean
164
+ onClose: () => void
165
+ onSubmit: (password: string) => Promise<void>
166
+ }) {
167
+ const [password, setPassword] = useState('')
168
+ const [busy, setBusy] = useState(false)
169
+
170
+ const submit = async () => {
171
+ if (!password || busy) return
172
+ setBusy(true)
173
+ try {
174
+ await onSubmit(password)
175
+ setPassword('')
176
+ } finally {
177
+ setBusy(false)
178
+ }
179
+ }
180
+
181
+ return (
182
+ <Modal
183
+ open={open}
184
+ onClose={() => {
185
+ setPassword('')
186
+ onClose()
187
+ }}
188
+ title="Confirm it's you"
189
+ actions={
190
+ <>
191
+ <Button
192
+ variant="ghost"
193
+ onClick={() => {
194
+ setPassword('')
195
+ onClose()
196
+ }}
197
+ >
198
+ Cancel
199
+ </Button>
200
+ <Button variant="primary" loading={busy} disabled={!password || busy} onClick={submit}>
201
+ Confirm
202
+ </Button>
203
+ </>
204
+ }
205
+ >
206
+ <p className="text-muted-foreground mb-3 text-sm">
207
+ This is a sensitive action. Re-enter your password to continue.
208
+ </p>
209
+ <label
210
+ htmlFor="reauth-password"
211
+ className="text-card-foreground mb-1 block text-sm font-medium"
212
+ >
213
+ Your password
214
+ </label>
215
+ <input
216
+ id="reauth-password"
217
+ type="password"
218
+ value={password}
219
+ onChange={(e) => setPassword(e.target.value)}
220
+ onKeyDown={(e) => {
221
+ if (e.key === 'Enter') submit()
222
+ }}
223
+ autoComplete="current-password"
224
+ className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
225
+ />
226
+ </Modal>
227
+ )
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Small presentational bits
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function Fact({ label, value }: { label: string; value: ReactNode }) {
235
+ return (
236
+ <div className="min-w-0">
237
+ <dt className="text-muted-foreground text-xs">{label}</dt>
238
+ <dd className="text-card-foreground mt-0.5 truncate text-sm">{value}</dd>
239
+ </div>
240
+ )
241
+ }
242
+
243
+ function SecurityRow({
244
+ icon,
245
+ title,
246
+ description,
247
+ action,
248
+ }: {
249
+ icon: ReactNode
250
+ title: string
251
+ description: string
252
+ action: ReactNode
253
+ }) {
254
+ return (
255
+ <div className="border-border flex items-center justify-between gap-3 border-b py-3 last:border-b-0">
256
+ <div className="flex min-w-0 items-start gap-2.5">
257
+ <span className="text-muted-foreground mt-0.5 shrink-0">{icon}</span>
258
+ <div className="min-w-0">
259
+ <div className="text-card-foreground text-sm font-medium">{title}</div>
260
+ <div className="text-muted-foreground text-xs">{description}</div>
261
+ </div>
262
+ </div>
263
+ <div className="shrink-0">{action}</div>
264
+ </div>
265
+ )
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Drawer
270
+ // ---------------------------------------------------------------------------
271
+
272
+ const TABS = [
273
+ { id: 'overview', label: 'Overview' },
274
+ { id: 'security', label: 'Security' },
275
+ { id: 'sessions', label: 'Sessions' },
276
+ { id: 'activity', label: 'Activity' },
277
+ { id: 'permissions', label: 'Permissions' },
278
+ ] as const
279
+
280
+ export interface UserDetailDrawerProps {
281
+ userId: string | null
282
+ open: boolean
283
+ onOpenChange: (open: boolean) => void
284
+ /** Refetch the parent table after any change to this user. */
285
+ onChanged?: () => void
286
+ }
287
+
288
+ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: UserDetailDrawerProps) {
289
+ const [tab, setTab] = useState<string>('overview')
290
+
291
+ const detailEp = open && userId ? `/users/${userId}` : null
292
+ const detail = useApiData<UserDetail>(detailEp)
293
+
294
+ const sessionsEp = open && userId && tab === 'sessions' ? `/users/${userId}/sessions` : null
295
+ const sessions = useApiData<SessionInfo[]>(sessionsEp)
296
+
297
+ const activityEp = open && userId && tab === 'activity' ? `/users/${userId}/activity` : null
298
+ const activity = useApiData<ActivityEvent[]>(activityEp)
299
+
300
+ const permsEp = open && userId && tab === 'permissions' ? `/users/${userId}/permissions` : null
301
+ const perms = useApiData<PermissionView>(permsEp)
302
+
303
+ const [reauth, setReauth] = useState<{ run: (password: string) => Promise<void> } | null>(null)
304
+ const [confirm, setConfirm] = useState<{
305
+ title: string
306
+ description: string
307
+ confirmLabel: string
308
+ destructive?: boolean
309
+ onConfirm: () => void
310
+ } | null>(null)
311
+ const [pending, setPending] = useState<string | null>(null)
312
+
313
+ const refreshAfterChange = useCallback(() => {
314
+ detail.refetch()
315
+ onChanged?.()
316
+ }, [detail, onChanged])
317
+
318
+ /**
319
+ * Run a mutating action. If the server demands reauthentication (401), prompt
320
+ * for the password and retry once with the `x-reauth-password` header.
321
+ */
322
+ const runAction = useCallback(
323
+ async (
324
+ key: string,
325
+ path: string,
326
+ options: RequestInit,
327
+ { successMsg, after }: { successMsg: string; after?: () => void },
328
+ ) => {
329
+ setPending(key)
330
+ try {
331
+ const res = await cmsApi(path, options)
332
+ if (res.status === 401 && /re-?authenticat/i.test(res.error ?? '')) {
333
+ setReauth({
334
+ run: async (password: string) => {
335
+ const retry = await cmsApi(path, {
336
+ ...options,
337
+ headers: { ...(options.headers ?? {}), 'x-reauth-password': password },
338
+ })
339
+ if (retry.error) {
340
+ toast.error(retry.error)
341
+ return
342
+ }
343
+ toast.success(successMsg)
344
+ setReauth(null)
345
+ after?.()
346
+ },
347
+ })
348
+ return
349
+ }
350
+ if (res.error) {
351
+ toast.error(res.error)
352
+ return
353
+ }
354
+ toast.success(successMsg)
355
+ after?.()
356
+ } finally {
357
+ setPending(null)
358
+ }
359
+ },
360
+ [],
361
+ )
362
+
363
+ const u = detail.data
364
+
365
+ return (
366
+ <>
367
+ <Drawer
368
+ open={open}
369
+ onOpenChange={onOpenChange}
370
+ width="max-w-xl"
371
+ title={u ? u.name : detail.loading ? 'Loading…' : 'User'}
372
+ description={u?.email}
373
+ headerExtra={
374
+ u ? (
375
+ <span
376
+ className={`mr-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
377
+ ROLE_PILL[u.roleName] ?? ROLE_PILL.Viewer
378
+ }`}
379
+ >
380
+ {u.roleName}
381
+ </span>
382
+ ) : undefined
383
+ }
384
+ >
385
+ {detail.loading ? (
386
+ <div className="space-y-3">
387
+ <Skeleton variant="card" />
388
+ <Skeleton variant="table-row" lines={4} />
389
+ </div>
390
+ ) : detail.error || !u ? (
391
+ <div className="flex items-center gap-3 py-6">
392
+ <AlertTriangle size={18} className="text-destructive shrink-0" aria-hidden />
393
+ <span className="text-destructive flex-1 text-sm">
394
+ {detail.error ?? 'User not found.'}
395
+ </span>
396
+ <Button variant="outline" size="sm" onClick={() => detail.refetch()}>
397
+ Retry
398
+ </Button>
399
+ </div>
400
+ ) : (
401
+ <Tabs.Root value={tab} onValueChange={setTab}>
402
+ <Tabs.List
403
+ className="border-border mb-4 flex gap-1 overflow-x-auto border-b"
404
+ aria-label="User detail sections"
405
+ >
406
+ {TABS.map((t) => (
407
+ <Tabs.Trigger
408
+ key={t.id}
409
+ value={t.id}
410
+ className="text-muted-foreground hover:text-foreground data-[state=active]:text-foreground data-[state=active]:border-brand focus-visible:ring-ring -mb-px shrink-0 border-b-2 border-transparent px-3 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
411
+ >
412
+ {t.label}
413
+ </Tabs.Trigger>
414
+ ))}
415
+ </Tabs.List>
416
+
417
+ {/* Overview ----------------------------------------------------- */}
418
+ <Tabs.Content value="overview" tabIndex={-1} className="focus:outline-none">
419
+ <div className="mb-4 flex items-center gap-2">
420
+ <span
421
+ className={`inline-flex items-center gap-1.5 text-sm ${STATUS_META[u.status].text}`}
422
+ >
423
+ <span
424
+ className={`h-2 w-2 rounded-full ${STATUS_META[u.status].dot}`}
425
+ aria-hidden
426
+ />
427
+ {STATUS_META[u.status].label}
428
+ </span>
429
+ {u.isCurrentUser && (
430
+ <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs">
431
+ You
432
+ </span>
433
+ )}
434
+ {u.externalDomain && (
435
+ <span className="bg-warning/10 text-warning rounded px-1.5 py-0.5 text-xs">
436
+ External domain
437
+ </span>
438
+ )}
439
+ </div>
440
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-4">
441
+ <Fact label="Role" value={u.roleName} />
442
+ <Fact label="Status" value={STATUS_META[u.status].label} />
443
+ <Fact label="Last active" value={relativeTime(u.lastActiveAt)} />
444
+ <Fact label="Last sign-in" value={relativeTime(u.lastLoginAt)} />
445
+ <Fact label="Created" value={formatDate(u.createdAt)} />
446
+ <Fact label="Accepted invite" value={formatDate(u.acceptedInviteAt)} />
447
+ <Fact
448
+ label="Invited by"
449
+ value={
450
+ u.invitedBy ? (
451
+ u.invitedBy.name
452
+ ) : (
453
+ <span className="text-muted-foreground">—</span>
454
+ )
455
+ }
456
+ />
457
+ <Fact label="Email domain" value={u.email.split('@')[1] ?? '—'} />
458
+ <Fact
459
+ label="Content owned"
460
+ value={`${u.contentOwnedCount} ${u.contentOwnedCount === 1 ? 'item' : 'items'}`}
461
+ />
462
+ <Fact
463
+ label="Email verified"
464
+ value={u.emailVerified ? 'Yes' : <span className="text-warning">No</span>}
465
+ />
466
+ {u.authProvider && <Fact label="Auth provider" value={u.authProvider} />}
467
+ {u.oauthProviders.length > 0 && (
468
+ <Fact label="Connected accounts" value={u.oauthProviders.join(', ')} />
469
+ )}
470
+ </dl>
471
+ {u.contentOwnedCount > 0 && (
472
+ <div className="border-info/30 bg-info/5 text-muted-foreground mt-4 flex items-start gap-2 rounded-md border p-3 text-xs">
473
+ <FileText size={14} className="text-info mt-0.5 shrink-0" aria-hidden />
474
+ <span>
475
+ This user owns {u.contentOwnedCount} content{' '}
476
+ {u.contentOwnedCount === 1 ? 'item' : 'items'}. Revoking access suspends the
477
+ account and preserves their content and authorship.
478
+ </span>
479
+ </div>
480
+ )}
481
+ </Tabs.Content>
482
+
483
+ {/* Security ----------------------------------------------------- */}
484
+ <Tabs.Content value="security" tabIndex={-1} className="focus:outline-none">
485
+ <SecurityRow
486
+ icon={u.mfaEnabled ? <ShieldCheck size={16} /> : <ShieldAlert size={16} />}
487
+ title="Multi-factor authentication"
488
+ description={
489
+ u.mfaEnabled
490
+ ? 'Enabled for this account.'
491
+ : u.mfaRequired
492
+ ? 'Required but not yet enrolled.'
493
+ : 'Not enabled.'
494
+ }
495
+ action={
496
+ <div className="flex items-center gap-2">
497
+ {!u.mfaRequired && (
498
+ <Button
499
+ size="sm"
500
+ variant="outline"
501
+ loading={pending === 'require-mfa'}
502
+ onClick={() =>
503
+ runAction(
504
+ 'require-mfa',
505
+ `/users/${u.id}/require-mfa`,
506
+ { method: 'POST', body: JSON.stringify({ required: true }) },
507
+ {
508
+ successMsg: 'MFA is now required for this user.',
509
+ after: refreshAfterChange,
510
+ },
511
+ )
512
+ }
513
+ >
514
+ Require MFA
515
+ </Button>
516
+ )}
517
+ <Button
518
+ size="sm"
519
+ variant="outline"
520
+ disabled={!u.mfaEnabled}
521
+ title={!u.mfaEnabled ? 'No MFA enrolment to reset' : undefined}
522
+ loading={pending === 'mfa-reset'}
523
+ onClick={() =>
524
+ setConfirm({
525
+ title: 'Reset MFA enrolment',
526
+ description: `This clears ${u.name}'s authenticator and backup codes and signs them out everywhere. Resetting MFA temporarily lowers account protection until the user re-enrols. You'll be asked to confirm your password.`,
527
+ confirmLabel: 'Reset MFA',
528
+ destructive: true,
529
+ onConfirm: () =>
530
+ runAction(
531
+ 'mfa-reset',
532
+ `/users/${u.id}/mfa-reset`,
533
+ { method: 'POST' },
534
+ { successMsg: 'MFA enrolment reset.', after: refreshAfterChange },
535
+ ),
536
+ })
537
+ }
538
+ >
539
+ Reset MFA
540
+ </Button>
541
+ </div>
542
+ }
543
+ />
544
+ <SecurityRow
545
+ icon={<KeyRound size={16} />}
546
+ title="Password"
547
+ description={
548
+ u.ssoOnly
549
+ ? 'Signs in via an external identity provider — no local password.'
550
+ : u.forcePasswordReset
551
+ ? 'Must set a new password on next sign-in.'
552
+ : u.passwordSet
553
+ ? 'A password is set.'
554
+ : 'No password set yet.'
555
+ }
556
+ action={
557
+ <div className="flex items-center gap-2">
558
+ <Button
559
+ size="sm"
560
+ variant="outline"
561
+ disabled={u.ssoOnly || !u.passwordSet}
562
+ title={
563
+ u.ssoOnly ? 'External identity provider — no password to reset' : undefined
564
+ }
565
+ loading={pending === 'password-reset'}
566
+ onClick={() =>
567
+ runAction(
568
+ 'password-reset',
569
+ `/users/${u.id}/password-reset`,
570
+ { method: 'POST' },
571
+ { successMsg: `Password reset email sent to ${u.email}.` },
572
+ )
573
+ }
574
+ >
575
+ Send reset
576
+ </Button>
577
+ <Button
578
+ size="sm"
579
+ variant="outline"
580
+ disabled={u.ssoOnly}
581
+ loading={pending === 'force-reset'}
582
+ onClick={() =>
583
+ runAction(
584
+ 'force-reset',
585
+ `/users/${u.id}/force-password-reset`,
586
+ {
587
+ method: 'POST',
588
+ body: JSON.stringify({ enabled: !u.forcePasswordReset }),
589
+ },
590
+ {
591
+ successMsg: u.forcePasswordReset
592
+ ? 'Forced reset cleared.'
593
+ : 'User must reset password on next sign-in.',
594
+ after: refreshAfterChange,
595
+ },
596
+ )
597
+ }
598
+ >
599
+ {u.forcePasswordReset ? 'Clear forced reset' : 'Force reset'}
600
+ </Button>
601
+ </div>
602
+ }
603
+ />
604
+ <SecurityRow
605
+ icon={<ShieldAlert size={16} />}
606
+ title="Failed sign-ins"
607
+ description={`${u.failedLoginCount} failed ${u.failedLoginCount === 1 ? 'attempt' : 'attempts'} on record.`}
608
+ action={null}
609
+ />
610
+ <SecurityRow
611
+ icon={u.status === 'locked' ? <Lock size={16} /> : <Unlock size={16} />}
612
+ title="Account lock"
613
+ description={
614
+ u.status === 'locked' ? 'Locked by a security policy or an admin.' : 'Not locked.'
615
+ }
616
+ action={
617
+ u.status === 'locked' ? (
618
+ <Button
619
+ size="sm"
620
+ variant="outline"
621
+ loading={pending === 'unlock'}
622
+ onClick={() =>
623
+ runAction(
624
+ 'unlock',
625
+ `/users/${u.id}/unlock`,
626
+ { method: 'POST' },
627
+ { successMsg: 'Account unlocked.', after: refreshAfterChange },
628
+ )
629
+ }
630
+ >
631
+ Unlock
632
+ </Button>
633
+ ) : (
634
+ <Button
635
+ size="sm"
636
+ variant="outline"
637
+ disabled={u.isCurrentUser}
638
+ title={u.isCurrentUser ? 'You cannot lock your own account' : undefined}
639
+ loading={pending === 'lock'}
640
+ onClick={() =>
641
+ setConfirm({
642
+ title: 'Lock account',
643
+ description: `${u.name} will be blocked from signing in and all their sessions will be revoked until you unlock the account.`,
644
+ confirmLabel: 'Lock account',
645
+ destructive: true,
646
+ onConfirm: () =>
647
+ runAction(
648
+ 'lock',
649
+ `/users/${u.id}/lock`,
650
+ { method: 'POST' },
651
+ { successMsg: 'Account locked.', after: refreshAfterChange },
652
+ ),
653
+ })
654
+ }
655
+ >
656
+ Lock
657
+ </Button>
658
+ )
659
+ }
660
+ />
661
+ <SecurityRow
662
+ icon={<Ban size={16} />}
663
+ title="Account access"
664
+ description={
665
+ u.status === 'suspended'
666
+ ? 'Suspended — cannot sign in, history preserved.'
667
+ : 'Active — can sign in.'
668
+ }
669
+ action={
670
+ u.status === 'suspended' || u.status === 'deactivated' ? (
671
+ <Button
672
+ size="sm"
673
+ variant="outline"
674
+ loading={pending === 'reactivate'}
675
+ onClick={() =>
676
+ runAction(
677
+ 'reactivate',
678
+ `/users/${u.id}/reactivate`,
679
+ { method: 'POST' },
680
+ { successMsg: 'Access restored.', after: refreshAfterChange },
681
+ )
682
+ }
683
+ >
684
+ Reactivate
685
+ </Button>
686
+ ) : (
687
+ <Button
688
+ size="sm"
689
+ variant="outline"
690
+ disabled={u.isCurrentUser}
691
+ title={u.isCurrentUser ? 'You cannot suspend your own account' : undefined}
692
+ loading={pending === 'suspend'}
693
+ onClick={() =>
694
+ setConfirm({
695
+ title: 'Suspend user',
696
+ description: `${u.name} will be blocked from signing in and signed out everywhere. Their content and history are preserved, and you can reactivate them later.`,
697
+ confirmLabel: 'Suspend',
698
+ destructive: true,
699
+ onConfirm: () =>
700
+ runAction(
701
+ 'suspend',
702
+ `/users/${u.id}/suspend`,
703
+ { method: 'POST' },
704
+ { successMsg: 'User suspended.', after: refreshAfterChange },
705
+ ),
706
+ })
707
+ }
708
+ >
709
+ Suspend
710
+ </Button>
711
+ )
712
+ }
713
+ />
714
+ </Tabs.Content>
715
+
716
+ {/* Sessions ----------------------------------------------------- */}
717
+ <Tabs.Content value="sessions" tabIndex={-1} className="focus:outline-none">
718
+ {sessions.loading ? (
719
+ <Skeleton variant="table-row" lines={3} />
720
+ ) : sessions.error ? (
721
+ <div className="flex items-center gap-3 py-4">
722
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
723
+ <span className="text-destructive flex-1 text-sm">{sessions.error}</span>
724
+ <Button variant="outline" size="sm" onClick={() => sessions.refetch()}>
725
+ Retry
726
+ </Button>
727
+ </div>
728
+ ) : (sessions.data?.filter((s) => s.active).length ?? 0) === 0 ? (
729
+ <EmptyState
730
+ icon={<Monitor size={24} aria-hidden />}
731
+ title="No active sessions"
732
+ description="This user isn't signed in on any device."
733
+ />
734
+ ) : (
735
+ <>
736
+ <div className="mb-3 flex justify-end">
737
+ <Button
738
+ size="sm"
739
+ variant="outline"
740
+ loading={pending === 'revoke-all'}
741
+ leftIcon={<LogOut size={14} aria-hidden />}
742
+ onClick={() =>
743
+ setConfirm({
744
+ title: 'Revoke all sessions',
745
+ description: `${u.name} will be signed out of every device${
746
+ u.isCurrentUser ? ' except this one' : ''
747
+ }.`,
748
+ confirmLabel: 'Revoke all',
749
+ destructive: true,
750
+ onConfirm: () =>
751
+ runAction(
752
+ 'revoke-all',
753
+ `/users/${u.id}/sessions/revoke-all`,
754
+ { method: 'POST' },
755
+ { successMsg: 'Sessions revoked.', after: () => sessions.refetch() },
756
+ ),
757
+ })
758
+ }
759
+ >
760
+ Revoke all
761
+ </Button>
762
+ </div>
763
+ <ul className="space-y-2">
764
+ {sessions.data
765
+ ?.filter((s) => s.active)
766
+ .map((s) => (
767
+ <li
768
+ key={s.id}
769
+ className="border-border flex items-center justify-between gap-3 rounded-md border p-3"
770
+ >
771
+ <div className="flex min-w-0 items-center gap-2.5">
772
+ <Monitor
773
+ size={16}
774
+ className="text-muted-foreground shrink-0"
775
+ aria-hidden
776
+ />
777
+ <div className="min-w-0">
778
+ <div className="text-card-foreground flex items-center gap-1.5 text-sm">
779
+ <span className="truncate">{s.deviceLabel}</span>
780
+ {s.current && (
781
+ <span className="bg-success/10 text-success rounded px-1 text-[10px]">
782
+ This device
783
+ </span>
784
+ )}
785
+ </div>
786
+ <div className="text-muted-foreground text-xs">
787
+ Signed in {relativeTime(s.createdAt)} · expires{' '}
788
+ {formatDate(s.expiresAt)}
789
+ </div>
790
+ </div>
791
+ </div>
792
+ <button
793
+ type="button"
794
+ disabled={pending === `revoke-${s.id}`}
795
+ onClick={() =>
796
+ runAction(
797
+ `revoke-${s.id}`,
798
+ `/users/${u.id}/sessions/${s.id}`,
799
+ { method: 'DELETE' },
800
+ { successMsg: 'Session revoked.', after: () => sessions.refetch() },
801
+ )
802
+ }
803
+ className="text-muted-foreground hover:text-destructive shrink-0 text-xs font-medium transition-colors disabled:opacity-50"
804
+ >
805
+ Revoke
806
+ </button>
807
+ </li>
808
+ ))}
809
+ </ul>
810
+ </>
811
+ )}
812
+ </Tabs.Content>
813
+
814
+ {/* Activity ----------------------------------------------------- */}
815
+ <Tabs.Content value="activity" tabIndex={-1} className="focus:outline-none">
816
+ {activity.loading ? (
817
+ <Skeleton variant="table-row" lines={4} />
818
+ ) : activity.error ? (
819
+ <div className="flex items-center gap-3 py-4">
820
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
821
+ <span className="text-destructive flex-1 text-sm">{activity.error}</span>
822
+ <Button variant="outline" size="sm" onClick={() => activity.refetch()}>
823
+ Retry
824
+ </Button>
825
+ </div>
826
+ ) : (activity.data?.length ?? 0) === 0 ? (
827
+ <EmptyState
828
+ icon={<ActivityIcon size={24} aria-hidden />}
829
+ title="No recent activity"
830
+ description="Audit events for this user will appear here."
831
+ />
832
+ ) : (
833
+ <ul className="space-y-1">
834
+ {activity.data?.map((e) => (
835
+ <li
836
+ key={e.id}
837
+ className="hover:bg-accent/30 flex items-center justify-between gap-3 rounded-md px-2 py-2"
838
+ >
839
+ <div className="flex min-w-0 items-center gap-2.5">
840
+ <ActivityIcon
841
+ size={14}
842
+ className="text-muted-foreground shrink-0"
843
+ aria-hidden
844
+ />
845
+ <div className="min-w-0">
846
+ <div className="text-card-foreground truncate text-sm">
847
+ {humanizeEvent(e.event)}
848
+ </div>
849
+ <div className="text-muted-foreground text-xs">
850
+ {e.role === 'target' ? 'Affected this user' : 'Performed by this user'}
851
+ {e.ipAddress ? ` · ${e.ipAddress}` : ''}
852
+ </div>
853
+ </div>
854
+ </div>
855
+ <span className="text-muted-foreground shrink-0 text-xs">
856
+ {relativeTime(e.timestamp)}
857
+ </span>
858
+ </li>
859
+ ))}
860
+ </ul>
861
+ )}
862
+ </Tabs.Content>
863
+
864
+ {/* Permissions -------------------------------------------------- */}
865
+ <Tabs.Content value="permissions" tabIndex={-1} className="focus:outline-none">
866
+ {perms.loading ? (
867
+ <Skeleton variant="table-row" lines={4} />
868
+ ) : perms.error ? (
869
+ <div className="flex items-center gap-3 py-4">
870
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
871
+ <span className="text-destructive flex-1 text-sm">{perms.error}</span>
872
+ <Button variant="outline" size="sm" onClick={() => perms.refetch()}>
873
+ Retry
874
+ </Button>
875
+ </div>
876
+ ) : perms.data ? (
877
+ <>
878
+ <p className="text-muted-foreground mb-3 text-sm">
879
+ Effective role{' '}
880
+ <span className="text-card-foreground font-medium">{perms.data.roleName}</span>{' '}
881
+ grants {perms.data.permissions.length} permission
882
+ {perms.data.permissions.length === 1 ? '' : 's'}. Permissions are derived from
883
+ the role and managed in configuration.
884
+ </p>
885
+ <ul className="flex flex-wrap gap-1.5">
886
+ {perms.data.permissions.map((p) => (
887
+ <li
888
+ key={p}
889
+ className="border-border bg-muted/50 text-card-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs"
890
+ >
891
+ <Check size={12} className="text-success" aria-hidden />
892
+ {p}
893
+ </li>
894
+ ))}
895
+ </ul>
896
+ </>
897
+ ) : null}
898
+ </Tabs.Content>
899
+ </Tabs.Root>
900
+ )}
901
+ </Drawer>
902
+
903
+ {confirm && (
904
+ <ConfirmDialog
905
+ open={!!confirm}
906
+ onClose={() => setConfirm(null)}
907
+ onConfirm={confirm.onConfirm}
908
+ title={confirm.title}
909
+ description={confirm.description}
910
+ confirmLabel={confirm.confirmLabel}
911
+ destructive={confirm.destructive}
912
+ />
913
+ )}
914
+
915
+ <ReauthDialog
916
+ open={!!reauth}
917
+ onClose={() => setReauth(null)}
918
+ onSubmit={async (password) => {
919
+ if (reauth) await reauth.run(password)
920
+ }}
921
+ />
922
+ </>
923
+ )
924
+ }