@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.
- package/dist/AdminRoot.d.ts +6 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -3
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
- package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
- 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 +113 -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/layout/Header.d.ts +3 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +2 -2
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts +2 -1
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +2 -2
- package/dist/layout/Layout.js.map +1 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +54 -51
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +1 -3
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +420 -56
- 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 +236 -0
- package/dist/views/users/UserDetailDrawer.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +28 -2
- package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
- package/src/__tests__/views/users.render.test.tsx +155 -0
- package/src/layout/Header.tsx +10 -2
- package/src/layout/Layout.tsx +10 -1
- package/src/views/Settings.tsx +142 -263
- package/src/views/Users.tsx +1187 -271
- package/src/views/users/UserDetailDrawer.tsx +924 -0
package/src/views/Users.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
212
|
+
function MetricCards({ stats, loading }: { stats: ApiStats | null; loading: boolean }) {
|
|
213
|
+
if (loading || !stats) {
|
|
108
214
|
return (
|
|
109
|
-
<div className=
|
|
110
|
-
|
|
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=
|
|
117
|
-
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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={() =>
|
|
138
|
-
className="
|
|
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
|
-
<
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
184
|
-
<tr>
|
|
185
|
-
<th className="px-4 py-2
|
|
186
|
-
|
|
187
|
-
</th>
|
|
188
|
-
<th className="px-4 py-2
|
|
189
|
-
|
|
190
|
-
|
|
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-
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
</
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
+
}
|