@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.
@@ -1,103 +1,460 @@
1
1
  'use client'
2
2
 
3
3
  import * as Dialog from '@radix-ui/react-dialog'
4
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
4
5
  import {
5
- Pencil,
6
- Trash2,
7
- UserPlus,
6
+ Users as UsersIcon,
7
+ Mail,
8
+ ShieldCheck,
9
+ Ticket,
8
10
  Search,
9
- ArrowUpDown,
10
- ArrowUp,
11
- ArrowDown,
12
- Loader2,
11
+ Plus,
12
+ X,
13
+ Sparkles,
14
+ ShieldAlert,
15
+ MoreHorizontal,
16
+ ChevronDown,
17
+ Send,
18
+ RefreshCw,
19
+ Ban,
13
20
  AlertTriangle,
21
+ Check,
22
+ SlidersHorizontal,
14
23
  } from 'lucide-react'
15
- import { useState, useMemo, type FormEvent } from 'react'
24
+ import { useState, useMemo, useCallback, type FormEvent } from 'react'
16
25
  import { toast } from 'sonner'
17
- import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
18
26
  import { useApiData } from '../lib/useApiData.js'
19
27
  import { cmsApi } from '../lib/api.js'
28
+ import { Button } from '../components/ui/Button.js'
29
+ import { EmptyState } from '../components/ui/EmptyState.js'
30
+ import { Skeleton } from '../components/ui/Skeleton.js'
31
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
32
+ import { UserDetailDrawer } from './users/UserDetailDrawer.js'
20
33
 
21
- type UserSortKey = 'name' | 'role' | 'status' | 'lastLogin'
34
+ // ---------------------------------------------------------------------------
35
+ // Types (mirror the cms-core serialized shapes)
36
+ // ---------------------------------------------------------------------------
22
37
 
