@actuate-media/cms-admin 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/AdminRoot.d.ts +6 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -3
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  8. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  9. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/user-detail-drawer.render.test.js +113 -0
  12. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
  13. package/dist/__tests__/views/users.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/users.render.test.js +146 -0
  16. package/dist/__tests__/views/users.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/layout/Header.d.ts +3 -1
  19. package/dist/layout/Header.d.ts.map +1 -1
  20. package/dist/layout/Header.js +2 -2
  21. package/dist/layout/Header.js.map +1 -1
  22. package/dist/layout/Layout.d.ts +2 -1
  23. package/dist/layout/Layout.d.ts.map +1 -1
  24. package/dist/layout/Layout.js +2 -2
  25. package/dist/layout/Layout.js.map +1 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +54 -51
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/Users.d.ts +1 -3
  30. package/dist/views/Users.d.ts.map +1 -1
  31. package/dist/views/Users.js +420 -56
  32. package/dist/views/Users.js.map +1 -1
  33. package/dist/views/users/UserDetailDrawer.d.ts +9 -0
  34. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
  35. package/dist/views/users/UserDetailDrawer.js +236 -0
  36. package/dist/views/users/UserDetailDrawer.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/AdminRoot.tsx +28 -2
  39. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  40. package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
  41. package/src/__tests__/views/users.render.test.tsx +155 -0
  42. package/src/layout/Header.tsx +10 -2
  43. package/src/layout/Layout.tsx +10 -1
  44. package/src/views/Settings.tsx +142 -263
  45. package/src/views/Users.tsx +1187 -271
  46. package/src/views/users/UserDetailDrawer.tsx +924 -0
@@ -1,322 +1,1238 @@
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 [inviteRole, setInviteRole] = useState('editor')
35
- const [searchQuery, setSearchQuery] = useState('')
36
- const [filterRole, setFilterRole] = useState('all')
37
- const [sortConfig, setSortConfig] = useState<SortConfig<UserSortKey> | null>(null)
38
-
39
- const users = data ?? []
40
-
41
- const filteredAndSorted = useMemo(() => {
42
- let results = users.filter((user: any) => {
43
- const matchesSearch =
44
- (user.name ?? '').toLowerCase().includes(searchQuery.toLowerCase()) ||
45
- (user.email ?? '').toLowerCase().includes(searchQuery.toLowerCase())
46
- const matchesRole =
47
- filterRole === 'all' || (user.role ?? '').toLowerCase() === filterRole.toLowerCase()
48
- return matchesSearch && matchesRole
49
- })
50
-
51
- if (searchQuery.trim()) {
52
- results = sortByRelevance(results, searchQuery, (u: any) => [u.name, u.email, u.role])
53
- } else if (sortConfig) {
54
- results = [...results].sort((a: any, b: any) => {
55
- const aVal = a[sortConfig.key] ?? ''
56
- const bVal = b[sortConfig.key] ?? ''
57
- const cmp = String(aVal).localeCompare(String(bVal))
58
- return sortConfig.direction === 'asc' ? cmp : -cmp
59
- })
60
- }
61
- return results
62
- }, [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
+ }
63
65
 
64
- const handleInvite = (e: FormEvent) => {
65
- e.preventDefault()
66
- toast.success(`Invitation sent to ${inviteEmail}`)
67
- setShowInviteDialog(false)
68
- setInviteEmail('')
69
- setInviteRole('editor')
70
- }
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
+ }
71
75
 
72
- const handleDelete = async (userId: number) => {
73
- const res = await cmsApi(`/users/${userId}`, { method: 'DELETE' })
74
- if (res.error) {
75
- toast.error(res.error)
76
- } else {
77
- toast.success('User removed')
78
- refetch()
79
- }
80
- }
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
+ }
81
86
 
82
- function SortHeader({ label, sortKey }: { label: string; sortKey: UserSortKey }) {
83
- const active = sortConfig?.key === sortKey
84
- return (
85
- <button
86
- type="button"
87
- onClick={() => setSortConfig(toggleSort(sortConfig, sortKey))}
88
- className="flex items-center gap-1 text-xs font-medium text-gray-700 transition-colors hover:text-gray-900"
89
- >
90
- {label}
91
- {active ? (
92
- sortConfig!.direction === 'asc' ? (
93
- <ArrowUp className="h-3 w-3" />
94
- ) : (
95
- <ArrowDown className="h-3 w-3" />
96
- )
97
- ) : (
98
- <ArrowUpDown className="h-3 w-3 text-gray-400" />
99
- )}
100
- </button>
101
- )
102
- }
87
+ type RowStatus = ApiMember['status'] | 'invited' | 'expired'
103
88
 
104
- const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8'
105
- const Heading = embedded ? 'h2' : 'h1'
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
+ }
106
211
 
