@actuate-media/cms-admin 0.30.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js +144 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
- package/dist/__tests__/views/users.render.test.d.ts +2 -0
- package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users.render.test.js +146 -0
- package/dist/__tests__/views/users.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/views/Users.d.ts +1 -3
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +394 -73
- package/dist/views/Users.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts +9 -0
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
- package/dist/views/users/UserDetailDrawer.js +276 -0
- package/dist/views/users/UserDetailDrawer.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +162 -0
- package/src/__tests__/views/users.render.test.tsx +155 -0
- package/src/views/Users.tsx +1165 -331
- package/src/views/users/UserDetailDrawer.tsx +1069 -0
package/src/views/Users.tsx
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
Users as UsersIcon,
|
|
7
|
+
Mail,
|
|
8
|
+
ShieldCheck,
|
|
9
|
+
Ticket,
|
|
8
10
|
Search,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types (mirror the cms-core serialized shapes)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
22
37
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 (
|
|
75
|
-
|
|
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:
|
|
86
|
-
|
|
87
|
-
|
|
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 ${
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
474
|
+
setSending(false)
|
|
119
475
|
}
|
|
120
476
|
}
|
|
121
477
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
{
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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="
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<
|
|
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
|
-
|
|
196
|
-
<div
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
<
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
234
|
-
<tr>
|
|
235
|
-
<th className="px-4 py-2
|
|
236
|
-
|
|
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-
|
|
251
|
-
{
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
</
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
}
|