23
- export interface UsersProps {
24
- onNavigate?: (path: string) => void
25
- /** When rendered inside the Settings tab shell, drop the page padding and
26
- * demote the heading so it reads as a section rather than a second page. */
27
- embedded?: boolean
38
+ interface ApiMember {
39
+ id: string
40
+ name: string
41
+ email: string
42
+ initials: string
43
+ role: string
44
+ roleName: 'Owner' | 'Admin' | 'Editor' | 'Viewer'
45
+ status: 'active' | 'suspended' | 'locked' | 'deactivated'
46
+ mfaEnabled: boolean
47
+ externalDomain: boolean
48
+ lastLoginAt: string | null
49
+ lastActiveAt: string | null
50
+ acceptedInviteAt: string | null
51
+ createdAt: string
52
+ isCurrentUser: boolean
28
53
  }
29
54
 
30
- export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
31
- const { data, loading, error, refetch } = useApiData<any[]>('/users')
32
- const [showInviteDialog, setShowInviteDialog] = useState(false)
33
- const [inviteEmail, setInviteEmail] = useState('')
34
- const [inviteName, setInviteName] = useState('')
35
- const [inviteRole, setInviteRole] = useState('author')
36
- const [inviting, setInviting] = useState(false)
37
- const [searchQuery, setSearchQuery] = useState('')
38
- const [filterRole, setFilterRole] = useState('all')
39
- const [sortConfig, setSortConfig] = useState<SortConfig<UserSortKey> | null>(null)
40
-
41
- const users = data ?? []
42
-
43
- const filteredAndSorted = useMemo(() => {
44
- let results = users.filter((user: any) => {
45
- const matchesSearch =
46
- (user.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
47
- (user.email ?? '').toLowerCase().includes(searchQuery.toLowerCase())
48
- const matchesRole =
49
- filterRole === 'all' || (user.role ?? '').toLowerCase() === filterRole.toLowerCase()
50
- return matchesSearch && matchesRole
51
- })
52
-
53
- if (searchQuery.trim()) {
54
- results = sortByRelevance(results, searchQuery, (u: any) => [u.name, u.email, u.role])
55
- } else if (sortConfig) {
56
- results = [...results].sort((a: any, b: any) => {
57
- const aVal = a[sortConfig.key] ?? ''
58
- const bVal = b[sortConfig.key] ?? ''
59
- const cmp = String(aVal).localeCompare(String(bVal))
60
- return sortConfig.direction === 'asc' ? cmp : -cmp
61
- })
62
- }
63
- return results
64
- }, [users, searchQuery, filterRole, sortConfig])
55
+ interface ApiInvite {
56
+ id: string
57
+ email: string
58
+ role: string
59
+ roleName: 'Owner' | 'Admin' | 'Editor' | 'Viewer'
60
+ status: string
61
+ expiresAt: string
62
+ createdAt: string
63
+ expired: boolean
64
+ }
65
+
66
+ interface ApiStats {
67
+ members: number
68
+ pendingInvites: number
69
+ admins: number
70
+ seatsUsed: number
71
+ seatLimit: number | null
72
+ seatsAvailable: number | null
73
+ emailConfigured: boolean
74
+ }
75
+
76
+ interface ApiRecommendation {
77
+ id: string
78
+ userId: string | null
79
+ inviteId?: string | null
80
+ severity: 'low' | 'medium' | 'high' | 'critical'
81
+ type: string
82
+ title: string
83
+ description: string
84
+ recommendedAction: string
85
+ }
86
+
87
+ type RowStatus = ApiMember['status'] | 'invited' | 'expired'
88
+
89
+ interface Row {
90
+ kind: 'member' | 'invite'
91
+ id: string
92
+ name: string
93
+ email: string
94
+ initials: string
95
+ role: string
96
+ roleName: 'Owner' | 'Admin' | 'Editor' | 'Viewer'
97
+ status: RowStatus
98
+ mfaEnabled: boolean
99
+ externalDomain: boolean
100
+ /** ISO timestamp used for the "Last active" column. */
101
+ activityAt: string | null
102
+ isCurrentUser: boolean
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Presentation constants (token-driven; brand = violet, matches screenshots)
107
+ // ---------------------------------------------------------------------------
108
+
109
+ const ROLE_PILL: Record<string, string> = {
110
+ Owner: 'bg-brand/10 text-brand',
111
+ Admin: 'bg-info/10 text-info',
112
+ Editor: 'bg-accent text-accent-foreground',
113
+ Viewer: 'bg-muted text-muted-foreground',
114
+ }
115
+
116
+ const STATUS_META: Record<RowStatus, { dot: string; text: string; label: string }> = {
117
+ active: { dot: 'bg-success', text: 'text-success', label: 'Active' },
118
+ invited: { dot: 'bg-warning', text: 'text-warning', label: 'Invited' },
119
+ expired: { dot: 'bg-destructive', text: 'text-destructive', label: 'Expired invite' },
120
+ suspended: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', label: 'Suspended' },
121
+ locked: { dot: 'bg-destructive', text: 'text-destructive', label: 'Locked' },
122
+ deactivated: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', label: 'Deactivated' },
123
+ }
124
+
125
+ const SEVERITY_DOT: Record<ApiRecommendation['severity'], string> = {
126
+ critical: 'bg-destructive',
127
+ high: 'bg-destructive',
128
+ medium: 'bg-warning',
129
+ low: 'bg-info',
130
+ }
131
+
132
+ const AVATAR_TINTS = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4', 'bg-chart-5'] as const
133
+
134
+ interface RoleOption {
135
+ value: string
136
+ label: string
137
+ desc: string
138
+ }
139
+
140
+ const BASE_ROLE_OPTIONS: RoleOption[] = [
141
+ {
142
+ value: 'ADMIN',
143
+ label: 'Admin',
144
+ desc: 'Full access except billing and deleting the workspace.',
145
+ },
146
+ {
147
+ value: 'EDITOR',
148
+ label: 'Editor',
149
+ desc: 'Create, edit and publish content. No settings access.',
150
+ },
151
+ { value: 'VIEWER', label: 'Viewer', desc: 'Read-only access to content and analytics.' },
152
+ ]
153
+
154
+ const OWNER_ROLE_OPTION: RoleOption = {
155
+ value: 'OWNER',
156
+ label: 'Owner',
157
+ desc: 'Full workspace control, including billing and Owner management.',
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Helpers
162
+ // ---------------------------------------------------------------------------
163
+
164
+ function avatarTint(id: string): string {
165
+ let h = 0
166
+ for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0
167
+ return AVATAR_TINTS[h % AVATAR_TINTS.length]!
168
+ }
169
+
170
+ function relativeTime(iso: string | null): string {
171
+ if (!iso) return 'Never'
172
+ const then = new Date(iso).getTime()
173
+ if (Number.isNaN(then)) return '—'
174
+ const diffMs = Date.now() - then
175
+ const mins = Math.floor(diffMs / 60000)
176
+ if (mins < 5) return 'Active now'
177
+ if (mins < 60) return `${mins}m ago`
178
+ const hours = Math.floor(mins / 60)
179
+ if (hours < 24) return `${hours}h ago`
180
+ const days = Math.floor(hours / 24)
181
+ if (days === 1) return 'Yesterday'
182
+ return `${days} days ago`
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Metric cards
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function MetricCard({
190
+ icon,
191
+ label,
192
+ value,
193
+ sub,
194
+ }: {
195
+ icon: React.ReactNode
196
+ label: string
197
+ value: React.ReactNode
198
+ sub?: React.ReactNode
199
+ }) {
200
+ return (
201
+ <div className="border-border bg-card rounded-lg border p-4">
202
+ <div className="text-muted-foreground flex items-center gap-2">
203
+ {icon}
204
+ <span className="text-sm">{label}</span>
205
+ </div>
206
+ <div className="text-card-foreground mt-2 text-2xl font-medium">{value}</div>
207
+ {sub && <div className="text-muted-foreground mt-0.5 text-xs">{sub}</div>}
208
+ </div>
209
+ )
210
+ }
65
211
 
66
- const resetInviteForm = () => {
67
- setInviteEmail('')
68
- setInviteName('')
69
- setInviteRole('author')
212
+ function MetricCards({ stats, loading }: { stats: ApiStats | null; loading: boolean }) {
213
+ if (loading || !stats) {
214
+ return (
215
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
216
+ {Array.from({ length: 4 }, (_, i) => (
217
+ <Skeleton key={i} variant="card" />
218
+ ))}
219
+ </div>
220
+ )
70
221
  }
222
+ const seatsValue =
223
+ stats.seatLimit == null ? 'Unlimited' : `${stats.seatsUsed} / ${stats.seatLimit}`
224
+ return (
225
+ <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
226
+ <MetricCard
227
+ icon={<UsersIcon size={16} aria-hidden />}
228
+ label="Members"
229
+ value={stats.members}
230
+ />
231
+ <MetricCard
232
+ icon={<Mail size={16} aria-hidden />}
233
+ label="Pending invites"
234
+ value={stats.pendingInvites}
235
+ />
236
+ <MetricCard
237
+ icon={<ShieldCheck size={16} aria-hidden />}
238
+ label="Admins"
239
+ value={stats.admins}
240
+ />
241
+ <MetricCard
242
+ icon={<Ticket size={16} aria-hidden />}
243
+ label="Seats used"
244
+ value={seatsValue}
245
+ sub={stats.seatLimit == null ? 'No seat limit set' : undefined}
246
+ />
247
+ </div>
248
+ )
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Access review banner
253
+ // ---------------------------------------------------------------------------
71
254
 
72
- const handleInvite = async (e: FormEvent) => {
255
+ function AccessReviewBanner({
256
+ recommendations,
257
+ onAct,
258
+ onDismiss,
259
+ }: {
260
+ recommendations: ApiRecommendation[]
261
+ onAct: (rec: ApiRecommendation) => void
262
+ onDismiss: (rec: ApiRecommendation) => void
263
+ }) {
264
+ if (recommendations.length === 0) return null
265
+ const top = recommendations[0]!
266
+ const more = recommendations.length - 1
267
+ return (
268
+ <div
269
+ className="border-brand/20 bg-brand/5 flex items-start gap-3 rounded-lg border p-4"
270
+ role="status"
271
+ aria-live="polite"
272
+ >
273
+ <span className="bg-brand text-brand-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
274
+ <ShieldAlert size={16} aria-hidden />
275
+ </span>
276
+ <div className="min-w-0 flex-1">
277
+ <div className="flex items-center gap-2">
278
+ <span className="text-card-foreground text-sm font-medium">{top.title}</span>
279
+ <span className="bg-brand/10 text-brand inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium">
280
+ <Sparkles size={10} aria-hidden />
281
+ AI
282
+ </span>
283
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[10px]">
284
+ <span className={`h-1.5 w-1.5 rounded-full ${SEVERITY_DOT[top.severity]}`} />
285
+ {top.severity}
286
+ </span>
287
+ </div>
288
+ <p className="text-muted-foreground mt-0.5 text-sm">{top.description}</p>
289
+ {more > 0 && (
290
+ <p className="text-muted-foreground mt-1 text-xs">
291
+ +{more} more recommendation{more > 1 ? 's' : ''}
292
+ </p>
293
+ )}
294
+ </div>
295
+ <div className="flex shrink-0 items-center gap-2">
296
+ <Button size="sm" variant="primary" onClick={() => onAct(top)}>
297
+ {top.recommendedAction}
298
+ </Button>
299
+ <button
300
+ type="button"
301
+ onClick={() => onDismiss(top)}
302
+ className="text-muted-foreground hover:bg-accent rounded-md p-1.5 transition-colors"
303
+ aria-label="Dismiss recommendation"
304
+ >
305
+ <X size={16} aria-hidden />
306
+ </button>
307
+ </div>
308
+ </div>
309
+ )
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Avatar + row pieces
314
+ // ---------------------------------------------------------------------------
315
+
316
+ function Avatar({ row }: { row: Row }) {
317
+ return (
318
+ <span
319
+ className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white ${avatarTint(row.id)}`}
320
+ aria-hidden
321
+ >
322
+ {row.initials}
323
+ </span>
324
+ )
325
+ }
326
+
327
+ function RoleDropdown({
328
+ row,
329
+ options,
330
+ disabled,
331
+ onChange,
332
+ }: {
333
+ row: Row
334
+ options: RoleOption[]
335
+ disabled: boolean
336
+ onChange: (role: string) => void
337
+ }) {
338
+ const pill = ROLE_PILL[row.roleName] ?? ROLE_PILL.Viewer
339
+ if (disabled) {
340
+ return (
341
+ <span
342
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${pill}`}
343
+ >
344
+ {row.roleName}
345
+ </span>
346
+ )
347
+ }
348
+ return (
349
+ <DropdownMenu.Root>
350
+ <DropdownMenu.Trigger asChild>
351
+ <button
352
+ type="button"
353
+ className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-opacity hover:opacity-80 ${pill}`}
354
+ aria-label={`Change role for ${row.name}, current role ${row.roleName}`}
355
+ >
356
+ {row.roleName}
357
+ <ChevronDown size={12} aria-hidden />
358
+ </button>
359
+ </DropdownMenu.Trigger>
360
+ <DropdownMenu.Portal>
361
+ <DropdownMenu.Content
362
+ align="start"
363
+ sideOffset={4}
364
+ className="border-border bg-card z-50 min-w-44 rounded-lg border p-1 shadow-md"
365
+ >
366
+ {options.map((opt) => (
367
+ <DropdownMenu.Item
368
+ key={opt.value}
369
+ onSelect={() => onChange(opt.value)}
370
+ className="text-card-foreground data-highlighted:bg-accent flex cursor-pointer items-center justify-between gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
371
+ >
372
+ {opt.label}
373
+ {row.roleName.toUpperCase() === opt.value && (
374
+ <Check size={14} className="text-brand" aria-hidden />
375
+ )}
376
+ </DropdownMenu.Item>
377
+ ))}
378
+ </DropdownMenu.Content>
379
+ </DropdownMenu.Portal>
380
+ </DropdownMenu.Root>
381
+ )
382
+ }
383
+
384
+ function StatusBadge({ status }: { status: RowStatus }) {
385
+ const meta = STATUS_META[status]
386
+ return (
387
+ <span className={`inline-flex items-center gap-1.5 text-xs ${meta.text}`}>
388
+ <span className={`h-1.5 w-1.5 rounded-full ${meta.dot}`} aria-hidden />
389
+ {meta.label}
390
+ </span>
391
+ )
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Invite modal
396
+ // ---------------------------------------------------------------------------
397
+
398
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
399
+
400
+ function InviteModal({
401
+ open,
402
+ onOpenChange,
403
+ roleOptions,
404
+ emailConfigured,
405
+ seatsFull,
406
+ onInvited,
407
+ }: {
408
+ open: boolean
409
+ onOpenChange: (v: boolean) => void
410
+ roleOptions: RoleOption[]
411
+ emailConfigured: boolean
412
+ seatsFull: boolean
413
+ onInvited: () => void
414
+ }) {
415
+ const [email, setEmail] = useState('')
416
+ const [role, setRole] = useState('EDITOR')
417
+ const [message, setMessage] = useState('')
418
+ const [expiresInDays, setExpiresInDays] = useState(7)
419
+ const [showAdvanced, setShowAdvanced] = useState(false)
420
+ const [sending, setSending] = useState(false)
421
+ const [emailError, setEmailError] = useState<string | null>(null)
422
+
423
+ const reset = useCallback(() => {
424
+ setEmail('')
425
+ setRole('EDITOR')
426
+ setMessage('')
427
+ setExpiresInDays(7)
428
+ setShowAdvanced(false)
429
+ setEmailError(null)
430
+ }, [])
431
+
432
+ const handleSubmit = async (e: FormEvent) => {
73
433
  e.preventDefault()
74
- if (inviting) return
75
- setInviting(true)
434
+ if (sending) return
435
+ const trimmed = email.trim()
436
+ if (!EMAIL_RE.test(trimmed)) {
437
+ setEmailError('Enter a valid email address.')
438
+ return
439
+ }
440
+ setSending(true)
76
441
  try {
77
- const res = await cmsApi<{
78
- success: boolean
79
- emailed?: boolean
80
- inviteUrl?: string
81
- user?: { email: string }
82
- }>('/users/invite', {
442
+ const res = await cmsApi<{ emailed?: boolean; inviteUrl?: string }>('/users/invite', {
83
443
  method: 'POST',
84
444
  body: JSON.stringify({
85
- email: inviteEmail.trim(),
86
- name: inviteName.trim() || undefined,
87
- role: inviteRole,
445
+ email: trimmed,
446
+ role,
447
+ message: message.trim() || undefined,
448
+ expiresInDays,
88
449
  }),
89
450
  })
90
-
91
451
  if (res.error) {
92
452
  toast.error(res.error)
93
453
  return
94
454
  }
95
-
96
455
  if (res.data?.emailed) {
97
- toast.success(`Invitation sent to ${inviteEmail.trim()}`)
456
+ toast.success(`Invitation sent to ${trimmed}`)
98
457
  } else if (res.data?.inviteUrl) {
99
- // No email provider configured (or the send failed): surface the link
100
- // so the admin can share it manually rather than silently succeeding.
101
458
  await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => {})
102
459
  toast.success(
103
460
  'Invite created. Link copied — email isn’t configured, so share it manually.',
@@ -108,297 +465,774 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
108
465
  } else {
109
466
  toast.success('Invitation created.')
110
467
  }
111
-
112
- setShowInviteDialog(false)
113
- resetInviteForm()
114
- refetch()
468
+ onOpenChange(false)
469
+ reset()
470
+ onInvited()
115
471
  } catch {
116
472
  toast.error('Could not create the invitation. Please try again.')
117
473
  } finally {
118
- setInviting(false)
474
+ setSending(false)
119
475
  }
120
476
  }
121
477
 
122
- const handleDelete = async (userId: number) => {
123
- const res = await cmsApi(`/users/${userId}`, { method: 'DELETE' })
124
- if (res.error) {
125
- toast.error(res.error)
126
- } else {
127
- toast.success('User removed')
128
- refetch()
478
+ return (
479
+ <Dialog.Root
480
+ open={open}
481
+ onOpenChange={(v) => {
482
+ onOpenChange(v)
483
+ if (!v) reset()
484
+ }}
485
+ >
486
+ <Dialog.Portal>
487
+ <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" />
488
+ <Dialog.Content className="border-border bg-card fixed top-1/2 left-1/2 z-50 max-h-[90vh] w-[calc(100vw-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border p-6 shadow-lg focus:outline-none">
489
+ <div className="mb-4 flex items-center justify-between">
490
+ <Dialog.Title className="text-card-foreground text-lg font-medium">
491
+ Invite a user
492
+ </Dialog.Title>
493
+ <Dialog.Close asChild>
494
+ <button
495
+ type="button"
496
+ className="text-muted-foreground hover:bg-accent rounded-md p-1 transition-colors"
497
+ aria-label="Close"
498
+ >
499
+ <X size={18} aria-hidden />
500
+ </button>
501
+ </Dialog.Close>
502
+ </div>
503
+
504
+ {seatsFull && (
505
+ <div className="border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
506
+ <AlertTriangle size={16} className="mt-0.5 shrink-0" aria-hidden />
507
+ <span>
508
+ All seats are in use. Remove a member or raise the seat limit before inviting.
509
+ </span>
510
+ </div>
511
+ )}
512
+ {!emailConfigured && !seatsFull && (
513
+ <div className="border-border bg-muted/50 text-muted-foreground mb-4 flex items-start gap-2 rounded-md border p-3 text-sm">
514
+ <Mail size={16} className="mt-0.5 shrink-0" aria-hidden />
515
+ <span>
516
+ Email isn’t configured — you’ll get a shareable invite link to send manually.
517
+ </span>
518
+ </div>
519
+ )}
520
+
521
+ <form onSubmit={handleSubmit} className="space-y-4">
522
+ <div>
523
+ <label
524
+ htmlFor="invite-email"
525
+ className="text-card-foreground mb-1 block text-sm font-medium"
526
+ >
527
+ Email address
528
+ </label>
529
+ <input
530
+ id="invite-email"
531
+ type="email"
532
+ value={email}
533
+ onChange={(e) => {
534
+ setEmail(e.target.value)
535
+ if (emailError) setEmailError(null)
536
+ }}
537
+ placeholder="name@company.com"
538
+ autoComplete="off"
539
+ aria-invalid={!!emailError}
540
+ aria-describedby={emailError ? 'invite-email-error' : undefined}
541
+ 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"
542
+ required
543
+ />
544
+ {emailError && (
545
+ <p id="invite-email-error" className="text-destructive mt-1 text-xs" role="alert">
546
+ {emailError}
547
+ </p>
548
+ )}
549
+ </div>
550
+
551
+ <fieldset>
552
+ <legend className="text-card-foreground mb-2 block text-sm font-medium">Role</legend>
553
+ <div className="space-y-2">
554
+ {roleOptions.map((opt) => {
555
+ const active = role === opt.value
556
+ return (
557
+ <label
558
+ key={opt.value}
559
+ className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${
560
+ active ? 'border-brand bg-brand/5' : 'border-border hover:bg-accent/50'
561
+ }`}
562
+ >
563
+ <input
564
+ type="radio"
565
+ name="invite-role"
566
+ value={opt.value}
567
+ checked={active}
568
+ onChange={() => setRole(opt.value)}
569
+ className="accent-brand mt-0.5"
570
+ />
571
+ <span className="min-w-0">
572
+ <span className="text-card-foreground block text-sm font-medium">
573
+ {opt.label}
574
+ </span>
575
+ <span className="text-muted-foreground block text-xs">{opt.desc}</span>
576
+ </span>
577
+ </label>
578
+ )
579
+ })}
580
+ </div>
581
+ </fieldset>
582
+
583
+ <div>
584
+ <button
585
+ type="button"
586
+ onClick={() => setShowAdvanced((v) => !v)}
587
+ className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors"
588
+ aria-expanded={showAdvanced}
589
+ >
590
+ <ChevronDown
591
+ size={14}
592
+ className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
593
+ aria-hidden
594
+ />
595
+ Advanced options
596
+ </button>
597
+ {showAdvanced && (
598
+ <div className="mt-3 space-y-3">
599
+ <div>
600
+ <label
601
+ htmlFor="invite-message"
602
+ className="text-card-foreground mb-1 block text-sm font-medium"
603
+ >
604
+ Custom message{' '}
605
+ <span className="text-muted-foreground font-normal">(optional)</span>
606
+ </label>
607
+ <textarea
608
+ id="invite-message"
609
+ value={message}
610
+ onChange={(e) => setMessage(e.target.value)}
611
+ rows={2}
612
+ placeholder="Add a personal note to the invitation email."
613
+ 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"
614
+ />
615
+ </div>
616
+ <div>
617
+ <label
618
+ htmlFor="invite-expiry"
619
+ className="text-card-foreground mb-1 block text-sm font-medium"
620
+ >
621
+ Invite expires in
622
+ </label>
623
+ <select
624
+ id="invite-expiry"
625
+ value={expiresInDays}
626
+ onChange={(e) => setExpiresInDays(Number(e.target.value))}
627
+ 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"
628
+ >
629
+ <option value={7}>7 days</option>
630
+ <option value={14}>14 days</option>
631
+ <option value={30}>30 days</option>
632
+ </select>
633
+ </div>
634
+ </div>
635
+ )}
636
+ </div>
637
+
638
+ <div className="flex items-center justify-end gap-3 pt-2">
639
+ <Dialog.Close asChild>
640
+ <Button type="button" variant="outline">
641
+ Cancel
642
+ </Button>
643
+ </Dialog.Close>
644
+ <Button
645
+ type="submit"
646
+ variant="primary"
647
+ loading={sending}
648
+ disabled={sending || seatsFull || !email.trim()}
649
+ leftIcon={!sending ? <Send size={16} aria-hidden /> : undefined}
650
+ >
651
+ {sending ? 'Sending…' : 'Send invite'}
652
+ </Button>
653
+ </div>
654
+ </form>
655
+ </Dialog.Content>
656
+ </Dialog.Portal>
657
+ </Dialog.Root>
658
+ )
659
+ }
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // Main view
663
+ // ---------------------------------------------------------------------------
664
+
665
+ const ROLE_PILLS: Array<'All' | 'Owner' | 'Admin' | 'Editor' | 'Viewer'> = [
666
+ 'All',
667
+ 'Owner',
668
+ 'Admin',
669
+ 'Editor',
670
+ 'Viewer',
671
+ ]
672
+
673
+ export interface UsersProps {
674
+ onNavigate?: (path: string) => void
675
+ embedded?: boolean
676
+ }
677
+
678
+ export function Users({ embedded = false }: UsersProps = {}) {
679
+ const members = useApiData<ApiMember[]>('/users')
680
+ const invites = useApiData<ApiInvite[]>('/invites')
681
+ const stats = useApiData<ApiStats>('/users/stats')
682
+ const review = useApiData<ApiRecommendation[]>('/access-review')
683
+
684
+ const [search, setSearch] = useState('')
685
+ const [roleFilter, setRoleFilter] = useState<(typeof ROLE_PILLS)[number]>('All')
686
+ const [showInvite, setShowInvite] = useState(false)
687
+ const [detailUserId, setDetailUserId] = useState<string | null>(null)
688
+ const [confirm, setConfirm] = useState<{
689
+ title: string
690
+ description: string
691
+ confirmLabel: string
692
+ destructive?: boolean
693
+ onConfirm: () => void
694
+ } | null>(null)
695
+
696
+ const refetchAll = useCallback(() => {
697
+ members.refetch()
698
+ invites.refetch()
699
+ stats.refetch()
700
+ review.refetch()
701
+ }, [members, invites, stats, review])
702
+
703
+ const currentUserRole = useMemo(
704
+ () => (members.data ?? []).find((m) => m.isCurrentUser)?.roleName ?? null,
705
+ [members.data],
706
+ )
707
+ const isOwner = currentUserRole === 'Owner'
708
+
709
+ const roleOptions = useMemo(
710
+ () => (isOwner ? [OWNER_ROLE_OPTION, ...BASE_ROLE_OPTIONS] : BASE_ROLE_OPTIONS),
711
+ [isOwner],
712
+ )
713
+
714
+ const rows: Row[] = useMemo(() => {
715
+ const memberRows: Row[] = (members.data ?? []).map((m) => ({
716
+ kind: 'member',
717
+ id: m.id,
718
+ name: m.name,
719
+ email: m.email,
720
+ initials: m.initials,
721
+ role: m.role,
722
+ roleName: m.roleName,
723
+ status: m.status,
724
+ mfaEnabled: m.mfaEnabled,
725
+ externalDomain: m.externalDomain,
726
+ activityAt: m.lastActiveAt ?? m.lastLoginAt,
727
+ isCurrentUser: m.isCurrentUser,
728
+ }))
729
+ const inviteRows: Row[] = (invites.data ?? [])
730
+ .filter((i) => i.status === 'PENDING' || i.status === 'EXPIRED')
731
+ .map((i) => ({
732
+ kind: 'invite',
733
+ id: i.id,
734
+ name: i.email.split('@')[0] ?? i.email,
735
+ email: i.email,
736
+ initials: (i.email.slice(0, 2) || '?').toUpperCase(),
737
+ role: i.role,
738
+ roleName: i.roleName,
739
+ status: i.expired || i.status === 'EXPIRED' ? 'expired' : 'invited',
740
+ mfaEnabled: false,
741
+ externalDomain: false,
742
+ activityAt: i.createdAt,
743
+ isCurrentUser: false,
744
+ }))
745
+ return [...memberRows, ...inviteRows]
746
+ }, [members.data, invites.data])
747
+
748
+ const filteredRows = useMemo(() => {
749
+ let r = rows
750
+ if (roleFilter !== 'All') r = r.filter((row) => row.roleName === roleFilter)
751
+ if (search.trim()) {
752
+ const q = search.trim().toLowerCase()
753
+ r = r.filter(
754
+ (row) =>
755
+ row.name.toLowerCase().includes(q) ||
756
+ row.email.toLowerCase().includes(q) ||
757
+ row.roleName.toLowerCase().includes(q) ||
758
+ row.status.includes(q),
759
+ )
129
760
  }
130
- }
761
+ return r
762
+ }, [rows, roleFilter, search])
131
763
 
132
- function SortHeader({ label, sortKey }: { label: string; sortKey: UserSortKey }) {
133
- const active = sortConfig?.key === sortKey
134
- return (
135
- <button
136
- type="button"
137
- onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
138
- className="flex items-center gap-1 text-xs font-medium text-gray-700 transition-colors hover:text-gray-900"
139
- >
140
- {label}
141
- {active ? (
142
- sortConfig!.direction === 'asc' ? (
143
- <ArrowUp className="h-3 w-3" />
144
- ) : (
145
- <ArrowDown className="h-3 w-3" />
146
- )
147
- ) : (
148
- <ArrowUpDown className="h-3 w-3 text-gray-400" />
149
- )}
150
- </button>
151
- )
152
- }
764
+ // -------------------------------------------------------------------------
765
+ // Actions
766
+ // -------------------------------------------------------------------------
153
767
 
154
- const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8'
155
- const Heading = embedded ? 'h2' : 'h1'
768
+ const changeMemberRole = useCallback(
769
+ async (row: Row, role: string) => {
770
+ if (role.toUpperCase() === row.role.toUpperCase()) return
771
+ const endpoint = row.kind === 'invite' ? `/invites/${row.id}` : `/users/${row.id}/role`
772
+ const res = await cmsApi(endpoint, { method: 'PATCH', body: JSON.stringify({ role }) })
773
+ if (res.error) {
774
+ toast.error(res.error)
775
+ return
776
+ }
777
+ toast.success(`Role updated to ${role.charAt(0) + role.slice(1).toLowerCase()}`)
778
+ refetchAll()
779
+ },
780
+ [refetchAll],
781
+ )
156
782
 
157
- if (loading) {
158
- return (
159
- <div className={`flex h-64 items-center justify-center ${rootPadding}`}>
160
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
161
- </div>
162
- )
163
- }
783
+ const requestRoleChange = useCallback(
784
+ (row: Row, role: string) => {
785
+ if (role === 'OWNER' || row.roleName === 'Owner') {
786
+ setConfirm({
787
+ title: role === 'OWNER' ? 'Assign Owner role' : 'Change Owner’s role',
788
+ description:
789
+ role === 'OWNER'
790
+ ? `Owners have full control of the workspace, including billing and managing other Owners. Make ${row.name} an Owner?`
791
+ : `This will change ${row.name}'s role away from Owner. Continue?`,
792
+ confirmLabel: 'Change role',
793
+ onConfirm: () => changeMemberRole(row, role),
794
+ })
795
+ return
796
+ }
797
+ changeMemberRole(row, role)
798
+ },
799
+ [changeMemberRole],
800
+ )
801
+
802
+ const revokeMember = useCallback(
803
+ (row: Row) => {
804
+ setConfirm({
805
+ title: 'Revoke access',
806
+ description: `${row.name} will lose access immediately and all their sessions will be revoked. Their content and history are preserved. This can be reversed later.`,
807
+ confirmLabel: 'Revoke access',
808
+ destructive: true,
809
+ onConfirm: async () => {
810
+ const res = await cmsApi(`/users/${row.id}`, { method: 'DELETE' })
811
+ if (res.error) {
812
+ toast.error(res.error)
813
+ return
814
+ }
815
+ toast.success(`Access revoked for ${row.name}`)
816
+ refetchAll()
817
+ },
818
+ })
819
+ },
820
+ [refetchAll],
821
+ )
822
+
823
+ const cancelInvite = useCallback(
824
+ (row: Row) => {
825
+ setConfirm({
826
+ title: 'Cancel invitation',
827
+ description: `The invitation to ${row.email} will be cancelled and the link will stop working.`,
828
+ confirmLabel: 'Cancel invite',
829
+ destructive: true,
830
+ onConfirm: async () => {
831
+ const res = await cmsApi(`/invites/${row.id}/cancel`, { method: 'POST' })
832
+ if (res.error) {
833
+ toast.error(res.error)
834
+ return
835
+ }
836
+ toast.success('Invitation cancelled')
837
+ refetchAll()
838
+ },
839
+ })
840
+ },
841
+ [refetchAll],
842
+ )
843
+
844
+ const resendInvite = useCallback(
845
+ async (row: Row) => {
846
+ const res = await cmsApi<{ emailed?: boolean; inviteUrl?: string }>(
847
+ `/invites/${row.id}/resend`,
848
+ { method: 'POST' },
849
+ )
850
+ if (res.error) {
851
+ toast.error(res.error)
852
+ return
853
+ }
854
+ if (res.data?.emailed) {
855
+ toast.success(`Invitation re-sent to ${row.email}`)
856
+ } else if (res.data?.inviteUrl) {
857
+ await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => {})
858
+ toast.success('New invite link copied — email isn’t configured, so share it manually.', {
859
+ duration: 8000,
860
+ })
861
+ } else {
862
+ toast.success('Invitation re-sent')
863
+ }
864
+ refetchAll()
865
+ },
866
+ [refetchAll],
867
+ )
868
+
869
+ const actOnRecommendation = useCallback(
870
+ (rec: ApiRecommendation) => {
871
+ const target = rows.find((r) => r.id === rec.userId || r.id === rec.inviteId)
872
+ if (rec.type === 'stale_invite' && target) {
873
+ cancelInvite(target)
874
+ return
875
+ }
876
+ if (target) {
877
+ revokeMember(target)
878
+ return
879
+ }
880
+ toast.info('Open the member to action this recommendation.')
881
+ },
882
+ [rows, cancelInvite, revokeMember],
883
+ )
884
+
885
+ const dismissRecommendation = useCallback(
886
+ async (rec: ApiRecommendation) => {
887
+ const res = await cmsApi(`/access-review/${encodeURIComponent(rec.id)}/dismiss`, {
888
+ method: 'POST',
889
+ })
890
+ if (res.error) {
891
+ toast.error(res.error)
892
+ return
893
+ }
894
+ toast.success('Recommendation dismissed')
895
+ review.refetch()
896
+ },
897
+ [review],
898
+ )
899
+
900
+ // -------------------------------------------------------------------------
901
+ // Render
902
+ // -------------------------------------------------------------------------
903
+
904
+ const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8'
905
+ const loading = members.loading || invites.loading
906
+ const loadError = members.error || invites.error
907
+ const seatsFull = stats.data?.seatsAvailable != null && stats.data.seatsAvailable <= 0
908
+ const peopleCount = (members.data?.length ?? 0) + rows.filter((r) => r.kind === 'invite').length
164
909
 
165
910
  return (
166
- <div className={rootPadding}>
167
- {error && (
168
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
169
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
170
- <span className="flex-1 text-sm text-red-800">{error}</span>
171
- <button
172
- onClick={refetch}
173
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
174
- >
175
- Retry
176
- </button>
177
- </div>
911
+ <div className={`space-y-4 ${rootPadding}`}>
912
+ <MetricCards stats={stats.data} loading={stats.loading} />
913
+
914
+ {!review.loading && review.data && review.data.length > 0 && (
915
+ <AccessReviewBanner
916
+ recommendations={review.data}
917
+ onAct={actOnRecommendation}
918
+ onDismiss={dismissRecommendation}
919
+ />
178
920
  )}
179
921
 
180
- <div className="mb-4 flex items-center justify-between">
181
- <div>
182
- <Heading className="mb-1 text-2xl font-semibold text-gray-900">Users</Heading>
183
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total users</p>
922
+ <div className="border-border bg-card rounded-lg border">
923
+ {/* Header */}
924
+ <div className="border-border flex flex-col gap-3 border-b p-4 lg:flex-row lg:items-center lg:justify-between">
925
+ <div>
926
+ <h2 className="text-card-foreground text-base font-medium">Team members</h2>
927
+ <p className="text-muted-foreground text-sm">
928
+ {peopleCount} {peopleCount === 1 ? 'person has' : 'people have'} access to this
929
+ workspace
930
+ </p>
931
+ </div>
932
+ <div className="flex items-center gap-2">
933
+ <div className="relative">
934
+ <Search
935
+ size={16}
936
+ className="text-muted-foreground absolute top-1/2 left-3 -translate-y-1/2"
937
+ aria-hidden
938
+ />
939
+ <input
940
+ type="text"
941
+ value={search}
942
+ onChange={(e) => setSearch(e.target.value)}
943
+ placeholder="Search by name or email…"
944
+ aria-label="Search team members"
945
+ className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border py-2 pr-3 pl-9 text-sm focus:ring-2 focus:outline-none lg:w-64"
946
+ />
947
+ </div>
948
+ <Button
949
+ variant="primary"
950
+ onClick={() => setShowInvite(true)}
951
+ disabled={seatsFull}
952
+ title={seatsFull ? 'All seats are in use' : undefined}
953
+ leftIcon={<Plus size={16} aria-hidden />}
954
+ >
955
+ Invite user
956
+ </Button>
957
+ </div>
184
958
  </div>
185
- <button
186
- type="button"
187
- onClick={() => setShowInviteDialog(true)}
188
- className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
189
- >
190
- <UserPlus className="h-4 w-4" />
191
- Invite User
192
- </button>
193
- </div>
194
959
 
195
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
196
- <div className="flex flex-col gap-3 p-3 sm:flex-row">
197
- <div className="relative flex-1">
198
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
199
- <input
200
- type="text"
201
- placeholder="Search users by name or email..."
202
- value={searchQuery}
203
- onChange={(e) => setSearchQuery(e.target.value)}
204
- className="w-full rounded-lg border border-gray-300 py-2 pr-3 pl-9 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
205
- />
206
- </div>
207
- <select
208
- value={filterRole}
209
- onChange={(e) => setFilterRole(e.target.value)}
210
- className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
211
- >
212
- <option value="all">All Roles</option>
213
- <option value="admin">Admin</option>
214
- <option value="editor">Editor</option>
215
- <option value="author">Author</option>
216
- <option value="client">Client</option>
217
- </select>
960
+ {/* Role filter pills */}
961
+ <div
962
+ className="border-border flex flex-wrap gap-2 border-b px-4 py-3"
963
+ role="group"
964
+ aria-label="Filter by role"
965
+ >
966
+ {ROLE_PILLS.map((pill) => {
967
+ const active = roleFilter === pill
968
+ return (
969
+ <button
970
+ key={pill}
971
+ type="button"
972
+ onClick={() => setRoleFilter(pill)}
973
+ aria-pressed={active}
974
+ className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
975
+ active
976
+ ? 'bg-brand text-brand-foreground'
977
+ : 'bg-muted text-muted-foreground hover:bg-accent'
978
+ }`}
979
+ >
980
+ {pill}
981
+ </button>
982
+ )
983
+ })}
218
984
  </div>
219
- </div>
220
985
 
221
- {filteredAndSorted.length === 0 && !loading ? (
222
- <div className="flex flex-col items-center justify-center py-16 text-center">
223
- <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
224
- <UserPlus className="h-6 w-6 text-gray-400" />
986
+ {/* Table / states */}
987
+ {loading ? (
988
+ <Skeleton variant="table-row" lines={5} className="p-2" />
989
+ ) : loadError ? (
990
+ <div className="flex items-center gap-3 p-4">
991
+ <AlertTriangle size={18} className="text-destructive shrink-0" aria-hidden />
992
+ <span className="text-destructive flex-1 text-sm">{loadError}</span>
993
+ <Button variant="outline" size="sm" onClick={refetchAll}>
994
+ Retry
995
+ </Button>
225
996
  </div>
226
- <h3 className="mb-1 text-sm font-medium text-gray-900">No users yet</h3>
227
- <p className="text-sm text-gray-500">Invite your first user to get started.</p>
228
- </div>
229
- ) : (
230
- <div className="rounded-lg border border-gray-200 bg-white">
997
+ ) : rows.length === 0 ? (
998
+ <EmptyState
999
+ icon={<UsersIcon size={24} aria-hidden />}
1000
+ title="No team members yet"
1001
+ description="Invite your first teammate to collaborate in this workspace."
1002
+ actionLabel="Invite user"
1003
+ onAction={() => setShowInvite(true)}
1004
+ />
1005
+ ) : filteredRows.length === 0 ? (
1006
+ <EmptyState
1007
+ icon={<Search size={24} aria-hidden />}
1008
+ title="No users match your filters"
1009
+ description="Try a different search or role filter."
1010
+ actionLabel="Clear filters"
1011
+ onAction={() => {
1012
+ setSearch('')
1013
+ setRoleFilter('All')
1014
+ }}
1015
+ />
1016
+ ) : (
231
1017
  <div className="overflow-x-auto">
232
- <table className="w-full">
233
- <thead className="border-b border-gray-200 bg-gray-50">
234
- <tr>
235
- <th className="px-4 py-2 text-left">
236
- <SortHeader label="User" sortKey="name" />
1018
+ <table className="w-full min-w-[640px]">
1019
+ <thead>
1020
+ <tr className="border-border text-muted-foreground border-b text-left text-xs">
1021
+ <th className="px-4 py-2 font-medium">Member</th>
1022
+ <th className="px-4 py-2 font-medium">Role</th>
1023
+ <th className="px-4 py-2 font-medium">Status</th>
1024
+ <th className="px-4 py-2 font-medium">Last active</th>
1025
+ <th className="px-4 py-2 font-medium">
1026
+ <span className="sr-only">Actions</span>
237
1027
  </th>
238
- <th className="px-4 py-2 text-left">
239
- <SortHeader label="Role" sortKey="role" />
240
- </th>
241
- <th className="px-4 py-2 text-left">
242
- <SortHeader label="Status" sortKey="status" />
243
- </th>
244
- <th className="px-4 py-2 text-left">
245
- <SortHeader label="Last Active" sortKey="lastLogin" />
246
- </th>
247
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
248
1028
  </tr>
249
1029
  </thead>
250
- <tbody className="divide-y divide-gray-200">
251
- {filteredAndSorted.map((user: any) => (
252
- <tr key={user.id} className="transition-colors hover:bg-gray-50">
253
- <td className="px-4 py-3">
254
- <div className="flex items-center gap-3">
255
- <div className="flex h-10 w-10 items-center justify-center rounded-full bg-linear-to-br from-blue-500 to-purple-600 text-sm font-medium text-white">
256
- {user.name.charAt(0)}
257
- </div>
258
- <div>
259
- <div className="text-sm font-medium text-gray-900">{user.name}</div>
260
- <div className="text-xs text-gray-600">{user.email}</div>
1030
+ <tbody className="divide-border divide-y">
1031
+ {filteredRows.map((row) => {
1032
+ const isInvite = row.kind === 'invite'
1033
+ // Last Owner / self protections are enforced server-side; here we
1034
+ // only disable the obviously-invalid affordances.
1035
+ const roleChangeDisabled =
1036
+ (row.roleName === 'Owner' && !isOwner) ||
1037
+ (row.roleName === 'Owner' && row.isCurrentUser)
1038
+ const openDetail = () => {
1039
+ if (!isInvite) setDetailUserId(row.id)
1040
+ }
1041
+ return (
1042
+ <tr
1043
+ key={`${row.kind}-${row.id}`}
1044
+ className={`hover:bg-accent/30 transition-colors ${
1045
+ isInvite ? '' : 'cursor-pointer'
1046
+ }`}
1047
+ {...(isInvite
1048
+ ? {}
1049
+ : {
1050
+ role: 'button',
1051
+ tabIndex: 0,
1052
+ 'aria-label': `Open details for ${row.name}`,
1053
+ onClick: openDetail,
1054
+ onKeyDown: (e: React.KeyboardEvent) => {
1055
+ if (e.key === 'Enter' || e.key === ' ') {
1056
+ e.preventDefault()
1057
+ openDetail()
1058
+ }
1059
+ },
1060
+ })}
1061
+ >
1062
+ <td className="px-4 py-3">
1063
+ <div className="flex items-center gap-3">
1064
+ <Avatar row={row} />
1065
+ <div className="min-w-0">
1066
+ <div className="text-card-foreground flex items-center gap-1.5 text-sm font-medium">
1067
+ <span className="truncate">{row.name}</span>
1068
+ {row.isCurrentUser && (
1069
+ <span className="bg-muted text-muted-foreground rounded px-1 text-[10px]">
1070
+ You
1071
+ </span>
1072
+ )}
1073
+ {row.externalDomain && (
1074
+ <span className="bg-warning/10 text-warning rounded px-1 text-[10px]">
1075
+ External
1076
+ </span>
1077
+ )}
1078
+ </div>
1079
+ <div className="text-muted-foreground truncate text-xs">
1080
+ {row.email}
1081
+ </div>
1082
+ </div>
261
1083
  </div>
262
- </div>
263
- </td>
264
- <td className="px-4 py-3">
265
- <span
266
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
267
- user.role === 'Admin'
268
- ? 'bg-purple-100 text-purple-800'
269
- : user.role === 'Editor'
270
- ? 'bg-blue-100 text-blue-800'
271
- : 'bg-gray-100 text-gray-800'
272
- }`}
1084
+ </td>
1085
+ <td
1086
+ className="px-4 py-3"
1087
+ onClick={(e) => e.stopPropagation()}
1088
+ role="presentation"
273
1089
  >
274
- {user.role}
275
- </span>
276
- </td>
277
- <td className="px-4 py-3">
278
- <span
279
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
280
- user.status === 'Active'
281
- ? 'bg-green-100 text-green-800'
282
- : 'bg-gray-100 text-gray-800'
283
- }`}
1090
+ <RoleDropdown
1091
+ row={row}
1092
+ options={roleOptions}
1093
+ disabled={roleChangeDisabled}
1094
+ onChange={(role) => requestRoleChange(row, role)}
1095
+ />
1096
+ </td>
1097
+ <td className="px-4 py-3">
1098
+ <StatusBadge status={row.status} />
1099
+ </td>
1100
+ <td className="text-muted-foreground px-4 py-3 text-sm">
1101
+ {isInvite
1102
+ ? `Invited ${relativeTime(row.activityAt)}`
1103
+ : relativeTime(row.activityAt)}
1104
+ </td>
1105
+ <td
1106
+ className="px-4 py-3 text-right"
1107
+ onClick={(e) => e.stopPropagation()}
1108
+ role="presentation"
284
1109
  >
285
- {user.status}
286
- </span>
287
- </td>
288
- <td className="px-4 py-3 text-sm text-gray-600">{user.lastLogin}</td>
289
- <td className="px-4 py-3">
290
- <div className="flex items-center gap-2">
291
- <button
292
- type="button"
293
- onClick={() => onNavigate?.(`/users/${user.id}`)}
294
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
295
- aria-label={`Edit ${user.name}`}
296
- >
297
- <Pencil className="h-4 w-4 text-gray-600" />
298
- </button>
299
- <button
300
- type="button"
301
- onClick={() => handleDelete(user.id)}
302
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
303
- aria-label={`Remove ${user.name}`}
304
- >
305
- <Trash2 className="h-4 w-4 text-red-600" />
306
- </button>
307
- </div>
308
- </td>
309
- </tr>
310
- ))}
1110
+ <RowActions
1111
+ row={row}
1112
+ isInvite={isInvite}
1113
+ onResend={() => resendInvite(row)}
1114
+ onCancelInvite={() => cancelInvite(row)}
1115
+ onRevoke={() => revokeMember(row)}
1116
+ onViewDetails={isInvite ? undefined : () => setDetailUserId(row.id)}
1117
+ canRevoke={!row.isCurrentUser}
1118
+ />
1119
+ </td>
1120
+ </tr>
1121
+ )
1122
+ })}
311
1123
  </tbody>
312
1124
  </table>
313
1125
  </div>
314
- </div>
1126
+ )}
1127
+ </div>
1128
+
1129
+ <InviteModal
1130
+ open={showInvite}
1131
+ onOpenChange={setShowInvite}
1132
+ roleOptions={roleOptions}
1133
+ emailConfigured={stats.data?.emailConfigured ?? true}
1134
+ seatsFull={!!seatsFull}
1135
+ onInvited={refetchAll}
1136
+ />
1137
+
1138
+ {confirm && (
1139
+ <ConfirmDialog
1140
+ open={!!confirm}
1141
+ onClose={() => setConfirm(null)}
1142
+ onConfirm={confirm.onConfirm}
1143
+ title={confirm.title}
1144
+ description={confirm.description}
1145
+ confirmLabel={confirm.confirmLabel}
1146
+ destructive={confirm.destructive}
1147
+ />
315
1148
  )}
316
1149
 
317
- <Dialog.Root open={showInviteDialog} onOpenChange={setShowInviteDialog}>
318
- <Dialog.Portal>
319
- <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
320
- <Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
321
- <Dialog.Title className="mb-1 text-lg font-semibold text-gray-900">
322
- Invite User
323
- </Dialog.Title>
324
- <Dialog.Description className="mb-4 text-sm text-gray-500">
325
- They’ll get a link to set a password and sign in. If email isn’t configured, you’ll
326
- get a link to share.
327
- </Dialog.Description>
328
- <form onSubmit={handleInvite} className="space-y-4">
329
- <div>
330
- <label
331
- htmlFor="invite-email"
332
- className="mb-1 block text-sm font-medium text-gray-700"
333
- >
334
- Email Address
335
- </label>
336
- <input
337
- id="invite-email"
338
- type="email"
339
- value={inviteEmail}
340
- onChange={(e) => setInviteEmail(e.target.value)}
341
- placeholder="user@example.com"
342
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
343
- required
344
- />
345
- </div>
346
- <div>
347
- <label
348
- htmlFor="invite-name"
349
- className="mb-1 block text-sm font-medium text-gray-700"
350
- >
351
- Name <span className="font-normal text-gray-400">(optional)</span>
352
- </label>
353
- <input
354
- id="invite-name"
355
- type="text"
356
- value={inviteName}
357
- onChange={(e) => setInviteName(e.target.value)}
358
- placeholder="Jane Doe"
359
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
360
- />
361
- </div>
362
- <div>
363
- <label
364
- htmlFor="invite-role"
365
- className="mb-1 block text-sm font-medium text-gray-700"
366
- >
367
- Role
368
- </label>
369
- <select
370
- id="invite-role"
371
- value={inviteRole}
372
- onChange={(e) => setInviteRole(e.target.value)}
373
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
374
- >
375
- <option value="author">Author</option>
376
- <option value="editor">Editor</option>
377
- <option value="admin">Admin</option>
378
- </select>
379
- </div>
380
- <div className="flex items-center justify-end gap-3 pt-4">
381
- <Dialog.Close asChild>
382
- <button
383
- type="button"
384
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
385
- >
386
- Cancel
387
- </button>
388
- </Dialog.Close>
389
- <button
390
- type="submit"
391
- disabled={inviting || !inviteEmail.trim()}
392
- className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
393
- >
394
- {inviting && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
395
- {inviting ? 'Sending…' : 'Send Invite'}
396
- </button>
397
- </div>
398
- </form>
399
- </Dialog.Content>
400
- </Dialog.Portal>
401
- </Dialog.Root>
1150
+ <UserDetailDrawer
1151
+ userId={detailUserId}
1152
+ open={detailUserId !== null}
1153
+ onOpenChange={(v) => {
1154
+ if (!v) setDetailUserId(null)
1155
+ }}
1156
+ onChanged={refetchAll}
1157
+ />
402
1158
  </div>
403
1159
  )
404
1160
  }
1161
+
1162
+ function RowActions({
1163
+ row,
1164
+ isInvite,
1165
+ onResend,
1166
+ onCancelInvite,
1167
+ onRevoke,
1168
+ onViewDetails,
1169
+ canRevoke,
1170
+ }: {
1171
+ row: Row
1172
+ isInvite: boolean
1173
+ onResend: () => void
1174
+ onCancelInvite: () => void
1175
+ onRevoke: () => void
1176
+ onViewDetails?: () => void
1177
+ canRevoke: boolean
1178
+ }) {
1179
+ return (
1180
+ <DropdownMenu.Root>
1181
+ <DropdownMenu.Trigger asChild>
1182
+ <button
1183
+ type="button"
1184
+ className="text-muted-foreground hover:bg-accent rounded-md p-1.5 transition-colors"
1185
+ aria-label={`Actions for ${row.name}`}
1186
+ >
1187
+ <MoreHorizontal size={16} aria-hidden />
1188
+ </button>
1189
+ </DropdownMenu.Trigger>
1190
+ <DropdownMenu.Portal>
1191
+ <DropdownMenu.Content
1192
+ align="end"
1193
+ sideOffset={4}
1194
+ className="border-border bg-card z-50 min-w-44 rounded-lg border p-1 shadow-md"
1195
+ >
1196
+ {isInvite ? (
1197
+ <>
1198
+ <DropdownMenu.Item
1199
+ onSelect={onResend}
1200
+ className="text-card-foreground data-highlighted:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
1201
+ >
1202
+ <RefreshCw size={14} aria-hidden />
1203
+ Resend invite
1204
+ </DropdownMenu.Item>
1205
+ <DropdownMenu.Item
1206
+ onSelect={onCancelInvite}
1207
+ className="text-destructive data-highlighted:bg-destructive/10 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
1208
+ >
1209
+ <Ban size={14} aria-hidden />
1210
+ Cancel invite
1211
+ </DropdownMenu.Item>
1212
+ </>
1213
+ ) : (
1214
+ <>
1215
+ {onViewDetails && (
1216
+ <DropdownMenu.Item
1217
+ onSelect={onViewDetails}
1218
+ className="text-card-foreground data-highlighted:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
1219
+ >
1220
+ <SlidersHorizontal size={14} aria-hidden />
1221
+ View details
1222
+ </DropdownMenu.Item>
1223
+ )}
1224
+ <DropdownMenu.Item
1225
+ onSelect={canRevoke ? onRevoke : undefined}
1226
+ disabled={!canRevoke}
1227
+ className="text-destructive data-highlighted:bg-destructive/10 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50"
1228
+ >
1229
+ <Ban size={14} aria-hidden />
1230
+ Revoke access
1231
+ </DropdownMenu.Item>
1232
+ </>
1233
+ )}
1234
+ </DropdownMenu.Content>
1235
+ </DropdownMenu.Portal>
1236
+ </DropdownMenu.Root>
1237
+ )
1238
+ }