107
- if (loading) {
212
+ function MetricCards({ stats, loading }: { stats: ApiStats | null; loading: boolean }) {
213
+ if (loading || !stats) {
108
214
  return (
109
- <div className={`flex h-64 items-center justify-center ${rootPadding}`}>
110
- <Loader2 className="h-6 w-6 animate-spin text-blue-600" />
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
+ ))}
111
219
  </div>
112
220
  )
113
221
  }
114
-
222
+ const seatsValue =
223
+ stats.seatLimit == null ? 'Unlimited' : `${stats.seatsUsed} / ${stats.seatLimit}`
115
224
  return (
116
- <div className={rootPadding}>
117
- {error && (
118
- <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
119
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
120
- <span className="flex-1 text-sm text-red-800">{error}</span>
121
- <button
122
- onClick={refetch}
123
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
124
- >
125
- Retry
126
- </button>
127
- </div>
128
- )}
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
+ }
129
250
 
130
- <div className="mb-4 flex items-center justify-between">
131
- <div>
132
- <Heading className="mb-1 text-2xl font-semibold text-gray-900">Users</Heading>
133
- <p className="text-sm text-gray-600">{filteredAndSorted.length} total users</p>
251
+ // ---------------------------------------------------------------------------
252
+ // Access review banner
253
+ // ---------------------------------------------------------------------------
254
+
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>
134
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>
135
299
  <button
136
300
  type="button"
137
- onClick={() => setShowInviteDialog(true)}
138
- 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"
301
+ onClick={() => onDismiss(top)}
302
+ className="text-muted-foreground hover:bg-accent rounded-md p-1.5 transition-colors"
303
+ aria-label="Dismiss recommendation"
139
304
  >
140
- <UserPlus className="h-4 w-4" />
141
- Invite User
305
+ <X size={16} aria-hidden />
142
306
  </button>
143
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
+ }
144
393
 
145
- <div className="mb-4 rounded-lg border border-gray-200 bg-white">
146
- <div className="flex flex-col gap-3 p-3 sm:flex-row">
147
- <div className="relative flex-1">
148
- <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
149
- <input
150
- type="text"
151
- placeholder="Search users by name or email..."
152
- value={searchQuery}
153
- onChange={(e) => setSearchQuery(e.target.value)}
154
- 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"
155
- />
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) => {
433
+ e.preventDefault()
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)
441
+ try {
442
+ const res = await cmsApi<{ emailed?: boolean; inviteUrl?: string }>('/users/invite', {
443
+ method: 'POST',
444
+ body: JSON.stringify({
445
+ email: trimmed,
446
+ role,
447
+ message: message.trim() || undefined,
448
+ expiresInDays,
449
+ }),
450
+ })
451
+ if (res.error) {
452
+ toast.error(res.error)
453
+ return
454
+ }
455
+ if (res.data?.emailed) {
456
+ toast.success(`Invitation sent to ${trimmed}`)
457
+ } else if (res.data?.inviteUrl) {
458
+ await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => {})
459
+ toast.success(
460
+ 'Invite created. Link copied — email isn’t configured, so share it manually.',
461
+ {
462
+ duration: 8000,
463
+ },
464
+ )
465
+ } else {
466
+ toast.success('Invitation created.')
467
+ }
468
+ onOpenChange(false)
469
+ reset()
470
+ onInvited()
471
+ } catch {
472
+ toast.error('Could not create the invitation. Please try again.')
473
+ } finally {
474
+ setSending(false)
475
+ }
476
+ }
477
+
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>
156
502
  </div>
157
- <select
158
- value={filterRole}
159
- onChange={(e) => setFilterRole(e.target.value)}
160
- className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
161
- >
162
- <option value="all">All Roles</option>
163
- <option value="admin">Admin</option>
164
- <option value="editor">Editor</option>
165
- <option value="author">Author</option>
166
- <option value="client">Client</option>
167
- </select>
168
- </div>
169
- </div>
170
503
 
171
- {filteredAndSorted.length === 0 && !loading ? (
172
- <div className="flex flex-col items-center justify-center py-16 text-center">
173
- <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
174
- <UserPlus className="h-6 w-6 text-gray-400" />
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
+ )
760
+ }
761
+ return r
762
+ }, [rows, roleFilter, search])
763
+
764
+ // -------------------------------------------------------------------------
765
+ // Actions
766
+ // -------------------------------------------------------------------------
767
+
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
+ )
782
+
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
909
+
910
+ return (
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
+ />
920
+ )}
921
+
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>
175
957
  </div>
176
- <h3 className="mb-1 text-sm font-medium text-gray-900">No users yet</h3>
177
- <p className="text-sm text-gray-500">Invite your first user to get started.</p>
178
958
  </div>
