@actuate-media/cms-admin 0.30.0 → 0.32.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.
@@ -0,0 +1,1069 @@
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
+ // Transfer content modal
232
+ // ---------------------------------------------------------------------------
233
+
234
+ interface TransferMember {
235
+ id: string
236
+ name: string
237
+ email: string
238
+ status: string
239
+ isCurrentUser: boolean
240
+ }
241
+
242
+ function TransferContentModal({
243
+ open,
244
+ onClose,
245
+ fromUser,
246
+ onTransferred,
247
+ }: {
248
+ open: boolean
249
+ onClose: () => void
250
+ fromUser: { id: string; name: string; contentOwnedCount: number }
251
+ onTransferred: () => void
252
+ }) {
253
+ // Only fetch the directory while the picker is open.
254
+ const members = useApiData<TransferMember[]>(open ? '/users' : null)
255
+ const [toUserId, setToUserId] = useState('')
256
+ const [busy, setBusy] = useState(false)
257
+
258
+ const recipients = (members.data ?? []).filter(
259
+ (m) => m.id !== fromUser.id && m.status === 'active',
260
+ )
261
+
262
+ const submit = async () => {
263
+ if (!toUserId || busy) return
264
+ setBusy(true)
265
+ try {
266
+ const res = await cmsApi<{
267
+ documents: number
268
+ media: number
269
+ templates: number
270
+ total: number
271
+ }>(`/users/${fromUser.id}/transfer-content`, {
272
+ method: 'POST',
273
+ body: JSON.stringify({ toUserId }),
274
+ })
275
+ if (res.error) {
276
+ toast.error(res.error)
277
+ return
278
+ }
279
+ const c = res.data
280
+ toast.success(
281
+ c && c.total > 0
282
+ ? `Transferred ${c.total} item${c.total === 1 ? '' : 's'} (${c.documents} docs, ${c.media} media, ${c.templates} templates).`
283
+ : 'Nothing to transfer.',
284
+ )
285
+ setToUserId('')
286
+ onClose()
287
+ onTransferred()
288
+ } finally {
289
+ setBusy(false)
290
+ }
291
+ }
292
+
293
+ return (
294
+ <Modal
295
+ open={open}
296
+ onClose={() => {
297
+ setToUserId('')
298
+ onClose()
299
+ }}
300
+ title="Transfer content ownership"
301
+ actions={
302
+ <>
303
+ <Button
304
+ variant="ghost"
305
+ onClick={() => {
306
+ setToUserId('')
307
+ onClose()
308
+ }}
309
+ >
310
+ Cancel
311
+ </Button>
312
+ <Button variant="primary" loading={busy} disabled={!toUserId || busy} onClick={submit}>
313
+ Transfer
314
+ </Button>
315
+ </>
316
+ }
317
+ >
318
+ <p className="text-muted-foreground mb-3 text-sm">
319
+ Reassign {fromUser.name}&apos;s authored documents, uploaded media, and templates to another
320
+ active member. Version history and audit records are left unchanged.
321
+ </p>
322
+ <label htmlFor="transfer-to" className="text-card-foreground mb-1 block text-sm font-medium">
323
+ Transfer to
324
+ </label>
325
+ {members.loading ? (
326
+ <Skeleton variant="table-row" lines={1} />
327
+ ) : recipients.length === 0 ? (
328
+ <p className="text-muted-foreground text-sm">
329
+ No other active members to receive the content.
330
+ </p>
331
+ ) : (
332
+ <select
333
+ id="transfer-to"
334
+ value={toUserId}
335
+ onChange={(e) => setToUserId(e.target.value)}
336
+ 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"
337
+ >
338
+ <option value="">Select a member…</option>
339
+ {recipients.map((m) => (
340
+ <option key={m.id} value={m.id}>
341
+ {m.name} ({m.email})
342
+ </option>
343
+ ))}
344
+ </select>
345
+ )}
346
+ </Modal>
347
+ )
348
+ }
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Small presentational bits
352
+ // ---------------------------------------------------------------------------
353
+
354
+ function Fact({ label, value }: { label: string; value: ReactNode }) {
355
+ return (
356
+ <div className="min-w-0">
357
+ <dt className="text-muted-foreground text-xs">{label}</dt>
358
+ <dd className="text-card-foreground mt-0.5 truncate text-sm">{value}</dd>
359
+ </div>
360
+ )
361
+ }
362
+
363
+ function SecurityRow({
364
+ icon,
365
+ title,
366
+ description,
367
+ action,
368
+ }: {
369
+ icon: ReactNode
370
+ title: string
371
+ description: string
372
+ action: ReactNode
373
+ }) {
374
+ return (
375
+ <div className="border-border flex items-center justify-between gap-3 border-b py-3 last:border-b-0">
376
+ <div className="flex min-w-0 items-start gap-2.5">
377
+ <span className="text-muted-foreground mt-0.5 shrink-0">{icon}</span>
378
+ <div className="min-w-0">
379
+ <div className="text-card-foreground text-sm font-medium">{title}</div>
380
+ <div className="text-muted-foreground text-xs">{description}</div>
381
+ </div>
382
+ </div>
383
+ <div className="shrink-0">{action}</div>
384
+ </div>
385
+ )
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Drawer
390
+ // ---------------------------------------------------------------------------
391
+
392
+ const TABS = [
393
+ { id: 'overview', label: 'Overview' },
394
+ { id: 'security', label: 'Security' },
395
+ { id: 'sessions', label: 'Sessions' },
396
+ { id: 'activity', label: 'Activity' },
397
+ { id: 'permissions', label: 'Permissions' },
398
+ ] as const
399
+
400
+ export interface UserDetailDrawerProps {
401
+ userId: string | null
402
+ open: boolean
403
+ onOpenChange: (open: boolean) => void
404
+ /** Refetch the parent table after any change to this user. */
405
+ onChanged?: () => void
406
+ }
407
+
408
+ export function UserDetailDrawer({ userId, open, onOpenChange, onChanged }: UserDetailDrawerProps) {
409
+ const [tab, setTab] = useState<string>('overview')
410
+
411
+ const detailEp = open && userId ? `/users/${userId}` : null
412
+ const detail = useApiData<UserDetail>(detailEp)
413
+
414
+ const sessionsEp = open && userId && tab === 'sessions' ? `/users/${userId}/sessions` : null
415
+ const sessions = useApiData<SessionInfo[]>(sessionsEp)
416
+
417
+ const activityEp = open && userId && tab === 'activity' ? `/users/${userId}/activity` : null
418
+ const activity = useApiData<ActivityEvent[]>(activityEp)
419
+
420
+ const permsEp = open && userId && tab === 'permissions' ? `/users/${userId}/permissions` : null
421
+ const perms = useApiData<PermissionView>(permsEp)
422
+
423
+ const [reauth, setReauth] = useState<{ run: (password: string) => Promise<void> } | null>(null)
424
+ const [confirm, setConfirm] = useState<{
425
+ title: string
426
+ description: string
427
+ confirmLabel: string
428
+ destructive?: boolean
429
+ onConfirm: () => void
430
+ } | null>(null)
431
+ const [pending, setPending] = useState<string | null>(null)
432
+ const [showTransfer, setShowTransfer] = useState(false)
433
+
434
+ const refreshAfterChange = useCallback(() => {
435
+ detail.refetch()
436
+ onChanged?.()
437
+ }, [detail, onChanged])
438
+
439
+ /**
440
+ * Run a mutating action. If the server demands reauthentication (401), prompt
441
+ * for the password and retry once with the `x-reauth-password` header.
442
+ */
443
+ const runAction = useCallback(
444
+ async (
445
+ key: string,
446
+ path: string,
447
+ options: RequestInit,
448
+ { successMsg, after }: { successMsg: string; after?: () => void },
449
+ ) => {
450
+ setPending(key)
451
+ try {
452
+ const res = await cmsApi(path, options)
453
+ if (res.status === 401 && /re-?authenticat/i.test(res.error ?? '')) {
454
+ setReauth({
455
+ run: async (password: string) => {
456
+ const retry = await cmsApi(path, {
457
+ ...options,
458
+ headers: { ...(options.headers ?? {}), 'x-reauth-password': password },
459
+ })
460
+ if (retry.error) {
461
+ toast.error(retry.error)
462
+ return
463
+ }
464
+ toast.success(successMsg)
465
+ setReauth(null)
466
+ after?.()
467
+ },
468
+ })
469
+ return
470
+ }
471
+ if (res.error) {
472
+ toast.error(res.error)
473
+ return
474
+ }
475
+ toast.success(successMsg)
476
+ after?.()
477
+ } finally {
478
+ setPending(null)
479
+ }
480
+ },
481
+ [],
482
+ )
483
+
484
+ const u = detail.data
485
+
486
+ return (
487
+ <>
488
+ <Drawer
489
+ open={open}
490
+ onOpenChange={onOpenChange}
491
+ width="max-w-xl"
492
+ title={u ? u.name : detail.loading ? 'Loading…' : 'User'}
493
+ description={u?.email}
494
+ headerExtra={
495
+ u ? (
496
+ <span
497
+ className={`mr-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
498
+ ROLE_PILL[u.roleName] ?? ROLE_PILL.Viewer
499
+ }`}
500
+ >
501
+ {u.roleName}
502
+ </span>
503
+ ) : undefined
504
+ }
505
+ >
506
+ {detail.loading ? (
507
+ <div className="space-y-3">
508
+ <Skeleton variant="card" />
509
+ <Skeleton variant="table-row" lines={4} />
510
+ </div>
511
+ ) : detail.error || !u ? (
512
+ <div className="flex items-center gap-3 py-6">
513
+ <AlertTriangle size={18} className="text-destructive shrink-0" aria-hidden />
514
+ <span className="text-destructive flex-1 text-sm">
515
+ {detail.error ?? 'User not found.'}
516
+ </span>
517
+ <Button variant="outline" size="sm" onClick={() => detail.refetch()}>
518
+ Retry
519
+ </Button>
520
+ </div>
521
+ ) : (
522
+ <Tabs.Root value={tab} onValueChange={setTab}>
523
+ <Tabs.List
524
+ className="border-border mb-4 flex gap-1 overflow-x-auto border-b"
525
+ aria-label="User detail sections"
526
+ >
527
+ {TABS.map((t) => (
528
+ <Tabs.Trigger
529
+ key={t.id}
530
+ value={t.id}
531
+ 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"
532
+ >
533
+ {t.label}
534
+ </Tabs.Trigger>
535
+ ))}
536
+ </Tabs.List>
537
+
538
+ {/* Overview ----------------------------------------------------- */}
539
+ <Tabs.Content value="overview" tabIndex={-1} className="focus:outline-none">
540
+ <div className="mb-4 flex items-center gap-2">
541
+ <span
542
+ className={`inline-flex items-center gap-1.5 text-sm ${STATUS_META[u.status].text}`}
543
+ >
544
+ <span
545
+ className={`h-2 w-2 rounded-full ${STATUS_META[u.status].dot}`}
546
+ aria-hidden
547
+ />
548
+ {STATUS_META[u.status].label}
549
+ </span>
550
+ {u.isCurrentUser && (
551
+ <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-xs">
552
+ You
553
+ </span>
554
+ )}
555
+ {u.externalDomain && (
556
+ <span className="bg-warning/10 text-warning rounded px-1.5 py-0.5 text-xs">
557
+ External domain
558
+ </span>
559
+ )}
560
+ </div>
561
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-4">
562
+ <Fact label="Role" value={u.roleName} />
563
+ <Fact label="Status" value={STATUS_META[u.status].label} />
564
+ <Fact label="Last active" value={relativeTime(u.lastActiveAt)} />
565
+ <Fact label="Last sign-in" value={relativeTime(u.lastLoginAt)} />
566
+ <Fact label="Created" value={formatDate(u.createdAt)} />
567
+ <Fact label="Accepted invite" value={formatDate(u.acceptedInviteAt)} />
568
+ <Fact
569
+ label="Invited by"
570
+ value={
571
+ u.invitedBy ? (
572
+ u.invitedBy.name
573
+ ) : (
574
+ <span className="text-muted-foreground">—</span>
575
+ )
576
+ }
577
+ />
578
+ <Fact label="Email domain" value={u.email.split('@')[1] ?? '—'} />
579
+ <Fact
580
+ label="Content owned"
581
+ value={`${u.contentOwnedCount} ${u.contentOwnedCount === 1 ? 'item' : 'items'}`}
582
+ />
583
+ <Fact
584
+ label="Email verified"
585
+ value={u.emailVerified ? 'Yes' : <span className="text-warning">No</span>}
586
+ />
587
+ {u.authProvider && <Fact label="Auth provider" value={u.authProvider} />}
588
+ {u.oauthProviders.length > 0 && (
589
+ <Fact label="Connected accounts" value={u.oauthProviders.join(', ')} />
590
+ )}
591
+ </dl>
592
+ {u.contentOwnedCount > 0 && (
593
+ <div className="border-info/30 bg-info/5 mt-4 flex items-start gap-2 rounded-md border p-3">
594
+ <FileText size={14} className="text-info mt-0.5 shrink-0" aria-hidden />
595
+ <div className="min-w-0 flex-1">
596
+ <p className="text-muted-foreground text-xs">
597
+ This user owns {u.contentOwnedCount} content{' '}
598
+ {u.contentOwnedCount === 1 ? 'item' : 'items'}. Revoking access suspends the
599
+ account and preserves their content and authorship — or reassign it to another
600
+ member first.
601
+ </p>
602
+ <Button
603
+ size="sm"
604
+ variant="outline"
605
+ className="mt-2"
606
+ leftIcon={<FileText size={14} aria-hidden />}
607
+ onClick={() => setShowTransfer(true)}
608
+ >
609
+ Transfer content
610
+ </Button>
611
+ </div>
612
+ </div>
613
+ )}
614
+ </Tabs.Content>
615
+
616
+ {/* Security ----------------------------------------------------- */}
617
+ <Tabs.Content value="security" tabIndex={-1} className="focus:outline-none">
618
+ <SecurityRow
619
+ icon={u.mfaEnabled ? <ShieldCheck size={16} /> : <ShieldAlert size={16} />}
620
+ title="Multi-factor authentication"
621
+ description={
622
+ u.mfaEnabled
623
+ ? 'Enabled for this account.'
624
+ : u.mfaRequired
625
+ ? 'Required but not yet enrolled.'
626
+ : 'Not enabled.'
627
+ }
628
+ action={
629
+ <div className="flex items-center gap-2">
630
+ {!u.mfaRequired && (
631
+ <Button
632
+ size="sm"
633
+ variant="outline"
634
+ loading={pending === 'require-mfa'}
635
+ onClick={() =>
636
+ runAction(
637
+ 'require-mfa',
638
+ `/users/${u.id}/require-mfa`,
639
+ { method: 'POST', body: JSON.stringify({ required: true }) },
640
+ {
641
+ successMsg: 'MFA is now required for this user.',
642
+ after: refreshAfterChange,
643
+ },
644
+ )
645
+ }
646
+ >
647
+ Require MFA
648
+ </Button>
649
+ )}
650
+ <Button
651
+ size="sm"
652
+ variant="outline"
653
+ disabled={!u.mfaEnabled}
654
+ title={!u.mfaEnabled ? 'No MFA enrolment to reset' : undefined}
655
+ loading={pending === 'mfa-reset'}
656
+ onClick={() =>
657
+ setConfirm({
658
+ title: 'Reset MFA enrolment',
659
+ 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.`,
660
+ confirmLabel: 'Reset MFA',
661
+ destructive: true,
662
+ onConfirm: () =>
663
+ runAction(
664
+ 'mfa-reset',
665
+ `/users/${u.id}/mfa-reset`,
666
+ { method: 'POST' },
667
+ { successMsg: 'MFA enrolment reset.', after: refreshAfterChange },
668
+ ),
669
+ })
670
+ }
671
+ >
672
+ Reset MFA
673
+ </Button>
674
+ </div>
675
+ }
676
+ />
677
+ <SecurityRow
678
+ icon={<KeyRound size={16} />}
679
+ title="Password"
680
+ description={
681
+ u.ssoOnly
682
+ ? 'Signs in via an external identity provider — no local password.'
683
+ : u.forcePasswordReset
684
+ ? 'Must set a new password on next sign-in.'
685
+ : u.passwordSet
686
+ ? 'A password is set.'
687
+ : 'No password set yet.'
688
+ }
689
+ action={
690
+ <div className="flex items-center gap-2">
691
+ <Button
692
+ size="sm"
693
+ variant="outline"
694
+ disabled={u.ssoOnly || !u.passwordSet}
695
+ title={
696
+ u.ssoOnly ? 'External identity provider — no password to reset' : undefined
697
+ }
698
+ loading={pending === 'password-reset'}
699
+ onClick={() =>
700
+ runAction(
701
+ 'password-reset',
702
+ `/users/${u.id}/password-reset`,
703
+ { method: 'POST' },
704
+ { successMsg: `Password reset email sent to ${u.email}.` },
705
+ )
706
+ }
707
+ >
708
+ Send reset
709
+ </Button>
710
+ <Button
711
+ size="sm"
712
+ variant="outline"
713
+ disabled={u.ssoOnly}
714
+ loading={pending === 'force-reset'}
715
+ onClick={() =>
716
+ runAction(
717
+ 'force-reset',
718
+ `/users/${u.id}/force-password-reset`,
719
+ {
720
+ method: 'POST',
721
+ body: JSON.stringify({ enabled: !u.forcePasswordReset }),
722
+ },
723
+ {
724
+ successMsg: u.forcePasswordReset
725
+ ? 'Forced reset cleared.'
726
+ : 'User must reset password on next sign-in.',
727
+ after: refreshAfterChange,
728
+ },
729
+ )
730
+ }
731
+ >
732
+ {u.forcePasswordReset ? 'Clear forced reset' : 'Force reset'}
733
+ </Button>
734
+ </div>
735
+ }
736
+ />
737
+ <SecurityRow
738
+ icon={<ShieldAlert size={16} />}
739
+ title="Failed sign-ins"
740
+ description={`${u.failedLoginCount} failed ${u.failedLoginCount === 1 ? 'attempt' : 'attempts'} on record.`}
741
+ action={null}
742
+ />
743
+ <SecurityRow
744
+ icon={u.status === 'locked' ? <Lock size={16} /> : <Unlock size={16} />}
745
+ title="Account lock"
746
+ description={
747
+ u.status === 'locked' ? 'Locked by a security policy or an admin.' : 'Not locked.'
748
+ }
749
+ action={
750
+ u.status === 'locked' ? (
751
+ <Button
752
+ size="sm"
753
+ variant="outline"
754
+ loading={pending === 'unlock'}
755
+ onClick={() =>
756
+ runAction(
757
+ 'unlock',
758
+ `/users/${u.id}/unlock`,
759
+ { method: 'POST' },
760
+ { successMsg: 'Account unlocked.', after: refreshAfterChange },
761
+ )
762
+ }
763
+ >
764
+ Unlock
765
+ </Button>
766
+ ) : (
767
+ <Button
768
+ size="sm"
769
+ variant="outline"
770
+ disabled={u.isCurrentUser}
771
+ title={u.isCurrentUser ? 'You cannot lock your own account' : undefined}
772
+ loading={pending === 'lock'}
773
+ onClick={() =>
774
+ setConfirm({
775
+ title: 'Lock account',
776
+ description: `${u.name} will be blocked from signing in and all their sessions will be revoked until you unlock the account.`,
777
+ confirmLabel: 'Lock account',
778
+ destructive: true,
779
+ onConfirm: () =>
780
+ runAction(
781
+ 'lock',
782
+ `/users/${u.id}/lock`,
783
+ { method: 'POST' },
784
+ { successMsg: 'Account locked.', after: refreshAfterChange },
785
+ ),
786
+ })
787
+ }
788
+ >
789
+ Lock
790
+ </Button>
791
+ )
792
+ }
793
+ />
794
+ <SecurityRow
795
+ icon={<Ban size={16} />}
796
+ title="Account access"
797
+ description={
798
+ u.status === 'suspended'
799
+ ? 'Suspended — cannot sign in, history preserved.'
800
+ : 'Active — can sign in.'
801
+ }
802
+ action={
803
+ u.status === 'suspended' || u.status === 'deactivated' ? (
804
+ <Button
805
+ size="sm"
806
+ variant="outline"
807
+ loading={pending === 'reactivate'}
808
+ onClick={() =>
809
+ runAction(
810
+ 'reactivate',
811
+ `/users/${u.id}/reactivate`,
812
+ { method: 'POST' },
813
+ { successMsg: 'Access restored.', after: refreshAfterChange },
814
+ )
815
+ }
816
+ >
817
+ Reactivate
818
+ </Button>
819
+ ) : (
820
+ <Button
821
+ size="sm"
822
+ variant="outline"
823
+ disabled={u.isCurrentUser}
824
+ title={u.isCurrentUser ? 'You cannot suspend your own account' : undefined}
825
+ loading={pending === 'suspend'}
826
+ onClick={() =>
827
+ setConfirm({
828
+ title: 'Suspend user',
829
+ 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.`,
830
+ confirmLabel: 'Suspend',
831
+ destructive: true,
832
+ onConfirm: () =>
833
+ runAction(
834
+ 'suspend',
835
+ `/users/${u.id}/suspend`,
836
+ { method: 'POST' },
837
+ { successMsg: 'User suspended.', after: refreshAfterChange },
838
+ ),
839
+ })
840
+ }
841
+ >
842
+ Suspend
843
+ </Button>
844
+ )
845
+ }
846
+ />
847
+ </Tabs.Content>
848
+
849
+ {/* Sessions ----------------------------------------------------- */}
850
+ <Tabs.Content value="sessions" tabIndex={-1} className="focus:outline-none">
851
+ {sessions.loading ? (
852
+ <Skeleton variant="table-row" lines={3} />
853
+ ) : sessions.error ? (
854
+ <div className="flex items-center gap-3 py-4">
855
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
856
+ <span className="text-destructive flex-1 text-sm">{sessions.error}</span>
857
+ <Button variant="outline" size="sm" onClick={() => sessions.refetch()}>
858
+ Retry
859
+ </Button>
860
+ </div>
861
+ ) : (sessions.data?.filter((s) => s.active).length ?? 0) === 0 ? (
862
+ <EmptyState
863
+ icon={<Monitor size={24} aria-hidden />}
864
+ title="No active sessions"
865
+ description="This user isn't signed in on any device."
866
+ />
867
+ ) : (
868
+ <>
869
+ <div className="mb-3 flex justify-end">
870
+ <Button
871
+ size="sm"
872
+ variant="outline"
873
+ loading={pending === 'revoke-all'}
874
+ leftIcon={<LogOut size={14} aria-hidden />}
875
+ onClick={() =>
876
+ setConfirm({
877
+ title: 'Revoke all sessions',
878
+ description: `${u.name} will be signed out of every device${
879
+ u.isCurrentUser ? ' except this one' : ''
880
+ }.`,
881
+ confirmLabel: 'Revoke all',
882
+ destructive: true,
883
+ onConfirm: () =>
884
+ runAction(
885
+ 'revoke-all',
886
+ `/users/${u.id}/sessions/revoke-all`,
887
+ { method: 'POST' },
888
+ { successMsg: 'Sessions revoked.', after: () => sessions.refetch() },
889
+ ),
890
+ })
891
+ }
892
+ >
893
+ Revoke all
894
+ </Button>
895
+ </div>
896
+ <ul className="space-y-2">
897
+ {sessions.data
898
+ ?.filter((s) => s.active)
899
+ .map((s) => (
900
+ <li
901
+ key={s.id}
902
+ className="border-border flex items-center justify-between gap-3 rounded-md border p-3"
903
+ >
904
+ <div className="flex min-w-0 items-center gap-2.5">
905
+ <Monitor
906
+ size={16}
907
+ className="text-muted-foreground shrink-0"
908
+ aria-hidden
909
+ />
910
+ <div className="min-w-0">
911
+ <div className="text-card-foreground flex items-center gap-1.5 text-sm">
912
+ <span className="truncate">{s.deviceLabel}</span>
913
+ {s.current && (
914
+ <span className="bg-success/10 text-success rounded px-1 text-[10px]">
915
+ This device
916
+ </span>
917
+ )}
918
+ </div>
919
+ <div className="text-muted-foreground text-xs">
920
+ Signed in {relativeTime(s.createdAt)} · expires{' '}
921
+ {formatDate(s.expiresAt)}
922
+ </div>
923
+ </div>
924
+ </div>
925
+ <button
926
+ type="button"
927
+ disabled={pending === `revoke-${s.id}`}
928
+ onClick={() =>
929
+ runAction(
930
+ `revoke-${s.id}`,
931
+ `/users/${u.id}/sessions/${s.id}`,
932
+ { method: 'DELETE' },
933
+ { successMsg: 'Session revoked.', after: () => sessions.refetch() },
934
+ )
935
+ }
936
+ className="text-muted-foreground hover:text-destructive shrink-0 text-xs font-medium transition-colors disabled:opacity-50"
937
+ >
938
+ Revoke
939
+ </button>
940
+ </li>
941
+ ))}
942
+ </ul>
943
+ </>
944
+ )}
945
+ </Tabs.Content>
946
+
947
+ {/* Activity ----------------------------------------------------- */}
948
+ <Tabs.Content value="activity" tabIndex={-1} className="focus:outline-none">
949
+ {activity.loading ? (
950
+ <Skeleton variant="table-row" lines={4} />
951
+ ) : activity.error ? (
952
+ <div className="flex items-center gap-3 py-4">
953
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
954
+ <span className="text-destructive flex-1 text-sm">{activity.error}</span>
955
+ <Button variant="outline" size="sm" onClick={() => activity.refetch()}>
956
+ Retry
957
+ </Button>
958
+ </div>
959
+ ) : (activity.data?.length ?? 0) === 0 ? (
960
+ <EmptyState
961
+ icon={<ActivityIcon size={24} aria-hidden />}
962
+ title="No recent activity"
963
+ description="Audit events for this user will appear here."
964
+ />
965
+ ) : (
966
+ <ul className="space-y-1">
967
+ {activity.data?.map((e) => (
968
+ <li
969
+ key={e.id}
970
+ className="hover:bg-accent/30 flex items-center justify-between gap-3 rounded-md px-2 py-2"
971
+ >
972
+ <div className="flex min-w-0 items-center gap-2.5">
973
+ <ActivityIcon
974
+ size={14}
975
+ className="text-muted-foreground shrink-0"
976
+ aria-hidden
977
+ />
978
+ <div className="min-w-0">
979
+ <div className="text-card-foreground truncate text-sm">
980
+ {humanizeEvent(e.event)}
981
+ </div>
982
+ <div className="text-muted-foreground text-xs">
983
+ {e.role === 'target' ? 'Affected this user' : 'Performed by this user'}
984
+ {e.ipAddress ? ` · ${e.ipAddress}` : ''}
985
+ </div>
986
+ </div>
987
+ </div>
988
+ <span className="text-muted-foreground shrink-0 text-xs">
989
+ {relativeTime(e.timestamp)}
990
+ </span>
991
+ </li>
992
+ ))}
993
+ </ul>
994
+ )}
995
+ </Tabs.Content>
996
+
997
+ {/* Permissions -------------------------------------------------- */}
998
+ <Tabs.Content value="permissions" tabIndex={-1} className="focus:outline-none">
999
+ {perms.loading ? (
1000
+ <Skeleton variant="table-row" lines={4} />
1001
+ ) : perms.error ? (
1002
+ <div className="flex items-center gap-3 py-4">
1003
+ <AlertTriangle size={16} className="text-destructive shrink-0" aria-hidden />
1004
+ <span className="text-destructive flex-1 text-sm">{perms.error}</span>
1005
+ <Button variant="outline" size="sm" onClick={() => perms.refetch()}>
1006
+ Retry
1007
+ </Button>
1008
+ </div>
1009
+ ) : perms.data ? (
1010
+ <>
1011
+ <p className="text-muted-foreground mb-3 text-sm">
1012
+ Effective role{' '}
1013
+ <span className="text-card-foreground font-medium">{perms.data.roleName}</span>{' '}
1014
+ grants {perms.data.permissions.length} permission
1015
+ {perms.data.permissions.length === 1 ? '' : 's'}. Permissions are derived from
1016
+ the role and managed in configuration.
1017
+ </p>
1018
+ <ul className="flex flex-wrap gap-1.5">
1019
+ {perms.data.permissions.map((p) => (
1020
+ <li
1021
+ key={p}
1022
+ className="border-border bg-muted/50 text-card-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs"
1023
+ >
1024
+ <Check size={12} className="text-success" aria-hidden />
1025
+ {p}
1026
+ </li>
1027
+ ))}
1028
+ </ul>
1029
+ </>
1030
+ ) : null}
1031
+ </Tabs.Content>
1032
+ </Tabs.Root>
1033
+ )}
1034
+
1035
+ {/* Auxiliary dialogs live inside the Drawer subtree so Radix's focus
1036
+ scope and aria-hidden management treat them as part of the active
1037
+ dialog rather than hiding them as background content. */}
1038
+ {confirm && (
1039
+ <ConfirmDialog
1040
+ open={!!confirm}
1041
+ onClose={() => setConfirm(null)}
1042
+ onConfirm={confirm.onConfirm}
1043
+ title={confirm.title}
1044
+ description={confirm.description}
1045
+ confirmLabel={confirm.confirmLabel}
1046
+ destructive={confirm.destructive}
1047
+ />
1048
+ )}
1049
+
1050
+ <ReauthDialog
1051
+ open={!!reauth}
1052
+ onClose={() => setReauth(null)}
1053
+ onSubmit={async (password) => {
1054
+ if (reauth) await reauth.run(password)
1055
+ }}
1056
+ />
1057
+
1058
+ {u && (
1059
+ <TransferContentModal
1060
+ open={showTransfer}
1061
+ onClose={() => setShowTransfer(false)}
1062
+ fromUser={{ id: u.id, name: u.name, contentOwnedCount: u.contentOwnedCount }}
1063
+ onTransferred={refreshAfterChange}
1064
+ />
1065
+ )}
1066
+ </Drawer>
1067
+ </>
1068
+ )
1069
+ }