@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.
- package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js +144 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
- package/dist/__tests__/views/users.render.test.d.ts +2 -0
- package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users.render.test.js +146 -0
- package/dist/__tests__/views/users.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Users.d.ts +1 -3
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +394 -73
- package/dist/views/Users.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts +9 -0
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
- package/dist/views/users/UserDetailDrawer.js +276 -0
- package/dist/views/users/UserDetailDrawer.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +162 -0
- package/src/__tests__/views/users.render.test.tsx +155 -0
- package/src/views/Users.tsx +1165 -331
- package/src/views/users/UserDetailDrawer.tsx +1069 -0
|
@@ -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}'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
|
+
}
|