179
- ) : (
180
- <div className="rounded-lg border border-gray-200 bg-white">
959
+
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
+ })}
984
+ </div>
985
+
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>
996
+ </div>
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
+ ) : (
181
1017
  <div className="overflow-x-auto">
182
- <table className="w-full">
183
- <thead className="border-b border-gray-200 bg-gray-50">
184
- <tr>
185
- <th className="px-4 py-2 text-left">
186
- <SortHeader label="User" sortKey="name" />
187
- </th>
188
- <th className="px-4 py-2 text-left">
189
- <SortHeader label="Role" sortKey="role" />
190
- </th>
191
- <th className="px-4 py-2 text-left">
192
- <SortHeader label="Status" sortKey="status" />
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>
193
1027
  </th>
194
- <th className="px-4 py-2 text-left">
195
- <SortHeader label="Last Active" sortKey="lastLogin" />
196
- </th>
197
- <th className="px-4 py-2 text-left text-xs font-medium text-gray-700">Actions</th>
198
1028
  </tr>
199
1029
  </thead>
200
- <tbody className="divide-y divide-gray-200">
201
- {filteredAndSorted.map((user: any) => (
202
- <tr key={user.id} className="transition-colors hover:bg-gray-50">
203
- <td className="px-4 py-3">
204
- <div className="flex items-center gap-3">
205
- <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">
206
- {user.name.charAt(0)}
207
- </div>
208
- <div>
209
- <div className="text-sm font-medium text-gray-900">{user.name}</div>
210
- <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>
211
1083
  </div>
212
- </div>
213
- </td>
214
- <td className="px-4 py-3">
215
- <span
216
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
217
- user.role === 'Admin'
218
- ? 'bg-purple-100 text-purple-800'
219
- : user.role === 'Editor'
220
- ? 'bg-blue-100 text-blue-800'
221
- : 'bg-gray-100 text-gray-800'
222
- }`}
1084
+ </td>
1085
+ <td
1086
+ className="px-4 py-3"
1087
+ onClick={(e) => e.stopPropagation()}
1088
+ role="presentation"
223
1089
  >
224
- {user.role}
225
- </span>
226
- </td>
227
- <td className="px-4 py-3">
228
- <span
229
- className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
230
- user.status === 'Active'
231
- ? 'bg-green-100 text-green-800'
232
- : 'bg-gray-100 text-gray-800'
233
- }`}
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"
234
1109
  >
235
- {user.status}
236
- </span>
237
- </td>
238
- <td className="px-4 py-3 text-sm text-gray-600">{user.lastLogin}</td>
239
- <td className="px-4 py-3">
240
- <div className="flex items-center gap-2">
241
- <button
242
- type="button"
243
- onClick={() => onNavigate?.(`/users/${user.id}`)}
244
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
245
- aria-label={`Edit ${user.name}`}
246
- >
247
- <Pencil className="h-4 w-4 text-gray-600" />
248
- </button>
249
- <button
250
- type="button"
251
- onClick={() => handleDelete(user.id)}
252
- className="rounded p-1.5 transition-colors hover:bg-gray-100"
253
- aria-label={`Remove ${user.name}`}
254
- >
255
- <Trash2 className="h-4 w-4 text-red-600" />
256
- </button>
257
- </div>
258
- </td>
259
- </tr>
260
- ))}
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
+ })}
261
1123
  </tbody>
262
1124
  </table>
263
1125
  </div>
264
- </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
+ />
265
1148
  )}
266
1149
 
267
- <Dialog.Root open={showInviteDialog} onOpenChange={setShowInviteDialog}>
268
- <Dialog.Portal>
269
- <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
270
- <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">
271
- <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">
272
- Invite User
273
- </Dialog.Title>
274
- <form onSubmit={handleInvite} className="space-y-4">
275
- <div>
276
- <label className="mb-1 block text-sm font-medium text-gray-700">
277
- Email Address
278
- </label>
279
- <input
280
- type="email"
281
- value={inviteEmail}
282
- onChange={(e) => setInviteEmail(e.target.value)}
283
- placeholder="user@example.com"
284
- 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"
285
- required
286
- />
287
- </div>
288
- <div>
289
- <label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
290
- <select
291
- value={inviteRole}
292
- onChange={(e) => setInviteRole(e.target.value)}
293
- 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"
294
- >
295
- <option value="viewer">Viewer</option>
296
- <option value="editor">Editor</option>
297
- <option value="admin">Admin</option>
298
- </select>
299
- </div>
300
- <div className="flex items-center justify-end gap-3 pt-4">
301
- <Dialog.Close asChild>
302
- <button
303
- type="button"
304
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
305
- >
306
- Cancel
307
- </button>
308
- </Dialog.Close>
309
- <button
310
- type="submit"
311
- className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
312
- >
313
- Send Invite
314
- </button>
315
- </div>
316
- </form>
317
- </Dialog.Content>
318
- </Dialog.Portal>
319
- </Dialog.Root>
1150
+ <UserDetailDrawer
1151
+ userId={detailUserId}
1152
+ open={detailUserId !== null}
1153
+ onOpenChange={(v) => {
1154
+ if (!v) setDetailUserId(null)
1155
+ }}
1156
+ onChanged={refetchAll}
1157
+ />
320
1158
  </div>
321
1159
  )
322
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
+ }