@actuate-media/cms-admin 0.30.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/__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/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 +236 -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 +119 -0
- package/src/__tests__/views/users.render.test.tsx +155 -0
- package/src/views/Users.tsx +1165 -331
- package/src/views/users/UserDetailDrawer.tsx +924 -0
package/dist/views/Users.js
CHANGED
|
@@ -1,60 +1,166 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog';
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
|
5
|
+
import { Users as UsersIcon, Mail, ShieldCheck, Ticket, Search, Plus, X, Sparkles, ShieldAlert, MoreHorizontal, ChevronDown, Send, RefreshCw, Ban, AlertTriangle, Check, SlidersHorizontal, } from 'lucide-react';
|
|
6
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
6
7
|
import { toast } from 'sonner';
|
|
7
|
-
import { sortByRelevance, toggleSort } from '../lib/search.js';
|
|
8
8
|
import { useApiData } from '../lib/useApiData.js';
|
|
9
9
|
import { cmsApi } from '../lib/api.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
10
|
+
import { Button } from '../components/ui/Button.js';
|
|
11
|
+
import { EmptyState } from '../components/ui/EmptyState.js';
|
|
12
|
+
import { Skeleton } from '../components/ui/Skeleton.js';
|
|
13
|
+
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js';
|
|
14
|
+
import { UserDetailDrawer } from './users/UserDetailDrawer.js';
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Presentation constants (token-driven; brand = violet, matches screenshots)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
const ROLE_PILL = {
|
|
19
|
+
Owner: 'bg-brand/10 text-brand',
|
|
20
|
+
Admin: 'bg-info/10 text-info',
|
|
21
|
+
Editor: 'bg-accent text-accent-foreground',
|
|
22
|
+
Viewer: 'bg-muted text-muted-foreground',
|
|
23
|
+
};
|
|
24
|
+
const STATUS_META = {
|
|
25
|
+
active: { dot: 'bg-success', text: 'text-success', label: 'Active' },
|
|
26
|
+
invited: { dot: 'bg-warning', text: 'text-warning', label: 'Invited' },
|
|
27
|
+
expired: { dot: 'bg-destructive', text: 'text-destructive', label: 'Expired invite' },
|
|
28
|
+
suspended: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', label: 'Suspended' },
|
|
29
|
+
locked: { dot: 'bg-destructive', text: 'text-destructive', label: 'Locked' },
|
|
30
|
+
deactivated: { dot: 'bg-muted-foreground', text: 'text-muted-foreground', label: 'Deactivated' },
|
|
31
|
+
};
|
|
32
|
+
const SEVERITY_DOT = {
|
|
33
|
+
critical: 'bg-destructive',
|
|
34
|
+
high: 'bg-destructive',
|
|
35
|
+
medium: 'bg-warning',
|
|
36
|
+
low: 'bg-info',
|
|
37
|
+
};
|
|
38
|
+
const AVATAR_TINTS = ['bg-chart-1', 'bg-chart-2', 'bg-chart-3', 'bg-chart-4', 'bg-chart-5'];
|
|
39
|
+
const BASE_ROLE_OPTIONS = [
|
|
40
|
+
{
|
|
41
|
+
value: 'ADMIN',
|
|
42
|
+
label: 'Admin',
|
|
43
|
+
desc: 'Full access except billing and deleting the workspace.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
value: 'EDITOR',
|
|
47
|
+
label: 'Editor',
|
|
48
|
+
desc: 'Create, edit and publish content. No settings access.',
|
|
49
|
+
},
|
|
50
|
+
{ value: 'VIEWER', label: 'Viewer', desc: 'Read-only access to content and analytics.' },
|
|
51
|
+
];
|
|
52
|
+
const OWNER_ROLE_OPTION = {
|
|
53
|
+
value: 'OWNER',
|
|
54
|
+
label: 'Owner',
|
|
55
|
+
desc: 'Full workspace control, including billing and Owner management.',
|
|
56
|
+
};
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
function avatarTint(id) {
|
|
61
|
+
let h = 0;
|
|
62
|
+
for (let i = 0; i < id.length; i++)
|
|
63
|
+
h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
|
64
|
+
return AVATAR_TINTS[h % AVATAR_TINTS.length];
|
|
65
|
+
}
|
|
66
|
+
function relativeTime(iso) {
|
|
67
|
+
if (!iso)
|
|
68
|
+
return 'Never';
|
|
69
|
+
const then = new Date(iso).getTime();
|
|
70
|
+
if (Number.isNaN(then))
|
|
71
|
+
return '—';
|
|
72
|
+
const diffMs = Date.now() - then;
|
|
73
|
+
const mins = Math.floor(diffMs / 60000);
|
|
74
|
+
if (mins < 5)
|
|
75
|
+
return 'Active now';
|
|
76
|
+
if (mins < 60)
|
|
77
|
+
return `${mins}m ago`;
|
|
78
|
+
const hours = Math.floor(mins / 60);
|
|
79
|
+
if (hours < 24)
|
|
80
|
+
return `${hours}h ago`;
|
|
81
|
+
const days = Math.floor(hours / 24);
|
|
82
|
+
if (days === 1)
|
|
83
|
+
return 'Yesterday';
|
|
84
|
+
return `${days} days ago`;
|
|
85
|
+
}
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Metric cards
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
function MetricCard({ icon, label, value, sub, }) {
|
|
90
|
+
return (_jsxs("div", { className: "border-border bg-card rounded-lg border p-4", children: [_jsxs("div", { className: "text-muted-foreground flex items-center gap-2", children: [icon, _jsx("span", { className: "text-sm", children: label })] }), _jsx("div", { className: "text-card-foreground mt-2 text-2xl font-medium", children: value }), sub && _jsx("div", { className: "text-muted-foreground mt-0.5 text-xs", children: sub })] }));
|
|
91
|
+
}
|
|
92
|
+
function MetricCards({ stats, loading }) {
|
|
93
|
+
if (loading || !stats) {
|
|
94
|
+
return (_jsx("div", { className: "grid grid-cols-2 gap-3 lg:grid-cols-4", children: Array.from({ length: 4 }, (_, i) => (_jsx(Skeleton, { variant: "card" }, i))) }));
|
|
95
|
+
}
|
|
96
|
+
const seatsValue = stats.seatLimit == null ? 'Unlimited' : `${stats.seatsUsed} / ${stats.seatLimit}`;
|
|
97
|
+
return (_jsxs("div", { className: "grid grid-cols-2 gap-3 lg:grid-cols-4", children: [_jsx(MetricCard, { icon: _jsx(UsersIcon, { size: 16, "aria-hidden": true }), label: "Members", value: stats.members }), _jsx(MetricCard, { icon: _jsx(Mail, { size: 16, "aria-hidden": true }), label: "Pending invites", value: stats.pendingInvites }), _jsx(MetricCard, { icon: _jsx(ShieldCheck, { size: 16, "aria-hidden": true }), label: "Admins", value: stats.admins }), _jsx(MetricCard, { icon: _jsx(Ticket, { size: 16, "aria-hidden": true }), label: "Seats used", value: seatsValue, sub: stats.seatLimit == null ? 'No seat limit set' : undefined })] }));
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Access review banner
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
function AccessReviewBanner({ recommendations, onAct, onDismiss, }) {
|
|
103
|
+
if (recommendations.length === 0)
|
|
104
|
+
return null;
|
|
105
|
+
const top = recommendations[0];
|
|
106
|
+
const more = recommendations.length - 1;
|
|
107
|
+
return (_jsxs("div", { className: "border-brand/20 bg-brand/5 flex items-start gap-3 rounded-lg border p-4", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "bg-brand text-brand-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full", children: _jsx(ShieldAlert, { size: 16, "aria-hidden": true }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-card-foreground text-sm font-medium", children: top.title }), _jsxs("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", children: [_jsx(Sparkles, { size: 10, "aria-hidden": true }), "AI"] }), _jsxs("span", { className: "text-muted-foreground inline-flex items-center gap-1 text-[10px]", children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${SEVERITY_DOT[top.severity]}` }), top.severity] })] }), _jsx("p", { className: "text-muted-foreground mt-0.5 text-sm", children: top.description }), more > 0 && (_jsxs("p", { className: "text-muted-foreground mt-1 text-xs", children: ["+", more, " more recommendation", more > 1 ? 's' : ''] }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsx(Button, { size: "sm", variant: "primary", onClick: () => onAct(top), children: top.recommendedAction }), _jsx("button", { type: "button", onClick: () => onDismiss(top), className: "text-muted-foreground hover:bg-accent rounded-md p-1.5 transition-colors", "aria-label": "Dismiss recommendation", children: _jsx(X, { size: 16, "aria-hidden": true }) })] })] }));
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Avatar + row pieces
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
function Avatar({ row }) {
|
|
113
|
+
return (_jsx("span", { className: `flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white ${avatarTint(row.id)}`, "aria-hidden": true, children: row.initials }));
|
|
114
|
+
}
|
|
115
|
+
function RoleDropdown({ row, options, disabled, onChange, }) {
|
|
116
|
+
const pill = ROLE_PILL[row.roleName] ?? ROLE_PILL.Viewer;
|
|
117
|
+
if (disabled) {
|
|
118
|
+
return (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${pill}`, children: row.roleName }));
|
|
119
|
+
}
|
|
120
|
+
return (_jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsxs("button", { type: "button", className: `inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-opacity hover:opacity-80 ${pill}`, "aria-label": `Change role for ${row.name}, current role ${row.roleName}`, children: [row.roleName, _jsx(ChevronDown, { size: 12, "aria-hidden": true })] }) }), _jsx(DropdownMenu.Portal, { children: _jsx(DropdownMenu.Content, { align: "start", sideOffset: 4, className: "border-border bg-card z-50 min-w-44 rounded-lg border p-1 shadow-md", children: options.map((opt) => (_jsxs(DropdownMenu.Item, { onSelect: () => onChange(opt.value), 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", children: [opt.label, row.roleName.toUpperCase() === opt.value && (_jsx(Check, { size: 14, className: "text-brand", "aria-hidden": true }))] }, opt.value))) }) })] }));
|
|
121
|
+
}
|
|
122
|
+
function StatusBadge({ status }) {
|
|
123
|
+
const meta = STATUS_META[status];
|
|
124
|
+
return (_jsxs("span", { className: `inline-flex items-center gap-1.5 text-xs ${meta.text}`, children: [_jsx("span", { className: `h-1.5 w-1.5 rounded-full ${meta.dot}`, "aria-hidden": true }), meta.label] }));
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Invite modal
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
130
|
+
function InviteModal({ open, onOpenChange, roleOptions, emailConfigured, seatsFull, onInvited, }) {
|
|
131
|
+
const [email, setEmail] = useState('');
|
|
132
|
+
const [role, setRole] = useState('EDITOR');
|
|
133
|
+
const [message, setMessage] = useState('');
|
|
134
|
+
const [expiresInDays, setExpiresInDays] = useState(7);
|
|
135
|
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
136
|
+
const [sending, setSending] = useState(false);
|
|
137
|
+
const [emailError, setEmailError] = useState(null);
|
|
138
|
+
const reset = useCallback(() => {
|
|
139
|
+
setEmail('');
|
|
140
|
+
setRole('EDITOR');
|
|
141
|
+
setMessage('');
|
|
142
|
+
setExpiresInDays(7);
|
|
143
|
+
setShowAdvanced(false);
|
|
144
|
+
setEmailError(null);
|
|
145
|
+
}, []);
|
|
146
|
+
const handleSubmit = async (e) => {
|
|
47
147
|
e.preventDefault();
|
|
48
|
-
if (
|
|
148
|
+
if (sending)
|
|
49
149
|
return;
|
|
50
|
-
|
|
150
|
+
const trimmed = email.trim();
|
|
151
|
+
if (!EMAIL_RE.test(trimmed)) {
|
|
152
|
+
setEmailError('Enter a valid email address.');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setSending(true);
|
|
51
156
|
try {
|
|
52
157
|
const res = await cmsApi('/users/invite', {
|
|
53
158
|
method: 'POST',
|
|
54
159
|
body: JSON.stringify({
|
|
55
|
-
email:
|
|
56
|
-
|
|
57
|
-
|
|
160
|
+
email: trimmed,
|
|
161
|
+
role,
|
|
162
|
+
message: message.trim() || undefined,
|
|
163
|
+
expiresInDays,
|
|
58
164
|
}),
|
|
59
165
|
});
|
|
60
166
|
if (res.error) {
|
|
@@ -62,11 +168,9 @@ export function Users({ onNavigate, embedded = false } = {}) {
|
|
|
62
168
|
return;
|
|
63
169
|
}
|
|
64
170
|
if (res.data?.emailed) {
|
|
65
|
-
toast.success(`Invitation sent to ${
|
|
171
|
+
toast.success(`Invitation sent to ${trimmed}`);
|
|
66
172
|
}
|
|
67
173
|
else if (res.data?.inviteUrl) {
|
|
68
|
-
// No email provider configured (or the send failed): surface the link
|
|
69
|
-
// so the admin can share it manually rather than silently succeeding.
|
|
70
174
|
await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => { });
|
|
71
175
|
toast.success('Invite created. Link copied — email isn’t configured, so share it manually.', {
|
|
72
176
|
duration: 8000,
|
|
@@ -75,42 +179,259 @@ export function Users({ onNavigate, embedded = false } = {}) {
|
|
|
75
179
|
else {
|
|
76
180
|
toast.success('Invitation created.');
|
|
77
181
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
182
|
+
onOpenChange(false);
|
|
183
|
+
reset();
|
|
184
|
+
onInvited();
|
|
81
185
|
}
|
|
82
186
|
catch {
|
|
83
187
|
toast.error('Could not create the invitation. Please try again.');
|
|
84
188
|
}
|
|
85
189
|
finally {
|
|
86
|
-
|
|
190
|
+
setSending(false);
|
|
87
191
|
}
|
|
88
192
|
};
|
|
89
|
-
|
|
90
|
-
|
|
193
|
+
return (_jsx(Dialog.Root, { open: open, onOpenChange: (v) => {
|
|
194
|
+
onOpenChange(v);
|
|
195
|
+
if (!v)
|
|
196
|
+
reset();
|
|
197
|
+
}, children: _jsxs(Dialog.Portal, { children: [_jsx(Dialog.Overlay, { className: "fixed inset-0 z-50 bg-black/40 backdrop-blur-sm" }), _jsxs(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", children: [_jsxs("div", { className: "mb-4 flex items-center justify-between", children: [_jsx(Dialog.Title, { className: "text-card-foreground text-lg font-medium", children: "Invite a user" }), _jsx(Dialog.Close, { asChild: true, children: _jsx("button", { type: "button", className: "text-muted-foreground hover:bg-accent rounded-md p-1 transition-colors", "aria-label": "Close", children: _jsx(X, { size: 18, "aria-hidden": true }) }) })] }), seatsFull && (_jsxs("div", { className: "border-warning/30 bg-warning/10 text-warning mb-4 flex items-start gap-2 rounded-md border p-3 text-sm", children: [_jsx(AlertTriangle, { size: 16, className: "mt-0.5 shrink-0", "aria-hidden": true }), _jsx("span", { children: "All seats are in use. Remove a member or raise the seat limit before inviting." })] })), !emailConfigured && !seatsFull && (_jsxs("div", { className: "border-border bg-muted/50 text-muted-foreground mb-4 flex items-start gap-2 rounded-md border p-3 text-sm", children: [_jsx(Mail, { size: 16, className: "mt-0.5 shrink-0", "aria-hidden": true }), _jsx("span", { children: "Email isn\u2019t configured \u2014 you\u2019ll get a shareable invite link to send manually." })] })), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { htmlFor: "invite-email", className: "text-card-foreground mb-1 block text-sm font-medium", children: "Email address" }), _jsx("input", { id: "invite-email", type: "email", value: email, onChange: (e) => {
|
|
198
|
+
setEmail(e.target.value);
|
|
199
|
+
if (emailError)
|
|
200
|
+
setEmailError(null);
|
|
201
|
+
}, placeholder: "name@company.com", autoComplete: "off", "aria-invalid": !!emailError, "aria-describedby": emailError ? 'invite-email-error' : undefined, 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", required: true }), emailError && (_jsx("p", { id: "invite-email-error", className: "text-destructive mt-1 text-xs", role: "alert", children: emailError }))] }), _jsxs("fieldset", { children: [_jsx("legend", { className: "text-card-foreground mb-2 block text-sm font-medium", children: "Role" }), _jsx("div", { className: "space-y-2", children: roleOptions.map((opt) => {
|
|
202
|
+
const active = role === opt.value;
|
|
203
|
+
return (_jsxs("label", { className: `flex cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors ${active ? 'border-brand bg-brand/5' : 'border-border hover:bg-accent/50'}`, children: [_jsx("input", { type: "radio", name: "invite-role", value: opt.value, checked: active, onChange: () => setRole(opt.value), className: "accent-brand mt-0.5" }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "text-card-foreground block text-sm font-medium", children: opt.label }), _jsx("span", { className: "text-muted-foreground block text-xs", children: opt.desc })] })] }, opt.value));
|
|
204
|
+
}) })] }), _jsxs("div", { children: [_jsxs("button", { type: "button", onClick: () => setShowAdvanced((v) => !v), className: "text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs font-medium transition-colors", "aria-expanded": showAdvanced, children: [_jsx(ChevronDown, { size: 14, className: `transition-transform ${showAdvanced ? 'rotate-180' : ''}`, "aria-hidden": true }), "Advanced options"] }), showAdvanced && (_jsxs("div", { className: "mt-3 space-y-3", children: [_jsxs("div", { children: [_jsxs("label", { htmlFor: "invite-message", className: "text-card-foreground mb-1 block text-sm font-medium", children: ["Custom message", ' ', _jsx("span", { className: "text-muted-foreground font-normal", children: "(optional)" })] }), _jsx("textarea", { id: "invite-message", value: message, onChange: (e) => setMessage(e.target.value), rows: 2, placeholder: "Add a personal note to the invitation email.", 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" })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: "invite-expiry", className: "text-card-foreground mb-1 block text-sm font-medium", children: "Invite expires in" }), _jsxs("select", { id: "invite-expiry", value: expiresInDays, onChange: (e) => setExpiresInDays(Number(e.target.value)), 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", children: [_jsx("option", { value: 7, children: "7 days" }), _jsx("option", { value: 14, children: "14 days" }), _jsx("option", { value: 30, children: "30 days" })] })] })] }))] }), _jsxs("div", { className: "flex items-center justify-end gap-3 pt-2", children: [_jsx(Dialog.Close, { asChild: true, children: _jsx(Button, { type: "button", variant: "outline", children: "Cancel" }) }), _jsx(Button, { type: "submit", variant: "primary", loading: sending, disabled: sending || seatsFull || !email.trim(), leftIcon: !sending ? _jsx(Send, { size: 16, "aria-hidden": true }) : undefined, children: sending ? 'Sending…' : 'Send invite' })] })] })] })] }) }));
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Main view
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
const ROLE_PILLS = [
|
|
210
|
+
'All',
|
|
211
|
+
'Owner',
|
|
212
|
+
'Admin',
|
|
213
|
+
'Editor',
|
|
214
|
+
'Viewer',
|
|
215
|
+
];
|
|
216
|
+
export function Users({ embedded = false } = {}) {
|
|
217
|
+
const members = useApiData('/users');
|
|
218
|
+
const invites = useApiData('/invites');
|
|
219
|
+
const stats = useApiData('/users/stats');
|
|
220
|
+
const review = useApiData('/access-review');
|
|
221
|
+
const [search, setSearch] = useState('');
|
|
222
|
+
const [roleFilter, setRoleFilter] = useState('All');
|
|
223
|
+
const [showInvite, setShowInvite] = useState(false);
|
|
224
|
+
const [detailUserId, setDetailUserId] = useState(null);
|
|
225
|
+
const [confirm, setConfirm] = useState(null);
|
|
226
|
+
const refetchAll = useCallback(() => {
|
|
227
|
+
members.refetch();
|
|
228
|
+
invites.refetch();
|
|
229
|
+
stats.refetch();
|
|
230
|
+
review.refetch();
|
|
231
|
+
}, [members, invites, stats, review]);
|
|
232
|
+
const currentUserRole = useMemo(() => (members.data ?? []).find((m) => m.isCurrentUser)?.roleName ?? null, [members.data]);
|
|
233
|
+
const isOwner = currentUserRole === 'Owner';
|
|
234
|
+
const roleOptions = useMemo(() => (isOwner ? [OWNER_ROLE_OPTION, ...BASE_ROLE_OPTIONS] : BASE_ROLE_OPTIONS), [isOwner]);
|
|
235
|
+
const rows = useMemo(() => {
|
|
236
|
+
const memberRows = (members.data ?? []).map((m) => ({
|
|
237
|
+
kind: 'member',
|
|
238
|
+
id: m.id,
|
|
239
|
+
name: m.name,
|
|
240
|
+
email: m.email,
|
|
241
|
+
initials: m.initials,
|
|
242
|
+
role: m.role,
|
|
243
|
+
roleName: m.roleName,
|
|
244
|
+
status: m.status,
|
|
245
|
+
mfaEnabled: m.mfaEnabled,
|
|
246
|
+
externalDomain: m.externalDomain,
|
|
247
|
+
activityAt: m.lastActiveAt ?? m.lastLoginAt,
|
|
248
|
+
isCurrentUser: m.isCurrentUser,
|
|
249
|
+
}));
|
|
250
|
+
const inviteRows = (invites.data ?? [])
|
|
251
|
+
.filter((i) => i.status === 'PENDING' || i.status === 'EXPIRED')
|
|
252
|
+
.map((i) => ({
|
|
253
|
+
kind: 'invite',
|
|
254
|
+
id: i.id,
|
|
255
|
+
name: i.email.split('@')[0] ?? i.email,
|
|
256
|
+
email: i.email,
|
|
257
|
+
initials: (i.email.slice(0, 2) || '?').toUpperCase(),
|
|
258
|
+
role: i.role,
|
|
259
|
+
roleName: i.roleName,
|
|
260
|
+
status: i.expired || i.status === 'EXPIRED' ? 'expired' : 'invited',
|
|
261
|
+
mfaEnabled: false,
|
|
262
|
+
externalDomain: false,
|
|
263
|
+
activityAt: i.createdAt,
|
|
264
|
+
isCurrentUser: false,
|
|
265
|
+
}));
|
|
266
|
+
return [...memberRows, ...inviteRows];
|
|
267
|
+
}, [members.data, invites.data]);
|
|
268
|
+
const filteredRows = useMemo(() => {
|
|
269
|
+
let r = rows;
|
|
270
|
+
if (roleFilter !== 'All')
|
|
271
|
+
r = r.filter((row) => row.roleName === roleFilter);
|
|
272
|
+
if (search.trim()) {
|
|
273
|
+
const q = search.trim().toLowerCase();
|
|
274
|
+
r = r.filter((row) => row.name.toLowerCase().includes(q) ||
|
|
275
|
+
row.email.toLowerCase().includes(q) ||
|
|
276
|
+
row.roleName.toLowerCase().includes(q) ||
|
|
277
|
+
row.status.includes(q));
|
|
278
|
+
}
|
|
279
|
+
return r;
|
|
280
|
+
}, [rows, roleFilter, search]);
|
|
281
|
+
// -------------------------------------------------------------------------
|
|
282
|
+
// Actions
|
|
283
|
+
// -------------------------------------------------------------------------
|
|
284
|
+
const changeMemberRole = useCallback(async (row, role) => {
|
|
285
|
+
if (role.toUpperCase() === row.role.toUpperCase())
|
|
286
|
+
return;
|
|
287
|
+
const endpoint = row.kind === 'invite' ? `/invites/${row.id}` : `/users/${row.id}/role`;
|
|
288
|
+
const res = await cmsApi(endpoint, { method: 'PATCH', body: JSON.stringify({ role }) });
|
|
289
|
+
if (res.error) {
|
|
290
|
+
toast.error(res.error);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
toast.success(`Role updated to ${role.charAt(0) + role.slice(1).toLowerCase()}`);
|
|
294
|
+
refetchAll();
|
|
295
|
+
}, [refetchAll]);
|
|
296
|
+
const requestRoleChange = useCallback((row, role) => {
|
|
297
|
+
if (role === 'OWNER' || row.roleName === 'Owner') {
|
|
298
|
+
setConfirm({
|
|
299
|
+
title: role === 'OWNER' ? 'Assign Owner role' : 'Change Owner’s role',
|
|
300
|
+
description: role === 'OWNER'
|
|
301
|
+
? `Owners have full control of the workspace, including billing and managing other Owners. Make ${row.name} an Owner?`
|
|
302
|
+
: `This will change ${row.name}'s role away from Owner. Continue?`,
|
|
303
|
+
confirmLabel: 'Change role',
|
|
304
|
+
onConfirm: () => changeMemberRole(row, role),
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
changeMemberRole(row, role);
|
|
309
|
+
}, [changeMemberRole]);
|
|
310
|
+
const revokeMember = useCallback((row) => {
|
|
311
|
+
setConfirm({
|
|
312
|
+
title: 'Revoke access',
|
|
313
|
+
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.`,
|
|
314
|
+
confirmLabel: 'Revoke access',
|
|
315
|
+
destructive: true,
|
|
316
|
+
onConfirm: async () => {
|
|
317
|
+
const res = await cmsApi(`/users/${row.id}`, { method: 'DELETE' });
|
|
318
|
+
if (res.error) {
|
|
319
|
+
toast.error(res.error);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
toast.success(`Access revoked for ${row.name}`);
|
|
323
|
+
refetchAll();
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
}, [refetchAll]);
|
|
327
|
+
const cancelInvite = useCallback((row) => {
|
|
328
|
+
setConfirm({
|
|
329
|
+
title: 'Cancel invitation',
|
|
330
|
+
description: `The invitation to ${row.email} will be cancelled and the link will stop working.`,
|
|
331
|
+
confirmLabel: 'Cancel invite',
|
|
332
|
+
destructive: true,
|
|
333
|
+
onConfirm: async () => {
|
|
334
|
+
const res = await cmsApi(`/invites/${row.id}/cancel`, { method: 'POST' });
|
|
335
|
+
if (res.error) {
|
|
336
|
+
toast.error(res.error);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
toast.success('Invitation cancelled');
|
|
340
|
+
refetchAll();
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}, [refetchAll]);
|
|
344
|
+
const resendInvite = useCallback(async (row) => {
|
|
345
|
+
const res = await cmsApi(`/invites/${row.id}/resend`, { method: 'POST' });
|
|
91
346
|
if (res.error) {
|
|
92
347
|
toast.error(res.error);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (res.data?.emailed) {
|
|
351
|
+
toast.success(`Invitation re-sent to ${row.email}`);
|
|
352
|
+
}
|
|
353
|
+
else if (res.data?.inviteUrl) {
|
|
354
|
+
await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => { });
|
|
355
|
+
toast.success('New invite link copied — email isn’t configured, so share it manually.', {
|
|
356
|
+
duration: 8000,
|
|
357
|
+
});
|
|
93
358
|
}
|
|
94
359
|
else {
|
|
95
|
-
toast.success('
|
|
96
|
-
refetch();
|
|
360
|
+
toast.success('Invitation re-sent');
|
|
97
361
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
362
|
+
refetchAll();
|
|
363
|
+
}, [refetchAll]);
|
|
364
|
+
const actOnRecommendation = useCallback((rec) => {
|
|
365
|
+
const target = rows.find((r) => r.id === rec.userId || r.id === rec.inviteId);
|
|
366
|
+
if (rec.type === 'stale_invite' && target) {
|
|
367
|
+
cancelInvite(target);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (target) {
|
|
371
|
+
revokeMember(target);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
toast.info('Open the member to action this recommendation.');
|
|
375
|
+
}, [rows, cancelInvite, revokeMember]);
|
|
376
|
+
const dismissRecommendation = useCallback(async (rec) => {
|
|
377
|
+
const res = await cmsApi(`/access-review/${encodeURIComponent(rec.id)}/dismiss`, {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
});
|
|
380
|
+
if (res.error) {
|
|
381
|
+
toast.error(res.error);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
toast.success('Recommendation dismissed');
|
|
385
|
+
review.refetch();
|
|
386
|
+
}, [review]);
|
|
387
|
+
// -------------------------------------------------------------------------
|
|
388
|
+
// Render
|
|
389
|
+
// -------------------------------------------------------------------------
|
|
103
390
|
const rootPadding = embedded ? '' : 'p-3 pr-6 sm:p-4 sm:pr-8';
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return (_jsxs("div", { className: rootPadding
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
391
|
+
const loading = members.loading || invites.loading;
|
|
392
|
+
const loadError = members.error || invites.error;
|
|
393
|
+
const seatsFull = stats.data?.seatsAvailable != null && stats.data.seatsAvailable <= 0;
|
|
394
|
+
const peopleCount = (members.data?.length ?? 0) + rows.filter((r) => r.kind === 'invite').length;
|
|
395
|
+
return (_jsxs("div", { className: `space-y-4 ${rootPadding}`, children: [_jsx(MetricCards, { stats: stats.data, loading: stats.loading }), !review.loading && review.data && review.data.length > 0 && (_jsx(AccessReviewBanner, { recommendations: review.data, onAct: actOnRecommendation, onDismiss: dismissRecommendation })), _jsxs("div", { className: "border-border bg-card rounded-lg border", children: [_jsxs("div", { className: "border-border flex flex-col gap-3 border-b p-4 lg:flex-row lg:items-center lg:justify-between", children: [_jsxs("div", { children: [_jsx("h2", { className: "text-card-foreground text-base font-medium", children: "Team members" }), _jsxs("p", { className: "text-muted-foreground text-sm", children: [peopleCount, " ", peopleCount === 1 ? 'person has' : 'people have', " access to this workspace"] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "relative", children: [_jsx(Search, { size: 16, className: "text-muted-foreground absolute top-1/2 left-3 -translate-y-1/2", "aria-hidden": true }), _jsx("input", { type: "text", value: search, onChange: (e) => setSearch(e.target.value), placeholder: "Search by name or email\u2026", "aria-label": "Search team members", 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" })] }), _jsx(Button, { variant: "primary", onClick: () => setShowInvite(true), disabled: seatsFull, title: seatsFull ? 'All seats are in use' : undefined, leftIcon: _jsx(Plus, { size: 16, "aria-hidden": true }), children: "Invite user" })] })] }), _jsx("div", { className: "border-border flex flex-wrap gap-2 border-b px-4 py-3", role: "group", "aria-label": "Filter by role", children: ROLE_PILLS.map((pill) => {
|
|
396
|
+
const active = roleFilter === pill;
|
|
397
|
+
return (_jsx("button", { type: "button", onClick: () => setRoleFilter(pill), "aria-pressed": active, className: `rounded-full px-3 py-1 text-xs font-medium transition-colors ${active
|
|
398
|
+
? 'bg-brand text-brand-foreground'
|
|
399
|
+
: 'bg-muted text-muted-foreground hover:bg-accent'}`, children: pill }, pill));
|
|
400
|
+
}) }), loading ? (_jsx(Skeleton, { variant: "table-row", lines: 5, className: "p-2" })) : loadError ? (_jsxs("div", { className: "flex items-center gap-3 p-4", children: [_jsx(AlertTriangle, { size: 18, className: "text-destructive shrink-0", "aria-hidden": true }), _jsx("span", { className: "text-destructive flex-1 text-sm", children: loadError }), _jsx(Button, { variant: "outline", size: "sm", onClick: refetchAll, children: "Retry" })] })) : rows.length === 0 ? (_jsx(EmptyState, { icon: _jsx(UsersIcon, { size: 24, "aria-hidden": true }), title: "No team members yet", description: "Invite your first teammate to collaborate in this workspace.", actionLabel: "Invite user", onAction: () => setShowInvite(true) })) : filteredRows.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Search, { size: 24, "aria-hidden": true }), title: "No users match your filters", description: "Try a different search or role filter.", actionLabel: "Clear filters", onAction: () => {
|
|
401
|
+
setSearch('');
|
|
402
|
+
setRoleFilter('All');
|
|
403
|
+
} })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full min-w-[640px]", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-border text-muted-foreground border-b text-left text-xs", children: [_jsx("th", { className: "px-4 py-2 font-medium", children: "Member" }), _jsx("th", { className: "px-4 py-2 font-medium", children: "Role" }), _jsx("th", { className: "px-4 py-2 font-medium", children: "Status" }), _jsx("th", { className: "px-4 py-2 font-medium", children: "Last active" }), _jsx("th", { className: "px-4 py-2 font-medium", children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsx("tbody", { className: "divide-border divide-y", children: filteredRows.map((row) => {
|
|
404
|
+
const isInvite = row.kind === 'invite';
|
|
405
|
+
// Last Owner / self protections are enforced server-side; here we
|
|
406
|
+
// only disable the obviously-invalid affordances.
|
|
407
|
+
const roleChangeDisabled = (row.roleName === 'Owner' && !isOwner) ||
|
|
408
|
+
(row.roleName === 'Owner' && row.isCurrentUser);
|
|
409
|
+
const openDetail = () => {
|
|
410
|
+
if (!isInvite)
|
|
411
|
+
setDetailUserId(row.id);
|
|
412
|
+
};
|
|
413
|
+
return (_jsxs("tr", { className: `hover:bg-accent/30 transition-colors ${isInvite ? '' : 'cursor-pointer'}`, ...(isInvite
|
|
414
|
+
? {}
|
|
415
|
+
: {
|
|
416
|
+
role: 'button',
|
|
417
|
+
tabIndex: 0,
|
|
418
|
+
'aria-label': `Open details for ${row.name}`,
|
|
419
|
+
onClick: openDetail,
|
|
420
|
+
onKeyDown: (e) => {
|
|
421
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
422
|
+
e.preventDefault();
|
|
423
|
+
openDetail();
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}), children: [_jsx("td", { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Avatar, { row: row }), _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "text-card-foreground flex items-center gap-1.5 text-sm font-medium", children: [_jsx("span", { className: "truncate", children: row.name }), row.isCurrentUser && (_jsx("span", { className: "bg-muted text-muted-foreground rounded px-1 text-[10px]", children: "You" })), row.externalDomain && (_jsx("span", { className: "bg-warning/10 text-warning rounded px-1 text-[10px]", children: "External" }))] }), _jsx("div", { className: "text-muted-foreground truncate text-xs", children: row.email })] })] }) }), _jsx("td", { className: "px-4 py-3", onClick: (e) => e.stopPropagation(), role: "presentation", children: _jsx(RoleDropdown, { row: row, options: roleOptions, disabled: roleChangeDisabled, onChange: (role) => requestRoleChange(row, role) }) }), _jsx("td", { className: "px-4 py-3", children: _jsx(StatusBadge, { status: row.status }) }), _jsx("td", { className: "text-muted-foreground px-4 py-3 text-sm", children: isInvite
|
|
427
|
+
? `Invited ${relativeTime(row.activityAt)}`
|
|
428
|
+
: relativeTime(row.activityAt) }), _jsx("td", { className: "px-4 py-3 text-right", onClick: (e) => e.stopPropagation(), role: "presentation", children: _jsx(RowActions, { row: row, isInvite: isInvite, onResend: () => resendInvite(row), onCancelInvite: () => cancelInvite(row), onRevoke: () => revokeMember(row), onViewDetails: isInvite ? undefined : () => setDetailUserId(row.id), canRevoke: !row.isCurrentUser }) })] }, `${row.kind}-${row.id}`));
|
|
429
|
+
}) })] }) }))] }), _jsx(InviteModal, { open: showInvite, onOpenChange: setShowInvite, roleOptions: roleOptions, emailConfigured: stats.data?.emailConfigured ?? true, seatsFull: !!seatsFull, onInvited: refetchAll }), confirm && (_jsx(ConfirmDialog, { open: !!confirm, onClose: () => setConfirm(null), onConfirm: confirm.onConfirm, title: confirm.title, description: confirm.description, confirmLabel: confirm.confirmLabel, destructive: confirm.destructive })), _jsx(UserDetailDrawer, { userId: detailUserId, open: detailUserId !== null, onOpenChange: (v) => {
|
|
430
|
+
if (!v)
|
|
431
|
+
setDetailUserId(null);
|
|
432
|
+
}, onChanged: refetchAll })] }));
|
|
433
|
+
}
|
|
434
|
+
function RowActions({ row, isInvite, onResend, onCancelInvite, onRevoke, onViewDetails, canRevoke, }) {
|
|
435
|
+
return (_jsxs(DropdownMenu.Root, { children: [_jsx(DropdownMenu.Trigger, { asChild: true, children: _jsx("button", { type: "button", className: "text-muted-foreground hover:bg-accent rounded-md p-1.5 transition-colors", "aria-label": `Actions for ${row.name}`, children: _jsx(MoreHorizontal, { size: 16, "aria-hidden": true }) }) }), _jsx(DropdownMenu.Portal, { children: _jsx(DropdownMenu.Content, { align: "end", sideOffset: 4, className: "border-border bg-card z-50 min-w-44 rounded-lg border p-1 shadow-md", children: isInvite ? (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu.Item, { onSelect: onResend, 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", children: [_jsx(RefreshCw, { size: 14, "aria-hidden": true }), "Resend invite"] }), _jsxs(DropdownMenu.Item, { onSelect: onCancelInvite, 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", children: [_jsx(Ban, { size: 14, "aria-hidden": true }), "Cancel invite"] })] })) : (_jsxs(_Fragment, { children: [onViewDetails && (_jsxs(DropdownMenu.Item, { onSelect: onViewDetails, 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", children: [_jsx(SlidersHorizontal, { size: 14, "aria-hidden": true }), "View details"] })), _jsxs(DropdownMenu.Item, { onSelect: canRevoke ? onRevoke : undefined, disabled: !canRevoke, 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", children: [_jsx(Ban, { size: 14, "aria-hidden": true }), "Revoke access"] })] })) }) })] }));
|
|
115
436
|
}
|
|
116
437
|
//# sourceMappingURL=Users.js.map
|