@invect/rbac 0.0.1
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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/backend/index.cjs +1365 -0
- package/dist/backend/index.cjs.map +1 -0
- package/dist/backend/index.d.ts +3 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.mjs +1363 -0
- package/dist/backend/index.mjs.map +1 -0
- package/dist/backend/plugin.d.ts +60 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/frontend/components/AccessControlPage.d.ts +2 -0
- package/dist/frontend/components/AccessControlPage.d.ts.map +1 -0
- package/dist/frontend/components/FlowAccessPanel.d.ts +10 -0
- package/dist/frontend/components/FlowAccessPanel.d.ts.map +1 -0
- package/dist/frontend/components/ShareButton.d.ts +9 -0
- package/dist/frontend/components/ShareButton.d.ts.map +1 -0
- package/dist/frontend/components/ShareFlowModal.d.ts +12 -0
- package/dist/frontend/components/ShareFlowModal.d.ts.map +1 -0
- package/dist/frontend/components/TeamsPage.d.ts +5 -0
- package/dist/frontend/components/TeamsPage.d.ts.map +1 -0
- package/dist/frontend/components/UserMenuSection.d.ts +14 -0
- package/dist/frontend/components/UserMenuSection.d.ts.map +1 -0
- package/dist/frontend/components/access-control/AccessControlPage.d.ts +2 -0
- package/dist/frontend/components/access-control/AccessControlPage.d.ts.map +1 -0
- package/dist/frontend/components/access-control/AccessTable.d.ts +17 -0
- package/dist/frontend/components/access-control/AccessTable.d.ts.map +1 -0
- package/dist/frontend/components/access-control/FlowDetailPanel.d.ts +11 -0
- package/dist/frontend/components/access-control/FlowDetailPanel.d.ts.map +1 -0
- package/dist/frontend/components/access-control/FormDialog.d.ts +7 -0
- package/dist/frontend/components/access-control/FormDialog.d.ts.map +1 -0
- package/dist/frontend/components/access-control/MemberCombobox.d.ts +8 -0
- package/dist/frontend/components/access-control/MemberCombobox.d.ts.map +1 -0
- package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts +9 -0
- package/dist/frontend/components/access-control/MoveConfirmationDialog.d.ts.map +1 -0
- package/dist/frontend/components/access-control/PrincipalCombobox.d.ts +11 -0
- package/dist/frontend/components/access-control/PrincipalCombobox.d.ts.map +1 -0
- package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts +11 -0
- package/dist/frontend/components/access-control/ScopeDetailPanel.d.ts.map +1 -0
- package/dist/frontend/components/access-control/index.d.ts +4 -0
- package/dist/frontend/components/access-control/index.d.ts.map +1 -0
- package/dist/frontend/components/access-control/types.d.ts +36 -0
- package/dist/frontend/components/access-control/types.d.ts.map +1 -0
- package/dist/frontend/components/access-control/useUsers.d.ts +3 -0
- package/dist/frontend/components/access-control/useUsers.d.ts.map +1 -0
- package/dist/frontend/hooks/useFlowAccess.d.ts +15 -0
- package/dist/frontend/hooks/useFlowAccess.d.ts.map +1 -0
- package/dist/frontend/hooks/useScopes.d.ts +15 -0
- package/dist/frontend/hooks/useScopes.d.ts.map +1 -0
- package/dist/frontend/hooks/useTeams.d.ts +25 -0
- package/dist/frontend/hooks/useTeams.d.ts.map +1 -0
- package/dist/frontend/index.cjs +2928 -0
- package/dist/frontend/index.cjs.map +1 -0
- package/dist/frontend/index.d.ts +23 -0
- package/dist/frontend/index.d.ts.map +1 -0
- package/dist/frontend/index.mjs +2899 -0
- package/dist/frontend/index.mjs.map +1 -0
- package/dist/frontend/providers/RbacProvider.d.ts +33 -0
- package/dist/frontend/providers/RbacProvider.d.ts.map +1 -0
- package/dist/frontend/stores/accessControlStore.d.ts +49 -0
- package/dist/frontend/stores/accessControlStore.d.ts.map +1 -0
- package/dist/frontend/types.d.ts +95 -0
- package/dist/frontend/types.d.ts.map +1 -0
- package/dist/shared/types.cjs +0 -0
- package/dist/shared/types.d.ts +172 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.mjs +1 -0
- package/package.json +107 -0
|
@@ -0,0 +1,2928 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let lucide_react = require("lucide-react");
|
|
3
|
+
let react = require("react");
|
|
4
|
+
let _tanstack_react_query = require("@tanstack/react-query");
|
|
5
|
+
let _invect_frontend = require("@invect/frontend");
|
|
6
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
7
|
+
let clsx = require("clsx");
|
|
8
|
+
let tailwind_merge = require("tailwind-merge");
|
|
9
|
+
let zustand = require("zustand");
|
|
10
|
+
//#region src/frontend/providers/RbacProvider.tsx
|
|
11
|
+
/**
|
|
12
|
+
* RBAC Context Provider
|
|
13
|
+
*
|
|
14
|
+
* Wraps the Invect app tree with RBAC state — current user identity,
|
|
15
|
+
* permissions cache, and permission-checking utilities.
|
|
16
|
+
*
|
|
17
|
+
* Fetches GET /plugins/auth/me on mount and caches the result.
|
|
18
|
+
*/
|
|
19
|
+
const RbacContext = (0, react.createContext)(null);
|
|
20
|
+
function RbacProvider({ children }) {
|
|
21
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
22
|
+
const { data: me, isLoading } = (0, _tanstack_react_query.useQuery)({
|
|
23
|
+
queryKey: [
|
|
24
|
+
"rbac",
|
|
25
|
+
"auth",
|
|
26
|
+
"me"
|
|
27
|
+
],
|
|
28
|
+
queryFn: async () => {
|
|
29
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/auth/me`, { credentials: "include" });
|
|
30
|
+
if (!response.ok) return {
|
|
31
|
+
identity: null,
|
|
32
|
+
permissions: [],
|
|
33
|
+
isAuthenticated: false
|
|
34
|
+
};
|
|
35
|
+
return response.json();
|
|
36
|
+
},
|
|
37
|
+
staleTime: 300 * 1e3,
|
|
38
|
+
retry: 1
|
|
39
|
+
});
|
|
40
|
+
const permissions = (0, react.useMemo)(() => new Set(me?.permissions ?? []), [me?.permissions]);
|
|
41
|
+
const checkPermission = (0, react.useCallback)((permission) => {
|
|
42
|
+
if (!me?.isAuthenticated) return true;
|
|
43
|
+
return permissions.has(permission) || permissions.has("admin:*");
|
|
44
|
+
}, [me?.isAuthenticated, permissions]);
|
|
45
|
+
const value = (0, react.useMemo)(() => ({
|
|
46
|
+
user: me?.identity ?? null,
|
|
47
|
+
isAuthenticated: me?.isAuthenticated ?? false,
|
|
48
|
+
permissions,
|
|
49
|
+
checkPermission,
|
|
50
|
+
isLoading
|
|
51
|
+
}), [
|
|
52
|
+
me,
|
|
53
|
+
permissions,
|
|
54
|
+
checkPermission,
|
|
55
|
+
isLoading
|
|
56
|
+
]);
|
|
57
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RbacContext.Provider, {
|
|
58
|
+
value,
|
|
59
|
+
children
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Access RBAC context — current user, permissions, and permission checking.
|
|
64
|
+
*
|
|
65
|
+
* Must be used within an `<RbacProvider>`.
|
|
66
|
+
* Returns a safe fallback (unauthenticated, allow all) if provider is missing.
|
|
67
|
+
*/
|
|
68
|
+
function useRbac() {
|
|
69
|
+
const ctx = (0, react.useContext)(RbacContext);
|
|
70
|
+
if (!ctx) return {
|
|
71
|
+
user: null,
|
|
72
|
+
isAuthenticated: false,
|
|
73
|
+
permissions: /* @__PURE__ */ new Set(),
|
|
74
|
+
checkPermission: () => true,
|
|
75
|
+
isLoading: false
|
|
76
|
+
};
|
|
77
|
+
return ctx;
|
|
78
|
+
}
|
|
79
|
+
//#endregion
|
|
80
|
+
//#region src/frontend/hooks/useFlowAccess.ts
|
|
81
|
+
/**
|
|
82
|
+
* useFlowAccess — React Query hooks for flow access records
|
|
83
|
+
*/
|
|
84
|
+
/** Fetch access records for a specific flow */
|
|
85
|
+
function useFlowAccess(flowId) {
|
|
86
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
87
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
88
|
+
queryKey: [
|
|
89
|
+
"rbac",
|
|
90
|
+
"flow-access",
|
|
91
|
+
flowId
|
|
92
|
+
],
|
|
93
|
+
queryFn: async () => {
|
|
94
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/${flowId}/access`, { credentials: "include" });
|
|
95
|
+
if (!response.ok) throw new Error(`Failed to fetch flow access: ${response.status}`);
|
|
96
|
+
return response.json();
|
|
97
|
+
},
|
|
98
|
+
enabled: !!flowId
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** Fetch all flow IDs accessible to the current user with their effective permission */
|
|
102
|
+
function useAccessibleFlows() {
|
|
103
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
104
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
105
|
+
queryKey: ["rbac", "accessible-flows"],
|
|
106
|
+
queryFn: async () => {
|
|
107
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/accessible`, { credentials: "include" });
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const err = await response.json().catch(() => ({}));
|
|
110
|
+
throw new Error(err.error || err.message || `Failed to fetch accessible flows: ${response.status}`);
|
|
111
|
+
}
|
|
112
|
+
return response.json();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Grant access to a flow */
|
|
117
|
+
function useGrantFlowAccess(flowId) {
|
|
118
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
119
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
120
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
121
|
+
mutationFn: async (input) => {
|
|
122
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/${flowId}/access`, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "content-type": "application/json" },
|
|
125
|
+
credentials: "include",
|
|
126
|
+
body: JSON.stringify(input)
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok) {
|
|
129
|
+
const err = await response.json().catch(() => ({}));
|
|
130
|
+
throw new Error(err.error || `Failed to grant access: ${response.status}`);
|
|
131
|
+
}
|
|
132
|
+
return response.json();
|
|
133
|
+
},
|
|
134
|
+
onSuccess: () => {
|
|
135
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
136
|
+
"rbac",
|
|
137
|
+
"flow-access",
|
|
138
|
+
flowId
|
|
139
|
+
] });
|
|
140
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
141
|
+
"rbac",
|
|
142
|
+
"effective-flow-access",
|
|
143
|
+
flowId
|
|
144
|
+
] });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/** Revoke a specific access record */
|
|
149
|
+
function useRevokeFlowAccess(flowId) {
|
|
150
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
151
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
152
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
153
|
+
mutationFn: async (accessId) => {
|
|
154
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/${flowId}/access/${accessId}`, {
|
|
155
|
+
method: "DELETE",
|
|
156
|
+
credentials: "include"
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok && response.status !== 204) {
|
|
159
|
+
const err = await response.json().catch(() => ({}));
|
|
160
|
+
throw new Error(err.error || `Failed to revoke access: ${response.status}`);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
onSuccess: () => {
|
|
164
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
165
|
+
"rbac",
|
|
166
|
+
"flow-access",
|
|
167
|
+
flowId
|
|
168
|
+
] });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
//#endregion
|
|
173
|
+
//#region src/frontend/components/ShareFlowModal.tsx
|
|
174
|
+
/**
|
|
175
|
+
* ShareFlowModal — Modal for managing flow access permissions.
|
|
176
|
+
*
|
|
177
|
+
* Shows current access records and allows granting/revoking access.
|
|
178
|
+
*/
|
|
179
|
+
const AVATAR_COLORS = [
|
|
180
|
+
"bg-blue-500/15 text-blue-600 dark:text-blue-400",
|
|
181
|
+
"bg-violet-500/15 text-violet-600 dark:text-violet-400",
|
|
182
|
+
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
|
183
|
+
"bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
|
184
|
+
"bg-rose-500/15 text-rose-600 dark:text-rose-400",
|
|
185
|
+
"bg-cyan-500/15 text-cyan-600 dark:text-cyan-400",
|
|
186
|
+
"bg-fuchsia-500/15 text-fuchsia-600 dark:text-fuchsia-400",
|
|
187
|
+
"bg-orange-500/15 text-orange-600 dark:text-orange-400"
|
|
188
|
+
];
|
|
189
|
+
function getAvatarColor(id) {
|
|
190
|
+
let hash = 0;
|
|
191
|
+
for (let i = 0; i < id.length; i++) hash = (hash << 5) - hash + id.charCodeAt(i) | 0;
|
|
192
|
+
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
|
|
193
|
+
}
|
|
194
|
+
function getPermissionColor(permission) {
|
|
195
|
+
switch (permission) {
|
|
196
|
+
case "owner": return "border-amber-500/30 bg-amber-500/5 text-amber-600 dark:text-amber-400";
|
|
197
|
+
case "editor": return "border-blue-500/30 bg-blue-500/5 text-blue-600 dark:text-blue-400";
|
|
198
|
+
case "operator": return "border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400";
|
|
199
|
+
default: return "border-imp-border bg-imp-muted/50 text-imp-muted-foreground";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const PERMISSION_OPTIONS = [
|
|
203
|
+
{
|
|
204
|
+
value: "viewer",
|
|
205
|
+
label: "Viewer"
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
value: "operator",
|
|
209
|
+
label: "Operator"
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
value: "editor",
|
|
213
|
+
label: "Editor"
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
value: "owner",
|
|
217
|
+
label: "Owner"
|
|
218
|
+
}
|
|
219
|
+
];
|
|
220
|
+
function ShareFlowModal({ flowId, onClose }) {
|
|
221
|
+
const { user } = useRbac();
|
|
222
|
+
const { data, isLoading } = useFlowAccess(flowId);
|
|
223
|
+
const grantAccess = useGrantFlowAccess(flowId);
|
|
224
|
+
const revokeAccess = useRevokeFlowAccess(flowId);
|
|
225
|
+
const [newPrincipal, setNewPrincipal] = (0, react.useState)("");
|
|
226
|
+
const [newPermission, setNewPermission] = (0, react.useState)("viewer");
|
|
227
|
+
const [principalType, setPrincipalType] = (0, react.useState)("user");
|
|
228
|
+
const [error, setError] = (0, react.useState)(null);
|
|
229
|
+
const [openRoleDropdown, setOpenRoleDropdown] = (0, react.useState)(null);
|
|
230
|
+
const accessRecords = data?.access ?? [];
|
|
231
|
+
const handleGrant = async () => {
|
|
232
|
+
if (!newPrincipal.trim()) {
|
|
233
|
+
setError("Please enter a user ID or team ID");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setError(null);
|
|
237
|
+
try {
|
|
238
|
+
await grantAccess.mutateAsync({
|
|
239
|
+
...principalType === "user" ? { userId: newPrincipal } : { teamId: newPrincipal },
|
|
240
|
+
permission: newPermission
|
|
241
|
+
});
|
|
242
|
+
setNewPrincipal("");
|
|
243
|
+
} catch (err) {
|
|
244
|
+
setError(err instanceof Error ? err.message : "Failed to grant access");
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const handleRevoke = async (accessId) => {
|
|
248
|
+
try {
|
|
249
|
+
await revokeAccess.mutateAsync(accessId);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
setError(err instanceof Error ? err.message : "Failed to revoke access");
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const handleChangeRole = async (accessId, record, newRole) => {
|
|
255
|
+
try {
|
|
256
|
+
await grantAccess.mutateAsync({
|
|
257
|
+
...record.userId ? { userId: record.userId } : record.teamId ? { teamId: record.teamId } : {},
|
|
258
|
+
permission: newRole
|
|
259
|
+
});
|
|
260
|
+
setOpenRoleDropdown(null);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
setError(err instanceof Error ? err.message : "Failed to update role");
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
266
|
+
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/50",
|
|
267
|
+
onClick: onClose,
|
|
268
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
269
|
+
className: "w-full max-w-md rounded-xl border border-imp-border bg-imp-background p-5 shadow-2xl",
|
|
270
|
+
onClick: (e) => e.stopPropagation(),
|
|
271
|
+
children: [
|
|
272
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
273
|
+
className: "mb-4 flex items-center justify-between",
|
|
274
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
275
|
+
className: "text-base font-semibold text-imp-foreground",
|
|
276
|
+
children: "Share Flow"
|
|
277
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
278
|
+
onClick: onClose,
|
|
279
|
+
className: "rounded-lg p-1.5 text-imp-muted-foreground transition-colors hover:bg-imp-muted hover:text-imp-foreground",
|
|
280
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "h-4 w-4" })
|
|
281
|
+
})]
|
|
282
|
+
}),
|
|
283
|
+
error && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
284
|
+
className: "mb-3 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600 dark:bg-red-950/20 dark:text-red-400",
|
|
285
|
+
children: error
|
|
286
|
+
}),
|
|
287
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
288
|
+
className: "mb-4",
|
|
289
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
290
|
+
className: "mb-2 text-xs font-medium uppercase tracking-wide text-imp-muted-foreground",
|
|
291
|
+
children: "People with access"
|
|
292
|
+
}), isLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
293
|
+
className: "py-4 text-center text-sm text-imp-muted-foreground",
|
|
294
|
+
children: "Loading..."
|
|
295
|
+
}) : accessRecords.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
296
|
+
className: "py-4 text-center text-sm text-imp-muted-foreground",
|
|
297
|
+
children: "No flow-specific access records yet."
|
|
298
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
299
|
+
className: "max-h-48 space-y-0.5 overflow-y-auto",
|
|
300
|
+
children: accessRecords.map((record) => {
|
|
301
|
+
const principalId = record.userId ?? record.teamId ?? "?";
|
|
302
|
+
const isCurrentUser = record.userId === user?.id;
|
|
303
|
+
const isRoleDropdownOpen = openRoleDropdown === record.id;
|
|
304
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
305
|
+
className: "flex items-center justify-between rounded-lg px-2.5 py-2 transition-colors hover:bg-imp-muted/30",
|
|
306
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
307
|
+
className: "flex items-center gap-2.5 min-w-0",
|
|
308
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
309
|
+
className: `flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold ${getAvatarColor(principalId)}`,
|
|
310
|
+
children: principalId[0]?.toUpperCase()
|
|
311
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
312
|
+
className: "truncate text-sm font-medium text-imp-foreground",
|
|
313
|
+
children: [principalId, isCurrentUser && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
314
|
+
className: "ml-1 font-normal text-imp-muted-foreground",
|
|
315
|
+
children: "(you)"
|
|
316
|
+
})]
|
|
317
|
+
})]
|
|
318
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
319
|
+
className: "relative",
|
|
320
|
+
children: isCurrentUser ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
321
|
+
className: `inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize ${getPermissionColor(record.permission)}`,
|
|
322
|
+
children: record.permission
|
|
323
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
324
|
+
type: "button",
|
|
325
|
+
onClick: () => setOpenRoleDropdown(isRoleDropdownOpen ? null : record.id),
|
|
326
|
+
className: `inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium capitalize transition-colors hover:bg-imp-muted/50 ${getPermissionColor(record.permission)}`,
|
|
327
|
+
children: [record.permission, /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "h-3 w-3" })]
|
|
328
|
+
}), isRoleDropdownOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
329
|
+
className: "fixed inset-0 z-10",
|
|
330
|
+
onClick: () => setOpenRoleDropdown(null)
|
|
331
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
332
|
+
className: "absolute right-0 top-full z-20 mt-1 w-36 rounded-lg border border-imp-border bg-imp-background py-1 shadow-lg",
|
|
333
|
+
children: [
|
|
334
|
+
PERMISSION_OPTIONS.map((opt) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
335
|
+
type: "button",
|
|
336
|
+
className: `w-full px-3 py-1.5 text-left text-xs ${record.permission === opt.value ? "bg-imp-muted font-medium text-imp-foreground" : "text-imp-foreground hover:bg-imp-muted/50"}`,
|
|
337
|
+
onClick: () => handleChangeRole(record.id, record, opt.value),
|
|
338
|
+
children: opt.label
|
|
339
|
+
}, opt.value)),
|
|
340
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { className: "my-1 border-t border-imp-border" }),
|
|
341
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
342
|
+
type: "button",
|
|
343
|
+
className: "w-full px-3 py-1.5 text-left text-xs text-red-600 hover:bg-red-500/10 dark:text-red-400",
|
|
344
|
+
onClick: () => {
|
|
345
|
+
handleRevoke(record.id);
|
|
346
|
+
setOpenRoleDropdown(null);
|
|
347
|
+
},
|
|
348
|
+
children: "Remove access"
|
|
349
|
+
})
|
|
350
|
+
]
|
|
351
|
+
})] })] })
|
|
352
|
+
})]
|
|
353
|
+
}, record.id);
|
|
354
|
+
})
|
|
355
|
+
})]
|
|
356
|
+
}),
|
|
357
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
358
|
+
className: "border-t border-imp-border pt-4",
|
|
359
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
360
|
+
className: "mb-2.5 text-xs font-medium uppercase tracking-wide text-imp-muted-foreground",
|
|
361
|
+
children: "Add people"
|
|
362
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
363
|
+
className: "flex gap-2",
|
|
364
|
+
children: [
|
|
365
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
366
|
+
type: "button",
|
|
367
|
+
onClick: () => setPrincipalType(principalType === "user" ? "team" : "user"),
|
|
368
|
+
className: "shrink-0 rounded-lg border border-imp-border bg-imp-background px-2.5 py-1.5 text-xs font-medium text-imp-foreground transition-colors hover:bg-imp-muted/50",
|
|
369
|
+
children: principalType === "user" ? "User" : "Team"
|
|
370
|
+
}),
|
|
371
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
372
|
+
type: "text",
|
|
373
|
+
value: newPrincipal,
|
|
374
|
+
onChange: (e) => setNewPrincipal(e.target.value),
|
|
375
|
+
placeholder: principalType === "user" ? "User ID" : "Team ID",
|
|
376
|
+
className: "flex-1 rounded-lg border border-imp-border bg-imp-background px-3 py-1.5 text-sm placeholder:text-imp-muted-foreground focus:border-imp-primary/50 focus:outline-none",
|
|
377
|
+
onKeyDown: (e) => {
|
|
378
|
+
if (e.key === "Enter") handleGrant();
|
|
379
|
+
}
|
|
380
|
+
}),
|
|
381
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
382
|
+
type: "button",
|
|
383
|
+
onClick: () => {
|
|
384
|
+
const next = PERMISSION_OPTIONS[(PERMISSION_OPTIONS.findIndex((o) => o.value === newPermission) + 1) % PERMISSION_OPTIONS.length];
|
|
385
|
+
setNewPermission(next.value);
|
|
386
|
+
},
|
|
387
|
+
className: `shrink-0 rounded-lg border px-2.5 py-1.5 text-xs font-medium capitalize transition-colors ${getPermissionColor(newPermission)}`,
|
|
388
|
+
children: newPermission
|
|
389
|
+
}),
|
|
390
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
391
|
+
onClick: handleGrant,
|
|
392
|
+
disabled: grantAccess.isPending,
|
|
393
|
+
className: "shrink-0 rounded-lg bg-imp-primary px-3.5 py-1.5 text-sm font-medium text-imp-primary-foreground transition-colors hover:bg-imp-primary/90 disabled:opacity-50",
|
|
394
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Share2, { className: "h-3.5 w-3.5" })
|
|
395
|
+
})
|
|
396
|
+
]
|
|
397
|
+
})]
|
|
398
|
+
})
|
|
399
|
+
]
|
|
400
|
+
})
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/frontend/components/ShareButton.tsx
|
|
405
|
+
/**
|
|
406
|
+
* ShareButton — Header action component for the flow editor.
|
|
407
|
+
*
|
|
408
|
+
* Renders a "Share" button in the flow header that opens the ShareFlowModal.
|
|
409
|
+
* Registered as a headerAction contribution for the 'flowHeader' context.
|
|
410
|
+
*/
|
|
411
|
+
function ShareButton({ flowId }) {
|
|
412
|
+
const [isOpen, setIsOpen] = (0, react.useState)(false);
|
|
413
|
+
const { isAuthenticated, checkPermission } = useRbac();
|
|
414
|
+
const { data } = useAccessibleFlows();
|
|
415
|
+
if (!flowId) return null;
|
|
416
|
+
if ((isAuthenticated && checkPermission("admin:*") ? "owner" : data?.permissions?.[flowId] ?? null) !== "owner") return null;
|
|
417
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
418
|
+
onClick: () => setIsOpen(true),
|
|
419
|
+
className: "inline-flex items-center gap-2 rounded-md border border-imp-border px-3 py-1.5 text-sm font-medium text-imp-foreground transition-colors hover:border-imp-primary/50 hover:bg-imp-muted",
|
|
420
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Share2, { className: "h-4 w-4" }), "Share"]
|
|
421
|
+
}), isOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ShareFlowModal, {
|
|
422
|
+
flowId,
|
|
423
|
+
onClose: () => setIsOpen(false)
|
|
424
|
+
})] });
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/frontend/components/FlowAccessPanel.tsx
|
|
428
|
+
/**
|
|
429
|
+
* FlowAccessPanel — Panel tab for the flow editor.
|
|
430
|
+
*
|
|
431
|
+
* Shows the current access records for the selected flow in a
|
|
432
|
+
* compact list. Registered as a panelTab contribution for the
|
|
433
|
+
* 'flowEditor' context.
|
|
434
|
+
*/
|
|
435
|
+
function FlowAccessPanel({ flowId }) {
|
|
436
|
+
const { user } = useRbac();
|
|
437
|
+
const { data, isLoading, error } = useFlowAccess(flowId);
|
|
438
|
+
const accessRecords = data?.access ?? [];
|
|
439
|
+
const myRecord = accessRecords.find((r) => r.userId === user?.id);
|
|
440
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
441
|
+
className: "flex h-full flex-col p-4",
|
|
442
|
+
children: [
|
|
443
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
444
|
+
className: "mb-4 flex items-center justify-between",
|
|
445
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
446
|
+
className: "flex items-center gap-2",
|
|
447
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Shield, { className: "h-4 w-4 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
448
|
+
className: "text-sm font-medium",
|
|
449
|
+
children: "Access Control"
|
|
450
|
+
})]
|
|
451
|
+
}), myRecord && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
452
|
+
className: "rounded-full border border-imp-border px-2 py-0.5 text-xs font-medium capitalize",
|
|
453
|
+
children: myRecord.permission
|
|
454
|
+
})]
|
|
455
|
+
}),
|
|
456
|
+
isLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
457
|
+
className: "flex flex-1 items-center justify-center",
|
|
458
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
459
|
+
className: "text-sm text-imp-muted-foreground",
|
|
460
|
+
children: "Loading access records..."
|
|
461
|
+
})
|
|
462
|
+
}) : error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
463
|
+
className: "flex flex-1 items-center justify-center",
|
|
464
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
465
|
+
className: "text-sm text-red-500",
|
|
466
|
+
children: error instanceof Error ? error.message : "Failed to load access records"
|
|
467
|
+
})
|
|
468
|
+
}) : accessRecords.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
469
|
+
className: "flex flex-1 flex-col items-center justify-center gap-2",
|
|
470
|
+
children: [
|
|
471
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-8 w-8 text-imp-muted-foreground/50" }),
|
|
472
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
473
|
+
className: "text-sm text-imp-muted-foreground",
|
|
474
|
+
children: "No access records"
|
|
475
|
+
}),
|
|
476
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
477
|
+
className: "text-xs text-imp-muted-foreground",
|
|
478
|
+
children: "Use the Share button to grant access."
|
|
479
|
+
})
|
|
480
|
+
]
|
|
481
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
482
|
+
className: "flex-1 space-y-1 overflow-y-auto",
|
|
483
|
+
children: accessRecords.map((record) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
484
|
+
className: "flex items-center gap-3 rounded-md px-3 py-2 bg-imp-muted/30",
|
|
485
|
+
children: [
|
|
486
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
487
|
+
className: "flex h-7 w-7 items-center justify-center rounded-full bg-imp-primary/10",
|
|
488
|
+
children: record.teamId ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-3.5 w-3.5 text-imp-primary" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-3.5 w-3.5 text-imp-primary" })
|
|
489
|
+
}),
|
|
490
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
491
|
+
className: "flex-1 min-w-0",
|
|
492
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
493
|
+
className: "truncate text-sm",
|
|
494
|
+
children: [record.userId ?? record.teamId, record.userId === user?.id && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
495
|
+
className: "ml-1 text-imp-muted-foreground",
|
|
496
|
+
children: "(you)"
|
|
497
|
+
})]
|
|
498
|
+
}), record.expiresAt && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
499
|
+
className: "text-xs text-imp-muted-foreground",
|
|
500
|
+
children: ["Expires ", new Date(record.expiresAt).toLocaleDateString()]
|
|
501
|
+
})]
|
|
502
|
+
}),
|
|
503
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
504
|
+
className: "rounded-full bg-imp-muted px-2 py-0.5 text-xs font-medium capitalize",
|
|
505
|
+
children: record.permission
|
|
506
|
+
})
|
|
507
|
+
]
|
|
508
|
+
}, record.id))
|
|
509
|
+
}),
|
|
510
|
+
accessRecords.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
511
|
+
className: "mt-3 border-t border-imp-border pt-3",
|
|
512
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
513
|
+
className: "text-xs text-imp-muted-foreground",
|
|
514
|
+
children: [
|
|
515
|
+
accessRecords.length,
|
|
516
|
+
" ",
|
|
517
|
+
accessRecords.length === 1 ? "person" : "people",
|
|
518
|
+
" with access"
|
|
519
|
+
]
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
]
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
//#endregion
|
|
526
|
+
//#region src/frontend/hooks/useTeams.ts
|
|
527
|
+
/**
|
|
528
|
+
* useTeams — React Query hooks for team management
|
|
529
|
+
*/
|
|
530
|
+
/** List all teams */
|
|
531
|
+
function useTeams() {
|
|
532
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
533
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
534
|
+
queryKey: ["rbac", "teams"],
|
|
535
|
+
queryFn: async () => {
|
|
536
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams`, { credentials: "include" });
|
|
537
|
+
if (!response.ok) throw new Error(`Failed to fetch teams: ${response.status}`);
|
|
538
|
+
return response.json();
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/** Get a single team with members */
|
|
543
|
+
function useTeam(teamId) {
|
|
544
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
545
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
546
|
+
queryKey: [
|
|
547
|
+
"rbac",
|
|
548
|
+
"team",
|
|
549
|
+
teamId
|
|
550
|
+
],
|
|
551
|
+
queryFn: async () => {
|
|
552
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams/${teamId}`, { credentials: "include" });
|
|
553
|
+
if (!response.ok) throw new Error(`Failed to fetch team: ${response.status}`);
|
|
554
|
+
return response.json();
|
|
555
|
+
},
|
|
556
|
+
enabled: !!teamId
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
/** Get current user's teams */
|
|
560
|
+
function useMyTeams() {
|
|
561
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
562
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
563
|
+
queryKey: ["rbac", "my-teams"],
|
|
564
|
+
queryFn: async () => {
|
|
565
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/my-teams`, { credentials: "include" });
|
|
566
|
+
if (!response.ok) throw new Error(`Failed to fetch my teams: ${response.status}`);
|
|
567
|
+
return response.json();
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
/** Create a team */
|
|
572
|
+
function useCreateTeam() {
|
|
573
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
574
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
575
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
576
|
+
mutationFn: async (input) => {
|
|
577
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams`, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
headers: { "content-type": "application/json" },
|
|
580
|
+
credentials: "include",
|
|
581
|
+
body: JSON.stringify(input)
|
|
582
|
+
});
|
|
583
|
+
if (!response.ok) {
|
|
584
|
+
const err = await response.json().catch(() => ({}));
|
|
585
|
+
throw new Error(err.error || `Failed to create team: ${response.status}`);
|
|
586
|
+
}
|
|
587
|
+
return response.json();
|
|
588
|
+
},
|
|
589
|
+
onSuccess: () => {
|
|
590
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "teams"] });
|
|
591
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/** Update a team */
|
|
596
|
+
function useUpdateTeam(teamId) {
|
|
597
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
598
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
599
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
600
|
+
mutationFn: async (input) => {
|
|
601
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams/${teamId}`, {
|
|
602
|
+
method: "PUT",
|
|
603
|
+
headers: { "content-type": "application/json" },
|
|
604
|
+
credentials: "include",
|
|
605
|
+
body: JSON.stringify(input)
|
|
606
|
+
});
|
|
607
|
+
if (!response.ok) {
|
|
608
|
+
const err = await response.json().catch(() => ({}));
|
|
609
|
+
throw new Error(err.error || `Failed to update team: ${response.status}`);
|
|
610
|
+
}
|
|
611
|
+
return response.json();
|
|
612
|
+
},
|
|
613
|
+
onSuccess: () => {
|
|
614
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "teams"] });
|
|
615
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
616
|
+
"rbac",
|
|
617
|
+
"team",
|
|
618
|
+
teamId
|
|
619
|
+
] });
|
|
620
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
/** Delete a team */
|
|
625
|
+
function useDeleteTeam() {
|
|
626
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
627
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
628
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
629
|
+
mutationFn: async (teamId) => {
|
|
630
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams/${teamId}`, {
|
|
631
|
+
method: "DELETE",
|
|
632
|
+
credentials: "include"
|
|
633
|
+
});
|
|
634
|
+
if (!response.ok && response.status !== 204) {
|
|
635
|
+
const err = await response.json().catch(() => ({}));
|
|
636
|
+
throw new Error(err.error || `Failed to delete team: ${response.status}`);
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
onSuccess: () => {
|
|
640
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "teams"] });
|
|
641
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/** Add a member to a team */
|
|
646
|
+
function useAddTeamMember(teamId) {
|
|
647
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
648
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
649
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
650
|
+
mutationFn: async (input) => {
|
|
651
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams/${teamId}/members`, {
|
|
652
|
+
method: "POST",
|
|
653
|
+
headers: { "content-type": "application/json" },
|
|
654
|
+
credentials: "include",
|
|
655
|
+
body: JSON.stringify(input)
|
|
656
|
+
});
|
|
657
|
+
if (!response.ok) {
|
|
658
|
+
const err = await response.json().catch(() => ({}));
|
|
659
|
+
throw new Error(err.error || `Failed to add member: ${response.status}`);
|
|
660
|
+
}
|
|
661
|
+
return response.json();
|
|
662
|
+
},
|
|
663
|
+
onSuccess: () => {
|
|
664
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
665
|
+
"rbac",
|
|
666
|
+
"team",
|
|
667
|
+
teamId
|
|
668
|
+
] });
|
|
669
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "teams"] });
|
|
670
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/** Remove a member from a team */
|
|
675
|
+
function useRemoveTeamMember(teamId) {
|
|
676
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
677
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
678
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
679
|
+
mutationFn: async (userId) => {
|
|
680
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/teams/${teamId}/members/${userId}`, {
|
|
681
|
+
method: "DELETE",
|
|
682
|
+
credentials: "include"
|
|
683
|
+
});
|
|
684
|
+
if (!response.ok && response.status !== 204) {
|
|
685
|
+
const err = await response.json().catch(() => ({}));
|
|
686
|
+
throw new Error(err.error || `Failed to remove member: ${response.status}`);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
onSuccess: () => {
|
|
690
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
691
|
+
"rbac",
|
|
692
|
+
"team",
|
|
693
|
+
teamId
|
|
694
|
+
] });
|
|
695
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "teams"] });
|
|
696
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
//#region src/frontend/hooks/useScopes.ts
|
|
702
|
+
function useScopeTree() {
|
|
703
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
704
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
705
|
+
queryKey: ["rbac", "scope-tree"],
|
|
706
|
+
queryFn: async () => {
|
|
707
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/scopes/tree`, { credentials: "include" });
|
|
708
|
+
if (!response.ok) {
|
|
709
|
+
const err = await response.json().catch(() => ({}));
|
|
710
|
+
throw new Error(err.error || `Failed to fetch scope tree: ${response.status}`);
|
|
711
|
+
}
|
|
712
|
+
return response.json();
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
function useScopeAccess(scopeId) {
|
|
717
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
718
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
719
|
+
queryKey: [
|
|
720
|
+
"rbac",
|
|
721
|
+
"scope-access",
|
|
722
|
+
scopeId
|
|
723
|
+
],
|
|
724
|
+
queryFn: async () => {
|
|
725
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/scopes/${scopeId}/access`, { credentials: "include" });
|
|
726
|
+
if (!response.ok) {
|
|
727
|
+
const err = await response.json().catch(() => ({}));
|
|
728
|
+
throw new Error(err.error || `Failed to fetch scope access: ${response.status}`);
|
|
729
|
+
}
|
|
730
|
+
return response.json();
|
|
731
|
+
},
|
|
732
|
+
enabled: !!scopeId
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function useGrantScopeAccess(scopeId) {
|
|
736
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
737
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
738
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
739
|
+
mutationFn: async (input) => {
|
|
740
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/scopes/${scopeId}/access`, {
|
|
741
|
+
method: "POST",
|
|
742
|
+
headers: { "content-type": "application/json" },
|
|
743
|
+
credentials: "include",
|
|
744
|
+
body: JSON.stringify(input)
|
|
745
|
+
});
|
|
746
|
+
if (!response.ok) {
|
|
747
|
+
const err = await response.json().catch(() => ({}));
|
|
748
|
+
throw new Error(err.error || `Failed to grant scope access: ${response.status}`);
|
|
749
|
+
}
|
|
750
|
+
return response.json();
|
|
751
|
+
},
|
|
752
|
+
onSuccess: () => {
|
|
753
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
754
|
+
"rbac",
|
|
755
|
+
"scope-access",
|
|
756
|
+
scopeId
|
|
757
|
+
] });
|
|
758
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
function useRevokeScopeAccess(scopeId) {
|
|
763
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
764
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
765
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
766
|
+
mutationFn: async (accessId) => {
|
|
767
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/scopes/${scopeId}/access/${accessId}`, {
|
|
768
|
+
method: "DELETE",
|
|
769
|
+
credentials: "include"
|
|
770
|
+
});
|
|
771
|
+
if (!response.ok && response.status !== 204) {
|
|
772
|
+
const err = await response.json().catch(() => ({}));
|
|
773
|
+
throw new Error(err.error || `Failed to revoke scope access: ${response.status}`);
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
onSuccess: () => {
|
|
777
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
778
|
+
"rbac",
|
|
779
|
+
"scope-access",
|
|
780
|
+
scopeId
|
|
781
|
+
] });
|
|
782
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
function useEffectiveFlowAccess(flowId) {
|
|
787
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
788
|
+
return (0, _tanstack_react_query.useQuery)({
|
|
789
|
+
queryKey: [
|
|
790
|
+
"rbac",
|
|
791
|
+
"effective-flow-access",
|
|
792
|
+
flowId
|
|
793
|
+
],
|
|
794
|
+
queryFn: async () => {
|
|
795
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/${flowId}/effective-access`, { credentials: "include" });
|
|
796
|
+
if (!response.ok) {
|
|
797
|
+
const err = await response.json().catch(() => ({}));
|
|
798
|
+
throw new Error(err.error || `Failed to fetch effective flow access: ${response.status}`);
|
|
799
|
+
}
|
|
800
|
+
return response.json();
|
|
801
|
+
},
|
|
802
|
+
enabled: !!flowId
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function useMoveFlow(flowId) {
|
|
806
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
807
|
+
const queryClient = (0, _tanstack_react_query.useQueryClient)();
|
|
808
|
+
return (0, _tanstack_react_query.useMutation)({
|
|
809
|
+
mutationFn: async (scopeId) => {
|
|
810
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/flows/${flowId}/scope`, {
|
|
811
|
+
method: "PUT",
|
|
812
|
+
headers: { "content-type": "application/json" },
|
|
813
|
+
credentials: "include",
|
|
814
|
+
body: JSON.stringify({ scopeId })
|
|
815
|
+
});
|
|
816
|
+
if (!response.ok) {
|
|
817
|
+
const err = await response.json().catch(() => ({}));
|
|
818
|
+
throw new Error(err.error || `Failed to move flow: ${response.status}`);
|
|
819
|
+
}
|
|
820
|
+
return response.json();
|
|
821
|
+
},
|
|
822
|
+
onSuccess: () => {
|
|
823
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "scope-tree"] });
|
|
824
|
+
queryClient.invalidateQueries({ queryKey: ["rbac", "accessible-flows"] });
|
|
825
|
+
queryClient.invalidateQueries({ queryKey: [
|
|
826
|
+
"rbac",
|
|
827
|
+
"effective-flow-access",
|
|
828
|
+
flowId
|
|
829
|
+
] });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
function usePreviewMove() {
|
|
834
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
835
|
+
return (0, _tanstack_react_query.useMutation)({ mutationFn: async (input) => {
|
|
836
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/rbac/preview-move`, {
|
|
837
|
+
method: "POST",
|
|
838
|
+
headers: { "content-type": "application/json" },
|
|
839
|
+
credentials: "include",
|
|
840
|
+
body: JSON.stringify(input)
|
|
841
|
+
});
|
|
842
|
+
if (!response.ok) {
|
|
843
|
+
const err = await response.json().catch(() => ({}));
|
|
844
|
+
throw new Error(err.error || `Failed to preview move: ${response.status}`);
|
|
845
|
+
}
|
|
846
|
+
return response.json();
|
|
847
|
+
} });
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/frontend/components/access-control/types.ts
|
|
851
|
+
function formatPermissionLabel(permission) {
|
|
852
|
+
switch (permission) {
|
|
853
|
+
case "owner": return "Owner";
|
|
854
|
+
case "editor": return "Editor";
|
|
855
|
+
case "operator": return "Operator";
|
|
856
|
+
default: return "Viewer";
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function getPermissionBadgeClasses(_permission) {
|
|
860
|
+
return "border-imp-border text-imp-muted-foreground";
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/frontend/components/access-control/useUsers.ts
|
|
864
|
+
function useUsers$1() {
|
|
865
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
866
|
+
const { data } = (0, _tanstack_react_query.useQuery)({
|
|
867
|
+
queryKey: ["rbac", "auth-users"],
|
|
868
|
+
queryFn: async () => {
|
|
869
|
+
const response = await fetch(`${api.getBaseURL()}/plugins/auth/users?limit=200`, { credentials: "include" });
|
|
870
|
+
if (!response.ok) throw new Error(`Failed to fetch users: ${response.status}`);
|
|
871
|
+
return response.json();
|
|
872
|
+
},
|
|
873
|
+
staleTime: 1e3 * 60 * 5,
|
|
874
|
+
gcTime: 1e3 * 60 * 10
|
|
875
|
+
});
|
|
876
|
+
return data?.users ?? [];
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/frontend/components/access-control/AccessTable.tsx
|
|
880
|
+
function cn(...inputs) {
|
|
881
|
+
return (0, tailwind_merge.twMerge)((0, clsx.clsx)(inputs));
|
|
882
|
+
}
|
|
883
|
+
const ROLE_OPTIONS = [
|
|
884
|
+
{
|
|
885
|
+
value: "viewer",
|
|
886
|
+
label: "Viewer",
|
|
887
|
+
description: "Can inspect the flow."
|
|
888
|
+
},
|
|
889
|
+
{
|
|
890
|
+
value: "operator",
|
|
891
|
+
label: "Operator",
|
|
892
|
+
description: "Can inspect and run the flow."
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
value: "editor",
|
|
896
|
+
label: "Editor",
|
|
897
|
+
description: "Can inspect, run, and edit the flow."
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
value: "owner",
|
|
901
|
+
label: "Owner",
|
|
902
|
+
description: "Can edit and manage sharing."
|
|
903
|
+
}
|
|
904
|
+
];
|
|
905
|
+
function RoleSelector({ value, onChange }) {
|
|
906
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenu, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuTrigger, {
|
|
907
|
+
asChild: true,
|
|
908
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
909
|
+
type: "button",
|
|
910
|
+
className: cn("inline-flex w-full items-center justify-between gap-1 rounded-md border px-2.5 py-1.5 text-sm font-medium capitalize", getPermissionBadgeClasses(value)),
|
|
911
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
912
|
+
className: "truncate",
|
|
913
|
+
children: value
|
|
914
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "h-3.5 w-3.5 shrink-0" })]
|
|
915
|
+
})
|
|
916
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenuContent, {
|
|
917
|
+
align: "end",
|
|
918
|
+
className: "w-56",
|
|
919
|
+
children: [
|
|
920
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuLabel, {
|
|
921
|
+
className: "text-xs",
|
|
922
|
+
children: "Select role"
|
|
923
|
+
}),
|
|
924
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuSeparator, {}),
|
|
925
|
+
ROLE_OPTIONS.map((role) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuItem, {
|
|
926
|
+
onSelect: () => onChange(role.value),
|
|
927
|
+
className: cn("items-start gap-0 px-2 py-2", value === role.value && "bg-accent text-accent-foreground"),
|
|
928
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
929
|
+
className: "min-w-0 text-left",
|
|
930
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
931
|
+
className: "text-sm font-medium",
|
|
932
|
+
children: role.label
|
|
933
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
934
|
+
className: "text-xs text-muted-foreground",
|
|
935
|
+
children: role.description
|
|
936
|
+
})]
|
|
937
|
+
})
|
|
938
|
+
}, role.value))
|
|
939
|
+
]
|
|
940
|
+
})] });
|
|
941
|
+
}
|
|
942
|
+
function OptionalRoleSelector({ value, onChange, emptyLabel = "No direct access" }) {
|
|
943
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenu, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuTrigger, {
|
|
944
|
+
asChild: true,
|
|
945
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
946
|
+
type: "button",
|
|
947
|
+
className: cn("inline-flex w-40 items-center justify-between gap-1 rounded-md border px-2.5 py-1.5 text-sm font-medium capitalize", value ? getPermissionBadgeClasses(value) : "border-imp-border text-imp-muted-foreground hover:bg-imp-muted/50"),
|
|
948
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
949
|
+
className: "truncate",
|
|
950
|
+
children: value ?? emptyLabel
|
|
951
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "h-3.5 w-3.5 shrink-0" })]
|
|
952
|
+
})
|
|
953
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenuContent, {
|
|
954
|
+
align: "end",
|
|
955
|
+
className: "w-56",
|
|
956
|
+
children: [
|
|
957
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuLabel, {
|
|
958
|
+
className: "text-xs",
|
|
959
|
+
children: "Set access level"
|
|
960
|
+
}),
|
|
961
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuSeparator, {}),
|
|
962
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuItem, {
|
|
963
|
+
onSelect: () => onChange(null),
|
|
964
|
+
className: cn("px-2 py-2 text-sm", value === null && "bg-accent text-accent-foreground"),
|
|
965
|
+
children: emptyLabel
|
|
966
|
+
}),
|
|
967
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuSeparator, {}),
|
|
968
|
+
ROLE_OPTIONS.map((role) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuItem, {
|
|
969
|
+
onSelect: () => onChange(role.value),
|
|
970
|
+
className: cn("items-start gap-0 px-2 py-2", value === role.value && "bg-accent text-accent-foreground"),
|
|
971
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
972
|
+
className: "min-w-0 text-left",
|
|
973
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
974
|
+
className: "text-sm font-medium",
|
|
975
|
+
children: role.label
|
|
976
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
977
|
+
className: "text-xs text-muted-foreground",
|
|
978
|
+
children: role.description
|
|
979
|
+
})]
|
|
980
|
+
})
|
|
981
|
+
}, role.value))
|
|
982
|
+
]
|
|
983
|
+
})] });
|
|
984
|
+
}
|
|
985
|
+
function AccessTable({ rows, isLoading, emptyLabel }) {
|
|
986
|
+
const [pendingRemovalRow, setPendingRemovalRow] = (0, react.useState)(null);
|
|
987
|
+
const removalDialogCopy = pendingRemovalRow ? pendingRemovalRow.group === "Members" ? {
|
|
988
|
+
title: "Remove team member",
|
|
989
|
+
body: `Remove "${pendingRemovalRow.label}" from team?`,
|
|
990
|
+
confirmLabel: "Remove member"
|
|
991
|
+
} : {
|
|
992
|
+
title: pendingRemovalRow.kind === "team" ? "Revoke team access" : "Revoke user access",
|
|
993
|
+
body: pendingRemovalRow.kind === "team" ? `Remove direct access for ${pendingRemovalRow.label}?` : `Remove direct access for ${pendingRemovalRow.label}?`,
|
|
994
|
+
confirmLabel: "Remove access"
|
|
995
|
+
} : null;
|
|
996
|
+
if (isLoading) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
997
|
+
className: "px-2 py-4 text-sm text-center text-imp-muted-foreground",
|
|
998
|
+
children: "Loading…"
|
|
999
|
+
});
|
|
1000
|
+
if (rows.length === 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1001
|
+
className: "px-2 py-4 text-sm text-center text-imp-muted-foreground",
|
|
1002
|
+
children: emptyLabel
|
|
1003
|
+
});
|
|
1004
|
+
const groups = [];
|
|
1005
|
+
for (const row of rows) {
|
|
1006
|
+
const groupLabel = row.group ?? "";
|
|
1007
|
+
const existing = groups.find((g) => g.label === groupLabel);
|
|
1008
|
+
if (existing) existing.rows.push(row);
|
|
1009
|
+
else groups.push({
|
|
1010
|
+
label: groupLabel,
|
|
1011
|
+
rows: [row]
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
const hasGroups = groups.length > 1;
|
|
1015
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("table", {
|
|
1016
|
+
className: "w-full text-sm table-fixed",
|
|
1017
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("colgroup", { children: [
|
|
1018
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("col", {}),
|
|
1019
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("col", { className: "w-36" }),
|
|
1020
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("col", { className: "w-10" })
|
|
1021
|
+
] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tbody", { children: groups.map((group) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [hasGroups && group.label && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("tr", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
1022
|
+
colSpan: 3,
|
|
1023
|
+
className: "px-3 pt-4 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-imp-muted-foreground",
|
|
1024
|
+
children: group.label
|
|
1025
|
+
}) }), group.rows.map((row, rowIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("tr", {
|
|
1026
|
+
className: cn("hover:bg-imp-muted/20", rowIndex > 0 && "border-t border-imp-border"),
|
|
1027
|
+
children: [
|
|
1028
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
1029
|
+
className: "px-3 py-2.5",
|
|
1030
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1031
|
+
className: "flex items-center gap-3",
|
|
1032
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1033
|
+
className: cn("flex h-7 w-7 shrink-0 items-center justify-center rounded-full", row.kind === "user" ? "bg-imp-primary/10" : "bg-imp-muted"),
|
|
1034
|
+
children: row.kind === "user" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-3.5 w-3.5 text-imp-primary" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-3.5 w-3.5 text-imp-muted-foreground" })
|
|
1035
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1036
|
+
className: "min-w-0",
|
|
1037
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1038
|
+
className: "flex items-center gap-2",
|
|
1039
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1040
|
+
className: "font-medium truncate",
|
|
1041
|
+
children: row.label
|
|
1042
|
+
}), row.kind === "team" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1043
|
+
className: "shrink-0 rounded border border-imp-border px-1.5 py-0.5 text-[11px] font-medium text-imp-muted-foreground",
|
|
1044
|
+
children: "Team"
|
|
1045
|
+
})]
|
|
1046
|
+
}), row.source && row.source !== "Direct grant" && row.source !== "Member" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1047
|
+
className: "text-[11px] text-imp-muted-foreground",
|
|
1048
|
+
children: row.source
|
|
1049
|
+
})]
|
|
1050
|
+
})]
|
|
1051
|
+
})
|
|
1052
|
+
}),
|
|
1053
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
1054
|
+
className: "px-3 py-2.5 text-center",
|
|
1055
|
+
children: row.permission ? row.onPermissionChange ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenu, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuTrigger, {
|
|
1056
|
+
asChild: true,
|
|
1057
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1058
|
+
type: "button",
|
|
1059
|
+
className: cn("inline-flex w-32 items-center justify-between gap-1 rounded-full border px-2.5 py-1 text-xs font-medium capitalize hover:bg-imp-muted/50", getPermissionBadgeClasses(row.permission)),
|
|
1060
|
+
title: row.source,
|
|
1061
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1062
|
+
className: "truncate",
|
|
1063
|
+
children: row.permission
|
|
1064
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "h-3.5 w-3.5 shrink-0" })]
|
|
1065
|
+
})
|
|
1066
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DropdownMenuContent, {
|
|
1067
|
+
align: "end",
|
|
1068
|
+
className: "w-64",
|
|
1069
|
+
children: [
|
|
1070
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuLabel, {
|
|
1071
|
+
className: "text-xs",
|
|
1072
|
+
children: "Change access role"
|
|
1073
|
+
}),
|
|
1074
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuSeparator, {}),
|
|
1075
|
+
ROLE_OPTIONS.map((role) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DropdownMenuItem, {
|
|
1076
|
+
onSelect: () => row.onPermissionChange?.(role.value),
|
|
1077
|
+
className: cn("items-start gap-0 px-2 py-2", row.permission === role.value && "bg-accent text-accent-foreground"),
|
|
1078
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1079
|
+
className: "min-w-0 text-left",
|
|
1080
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1081
|
+
className: "text-sm font-medium",
|
|
1082
|
+
children: role.label
|
|
1083
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1084
|
+
className: "text-xs text-muted-foreground",
|
|
1085
|
+
children: role.description
|
|
1086
|
+
})]
|
|
1087
|
+
})
|
|
1088
|
+
}, role.value))
|
|
1089
|
+
]
|
|
1090
|
+
})] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1091
|
+
className: cn("inline-flex w-32 justify-center rounded-full border px-2.5 py-1 text-xs font-medium capitalize", getPermissionBadgeClasses(row.permission)),
|
|
1092
|
+
title: row.source,
|
|
1093
|
+
children: row.permission
|
|
1094
|
+
}) : null
|
|
1095
|
+
}),
|
|
1096
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("td", {
|
|
1097
|
+
className: "px-3 py-2.5 text-center",
|
|
1098
|
+
children: row.canRemove && row.onRemove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1099
|
+
type: "button",
|
|
1100
|
+
onClick: () => setPendingRemovalRow(row),
|
|
1101
|
+
className: "p-1 rounded text-imp-muted-foreground hover:bg-red-500/10 hover:text-red-500",
|
|
1102
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "h-3.5 w-3.5" })
|
|
1103
|
+
}) : null
|
|
1104
|
+
})
|
|
1105
|
+
]
|
|
1106
|
+
}, row.id))] }, group.label || "_default")) })]
|
|
1107
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.Dialog, {
|
|
1108
|
+
open: !!pendingRemovalRow,
|
|
1109
|
+
onOpenChange: (open) => !open && setPendingRemovalRow(null),
|
|
1110
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogContent, {
|
|
1111
|
+
className: "max-w-sm border-imp-border bg-imp-background text-imp-foreground sm:max-w-sm",
|
|
1112
|
+
children: [
|
|
1113
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogTitle, {
|
|
1114
|
+
className: "text-sm font-semibold",
|
|
1115
|
+
children: removalDialogCopy?.title
|
|
1116
|
+
}) }),
|
|
1117
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1118
|
+
className: "text-sm text-imp-muted-foreground",
|
|
1119
|
+
children: removalDialogCopy?.body
|
|
1120
|
+
}),
|
|
1121
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogFooter, {
|
|
1122
|
+
className: "justify-between w-full sm:gap-2",
|
|
1123
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1124
|
+
type: "button",
|
|
1125
|
+
onClick: () => setPendingRemovalRow(null),
|
|
1126
|
+
className: "rounded-md mr-auto border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground hover:text-imp-foreground",
|
|
1127
|
+
children: "Cancel"
|
|
1128
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1129
|
+
type: "button",
|
|
1130
|
+
onClick: () => {
|
|
1131
|
+
pendingRemovalRow?.onRemove?.();
|
|
1132
|
+
setPendingRemovalRow(null);
|
|
1133
|
+
},
|
|
1134
|
+
className: "rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700",
|
|
1135
|
+
children: removalDialogCopy?.confirmLabel
|
|
1136
|
+
})]
|
|
1137
|
+
})
|
|
1138
|
+
]
|
|
1139
|
+
})
|
|
1140
|
+
})] });
|
|
1141
|
+
}
|
|
1142
|
+
//#endregion
|
|
1143
|
+
//#region src/frontend/components/access-control/FormDialog.tsx
|
|
1144
|
+
function FormDialog({ open, onClose, title, children }) {
|
|
1145
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.Dialog, {
|
|
1146
|
+
open,
|
|
1147
|
+
onOpenChange: (nextOpen) => !nextOpen && onClose(),
|
|
1148
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogContent, {
|
|
1149
|
+
className: "max-w-md p-4 border-imp-border bg-imp-background text-imp-foreground sm:max-w-md",
|
|
1150
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogHeader, {
|
|
1151
|
+
className: "mb-1",
|
|
1152
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogTitle, {
|
|
1153
|
+
className: "text-sm font-semibold",
|
|
1154
|
+
children: title
|
|
1155
|
+
})
|
|
1156
|
+
}), children]
|
|
1157
|
+
})
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
//#endregion
|
|
1161
|
+
//#region src/frontend/components/access-control/MemberCombobox.tsx
|
|
1162
|
+
function MemberCombobox({ users, excludeIds, selectedUserIds, onSelect }) {
|
|
1163
|
+
const [query, setQuery] = (0, react.useState)("");
|
|
1164
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
1165
|
+
const containerRef = (0, react.useRef)(null);
|
|
1166
|
+
const selectedSet = new Set(selectedUserIds);
|
|
1167
|
+
const filteredUsers = users.filter((user) => {
|
|
1168
|
+
if (excludeIds.has(user.id)) return false;
|
|
1169
|
+
if (selectedSet.has(user.id)) return false;
|
|
1170
|
+
return `${user.name || ""} ${user.email || ""} ${user.id}`.toLowerCase().includes(query.toLowerCase());
|
|
1171
|
+
});
|
|
1172
|
+
const toggle = (userId) => {
|
|
1173
|
+
onSelect(selectedSet.has(userId) ? selectedUserIds.filter((id) => id !== userId) : [...selectedUserIds, userId]);
|
|
1174
|
+
};
|
|
1175
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1176
|
+
ref: containerRef,
|
|
1177
|
+
className: "relative",
|
|
1178
|
+
onBlur: (e) => {
|
|
1179
|
+
if (!containerRef.current?.contains(e.relatedTarget)) setOpen(false);
|
|
1180
|
+
},
|
|
1181
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1182
|
+
className: "flex min-h-[2.5rem] w-full cursor-text flex-wrap gap-1.5 rounded-md border border-imp-border bg-imp-background px-2 py-1.5 focus-within:border-imp-primary/50",
|
|
1183
|
+
onClick: () => setOpen(true),
|
|
1184
|
+
children: [selectedUserIds.map((userId) => {
|
|
1185
|
+
const user = users.find((u) => u.id === userId);
|
|
1186
|
+
const label = user?.name || user?.email || userId;
|
|
1187
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1188
|
+
className: "inline-flex items-center gap-1 rounded-md border border-imp-border bg-imp-muted px-2 py-0.5 text-xs font-medium text-imp-foreground",
|
|
1189
|
+
children: [
|
|
1190
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-3 w-3 shrink-0 text-imp-muted-foreground" }),
|
|
1191
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1192
|
+
className: "max-w-[140px] truncate",
|
|
1193
|
+
children: label
|
|
1194
|
+
}),
|
|
1195
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1196
|
+
type: "button",
|
|
1197
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1198
|
+
onClick: (e) => {
|
|
1199
|
+
e.stopPropagation();
|
|
1200
|
+
toggle(userId);
|
|
1201
|
+
},
|
|
1202
|
+
className: "ml-0.5 text-imp-muted-foreground hover:text-imp-foreground",
|
|
1203
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "h-3 w-3" })
|
|
1204
|
+
})
|
|
1205
|
+
]
|
|
1206
|
+
}, userId);
|
|
1207
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1208
|
+
value: query,
|
|
1209
|
+
onChange: (e) => {
|
|
1210
|
+
setQuery(e.target.value);
|
|
1211
|
+
setOpen(true);
|
|
1212
|
+
},
|
|
1213
|
+
onFocus: () => setOpen(true),
|
|
1214
|
+
placeholder: selectedUserIds.length === 0 ? "Search users…" : "Add more…",
|
|
1215
|
+
className: "min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-imp-muted-foreground"
|
|
1216
|
+
})]
|
|
1217
|
+
}), open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1218
|
+
className: "absolute left-0 z-20 mt-1 w-full overflow-y-auto rounded-md border border-imp-border bg-imp-background shadow-lg top-full max-h-52",
|
|
1219
|
+
children: filteredUsers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1220
|
+
className: "px-3 py-2 text-xs text-imp-muted-foreground",
|
|
1221
|
+
children: query ? "No matches." : "No more users to add."
|
|
1222
|
+
}) : filteredUsers.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1223
|
+
type: "button",
|
|
1224
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1225
|
+
onClick: () => {
|
|
1226
|
+
toggle(user.id);
|
|
1227
|
+
setQuery("");
|
|
1228
|
+
},
|
|
1229
|
+
className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-imp-muted/50",
|
|
1230
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-4 w-4 shrink-0 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1231
|
+
className: "min-w-0 flex-1",
|
|
1232
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1233
|
+
className: "truncate font-medium",
|
|
1234
|
+
children: user.name || user.email || user.id
|
|
1235
|
+
}), user.name && user.email && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1236
|
+
className: "truncate text-xs text-imp-muted-foreground",
|
|
1237
|
+
children: user.email
|
|
1238
|
+
})]
|
|
1239
|
+
})]
|
|
1240
|
+
}, user.id))
|
|
1241
|
+
})]
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region src/frontend/components/access-control/ScopeDetailPanel.tsx
|
|
1246
|
+
function ScopeDetailPanel({ scopeId, scopeName, users, userMap, teams, isAdmin }) {
|
|
1247
|
+
const scopeQuery = useTeam(scopeId);
|
|
1248
|
+
const scopeAccessQuery = useScopeAccess(scopeId);
|
|
1249
|
+
const grantScopeAccess = useGrantScopeAccess(scopeId);
|
|
1250
|
+
const revokeScopeAccess = useRevokeScopeAccess(scopeId);
|
|
1251
|
+
const addTeamMember = useAddTeamMember(scopeId);
|
|
1252
|
+
const removeTeamMember = useRemoveTeamMember(scopeId);
|
|
1253
|
+
const deleteScope = useDeleteTeam();
|
|
1254
|
+
const [showGrantDialog, setShowGrantDialog] = (0, react.useState)(false);
|
|
1255
|
+
const [showMemberDialog, setShowMemberDialog] = (0, react.useState)(false);
|
|
1256
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = (0, react.useState)(false);
|
|
1257
|
+
const [permission, setPermission] = (0, react.useState)("viewer");
|
|
1258
|
+
const [grantUserIds, setGrantUserIds] = (0, react.useState)([]);
|
|
1259
|
+
const [memberUserIds, setMemberUserIds] = (0, react.useState)([]);
|
|
1260
|
+
const members = scopeQuery.data?.members ?? [];
|
|
1261
|
+
const scopeAccess = scopeAccessQuery.data?.access ?? [];
|
|
1262
|
+
const existingMemberIds = new Set(members.map((member) => member.userId));
|
|
1263
|
+
const existingScopeUserIds = new Set(scopeAccess.map((record) => record.userId).filter(Boolean));
|
|
1264
|
+
const teamRoleRecord = scopeAccess.find((record) => record.teamId === scopeId) ?? null;
|
|
1265
|
+
const childTeams = teams.filter((team) => team.parentId === scopeId);
|
|
1266
|
+
const isLoading = scopeQuery.isLoading || scopeAccessQuery.isLoading;
|
|
1267
|
+
const accessRows = (0, react.useMemo)(() => {
|
|
1268
|
+
const rows = [];
|
|
1269
|
+
for (const member of members) {
|
|
1270
|
+
const user = userMap.get(member.userId);
|
|
1271
|
+
rows.push({
|
|
1272
|
+
id: `member:${member.id}`,
|
|
1273
|
+
label: user?.name || user?.email || member.userId,
|
|
1274
|
+
kind: "user",
|
|
1275
|
+
permission: null,
|
|
1276
|
+
source: "Member",
|
|
1277
|
+
group: "Members",
|
|
1278
|
+
canRemove: isAdmin,
|
|
1279
|
+
onRemove: () => removeTeamMember.mutate(member.userId)
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
for (const record of scopeAccess) {
|
|
1283
|
+
if (record.teamId === scopeId) continue;
|
|
1284
|
+
const isUser = !!record.userId;
|
|
1285
|
+
const userId = record.userId ?? null;
|
|
1286
|
+
rows.push({
|
|
1287
|
+
id: `access:${record.id}`,
|
|
1288
|
+
label: isUser ? userMap.get(userId ?? "")?.name || userMap.get(userId ?? "")?.email || userId || "Unknown" : teams.find((team) => team.id === record.teamId)?.name || record.teamId || "Unknown",
|
|
1289
|
+
kind: isUser ? "user" : "team",
|
|
1290
|
+
permission: record.permission,
|
|
1291
|
+
source: "Direct grant",
|
|
1292
|
+
group: "Access Grants",
|
|
1293
|
+
canRemove: isAdmin,
|
|
1294
|
+
onPermissionChange: userId ? (permission) => grantScopeAccess.mutate({
|
|
1295
|
+
userId,
|
|
1296
|
+
permission
|
|
1297
|
+
}) : void 0,
|
|
1298
|
+
onRemove: () => revokeScopeAccess.mutate(record.id)
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
return rows;
|
|
1302
|
+
}, [
|
|
1303
|
+
members,
|
|
1304
|
+
scopeAccess,
|
|
1305
|
+
userMap,
|
|
1306
|
+
teams,
|
|
1307
|
+
isAdmin,
|
|
1308
|
+
removeTeamMember,
|
|
1309
|
+
grantScopeAccess,
|
|
1310
|
+
revokeScopeAccess
|
|
1311
|
+
]);
|
|
1312
|
+
const memberRows = accessRows.filter((row) => row.group === "Members");
|
|
1313
|
+
accessRows.filter((row) => row.group === "Access Grants");
|
|
1314
|
+
const scopePath = (0, react.useMemo)(() => {
|
|
1315
|
+
const path = [];
|
|
1316
|
+
let current = teams.find((team) => team.id === scopeId) ?? null;
|
|
1317
|
+
while (current) {
|
|
1318
|
+
path.unshift(current);
|
|
1319
|
+
current = current.parentId ? teams.find((team) => team.id === current?.parentId) ?? null : null;
|
|
1320
|
+
}
|
|
1321
|
+
return path;
|
|
1322
|
+
}, [teams, scopeId]);
|
|
1323
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1324
|
+
className: "flex flex-col h-full overflow-hidden",
|
|
1325
|
+
children: [
|
|
1326
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1327
|
+
className: "px-5 py-4 overflow-hidden border-b shrink-0 border-imp-border",
|
|
1328
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1329
|
+
className: "flex items-start gap-3",
|
|
1330
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1331
|
+
className: "flex items-center justify-center flex-none w-10 h-10 rounded-xl bg-imp-primary/10 text-imp-primary",
|
|
1332
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "w-5 h-5" })
|
|
1333
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1334
|
+
className: "flex-1 min-w-0",
|
|
1335
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1336
|
+
className: "flex items-center gap-2",
|
|
1337
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
1338
|
+
className: "flex-1 min-w-0 text-base font-semibold truncate",
|
|
1339
|
+
children: scopeName
|
|
1340
|
+
}), isAdmin ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1341
|
+
className: "flex items-center gap-1.5",
|
|
1342
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1343
|
+
type: "button",
|
|
1344
|
+
onClick: () => setShowDeleteConfirm(true),
|
|
1345
|
+
className: "flex items-center gap-1.5 rounded-md border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-500/10 dark:border-red-500/20 dark:text-red-400",
|
|
1346
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "w-4 h-4" }), " Delete team"]
|
|
1347
|
+
})
|
|
1348
|
+
}) : null]
|
|
1349
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1350
|
+
className: "flex flex-wrap items-center gap-1 mt-1 text-[11px] text-imp-muted-foreground",
|
|
1351
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Root" }), scopePath.map((team) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1352
|
+
className: "flex items-center gap-1",
|
|
1353
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronRight, { className: "w-3 h-3" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1354
|
+
className: team.id === scopeId ? "font-medium text-imp-foreground" : void 0,
|
|
1355
|
+
children: team.name
|
|
1356
|
+
})]
|
|
1357
|
+
}, team.id))]
|
|
1358
|
+
})]
|
|
1359
|
+
})]
|
|
1360
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1361
|
+
className: "flex items-center justify-between",
|
|
1362
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1363
|
+
className: "mt-3 text-xs text-imp-muted-foreground",
|
|
1364
|
+
children: "Access applies to all flows inside this team and its child teams."
|
|
1365
|
+
})
|
|
1366
|
+
})]
|
|
1367
|
+
}),
|
|
1368
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1369
|
+
className: "flex-1 px-5 py-4 overflow-x-hidden overflow-y-auto",
|
|
1370
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1371
|
+
className: "space-y-6",
|
|
1372
|
+
children: [
|
|
1373
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1374
|
+
className: "flex items-center justify-between gap-4",
|
|
1375
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1376
|
+
className: "min-w-0",
|
|
1377
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1378
|
+
className: "text-sm font-semibold text-imp-foreground",
|
|
1379
|
+
children: "Team Role on Child Flows"
|
|
1380
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1381
|
+
className: "mt-0.5 text-xs text-imp-muted-foreground",
|
|
1382
|
+
children: "Base role applied to every flow inside this team."
|
|
1383
|
+
})]
|
|
1384
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1385
|
+
className: "shrink-0",
|
|
1386
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionalRoleSelector, {
|
|
1387
|
+
value: teamRoleRecord?.permission ?? null,
|
|
1388
|
+
emptyLabel: "No team role",
|
|
1389
|
+
onChange: (nextPermission) => {
|
|
1390
|
+
if (nextPermission === null) {
|
|
1391
|
+
if (teamRoleRecord) revokeScopeAccess.mutate(teamRoleRecord.id);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
grantScopeAccess.mutate({
|
|
1395
|
+
teamId: scopeId,
|
|
1396
|
+
permission: nextPermission
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
})
|
|
1400
|
+
})]
|
|
1401
|
+
}) }),
|
|
1402
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1403
|
+
className: "space-y-2",
|
|
1404
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1405
|
+
className: "flex items-center justify-between",
|
|
1406
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1407
|
+
className: "text-sm font-semibold text-imp-foreground",
|
|
1408
|
+
children: "Members"
|
|
1409
|
+
}), isAdmin ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1410
|
+
type: "button",
|
|
1411
|
+
onClick: () => setShowMemberDialog(true),
|
|
1412
|
+
className: "flex items-center gap-1.5 rounded-md border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
|
|
1413
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "w-4 h-4" }), " Add Member"]
|
|
1414
|
+
}) : null]
|
|
1415
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1416
|
+
className: "overflow-hidden border rounded-xl border-imp-border bg-imp-background/40",
|
|
1417
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTable, {
|
|
1418
|
+
rows: memberRows,
|
|
1419
|
+
isLoading,
|
|
1420
|
+
emptyLabel: "No members"
|
|
1421
|
+
})
|
|
1422
|
+
})]
|
|
1423
|
+
}),
|
|
1424
|
+
childTeams.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1425
|
+
className: "space-y-2",
|
|
1426
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1427
|
+
className: "text-sm font-semibold text-imp-foreground",
|
|
1428
|
+
children: "Sub-teams"
|
|
1429
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1430
|
+
className: "space-y-2",
|
|
1431
|
+
children: childTeams.map((team) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1432
|
+
className: "flex items-center gap-2 px-3 py-2 border rounded-xl border-imp-border bg-imp-background/40",
|
|
1433
|
+
children: [
|
|
1434
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "w-3.5 h-3.5 text-imp-muted-foreground" }),
|
|
1435
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1436
|
+
className: "flex-1 min-w-0 text-sm font-medium truncate text-imp-foreground",
|
|
1437
|
+
children: team.name
|
|
1438
|
+
}),
|
|
1439
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1440
|
+
className: "text-[11px] text-imp-muted-foreground",
|
|
1441
|
+
children: "Child team"
|
|
1442
|
+
})
|
|
1443
|
+
]
|
|
1444
|
+
}, team.id))
|
|
1445
|
+
})]
|
|
1446
|
+
}) : null
|
|
1447
|
+
]
|
|
1448
|
+
})
|
|
1449
|
+
}),
|
|
1450
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FormDialog, {
|
|
1451
|
+
open: showGrantDialog,
|
|
1452
|
+
onClose: () => setShowGrantDialog(false),
|
|
1453
|
+
title: "Grant Scope Access",
|
|
1454
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1455
|
+
className: "flex flex-col gap-3",
|
|
1456
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemberCombobox, {
|
|
1457
|
+
users,
|
|
1458
|
+
excludeIds: existingScopeUserIds,
|
|
1459
|
+
selectedUserIds: grantUserIds,
|
|
1460
|
+
onSelect: setGrantUserIds
|
|
1461
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1462
|
+
className: "flex items-center gap-2",
|
|
1463
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoleSelector, {
|
|
1464
|
+
value: permission,
|
|
1465
|
+
onChange: setPermission
|
|
1466
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1467
|
+
type: "button",
|
|
1468
|
+
onClick: () => {
|
|
1469
|
+
if (grantUserIds.length === 0) return;
|
|
1470
|
+
for (const userId of grantUserIds) grantScopeAccess.mutate({
|
|
1471
|
+
userId,
|
|
1472
|
+
permission
|
|
1473
|
+
});
|
|
1474
|
+
setGrantUserIds([]);
|
|
1475
|
+
setShowGrantDialog(false);
|
|
1476
|
+
},
|
|
1477
|
+
disabled: grantUserIds.length === 0 || grantScopeAccess.isPending,
|
|
1478
|
+
className: "ml-auto rounded-md bg-imp-primary px-4 py-2 text-sm font-semibold text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
1479
|
+
children: "Grant"
|
|
1480
|
+
})]
|
|
1481
|
+
})]
|
|
1482
|
+
})
|
|
1483
|
+
}),
|
|
1484
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FormDialog, {
|
|
1485
|
+
open: showMemberDialog,
|
|
1486
|
+
onClose: () => setShowMemberDialog(false),
|
|
1487
|
+
title: "Add Team Member",
|
|
1488
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1489
|
+
className: "flex flex-col gap-3",
|
|
1490
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemberCombobox, {
|
|
1491
|
+
users,
|
|
1492
|
+
excludeIds: existingMemberIds,
|
|
1493
|
+
selectedUserIds: memberUserIds,
|
|
1494
|
+
onSelect: setMemberUserIds
|
|
1495
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1496
|
+
type: "button",
|
|
1497
|
+
onClick: () => {
|
|
1498
|
+
if (memberUserIds.length === 0) return;
|
|
1499
|
+
for (const userId of memberUserIds) addTeamMember.mutate({ userId });
|
|
1500
|
+
setMemberUserIds([]);
|
|
1501
|
+
setShowMemberDialog(false);
|
|
1502
|
+
},
|
|
1503
|
+
disabled: memberUserIds.length === 0 || addTeamMember.isPending,
|
|
1504
|
+
className: "rounded-md bg-imp-primary px-4 py-2 text-sm font-semibold text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
1505
|
+
children: "Add"
|
|
1506
|
+
})]
|
|
1507
|
+
})
|
|
1508
|
+
}),
|
|
1509
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.Dialog, {
|
|
1510
|
+
open: showDeleteConfirm,
|
|
1511
|
+
onOpenChange: (open) => !open && setShowDeleteConfirm(false),
|
|
1512
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogContent, {
|
|
1513
|
+
className: "max-w-sm border-imp-border bg-imp-background text-imp-foreground sm:max-w-sm",
|
|
1514
|
+
children: [
|
|
1515
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogHeader, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.DialogTitle, {
|
|
1516
|
+
className: "text-sm font-semibold",
|
|
1517
|
+
children: "Delete team"
|
|
1518
|
+
}) }),
|
|
1519
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1520
|
+
className: "text-sm text-imp-muted-foreground",
|
|
1521
|
+
children: [
|
|
1522
|
+
"Are you sure you want to delete",
|
|
1523
|
+
" ",
|
|
1524
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", {
|
|
1525
|
+
className: "text-imp-foreground",
|
|
1526
|
+
children: scopeName
|
|
1527
|
+
}),
|
|
1528
|
+
"? Flows directly inside this team will move to the parent team when one exists, and this team's access grants will be removed."
|
|
1529
|
+
]
|
|
1530
|
+
}),
|
|
1531
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.DialogFooter, {
|
|
1532
|
+
className: "gap-2 sm:gap-0",
|
|
1533
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1534
|
+
type: "button",
|
|
1535
|
+
onClick: () => setShowDeleteConfirm(false),
|
|
1536
|
+
className: "rounded-md border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground hover:text-imp-foreground",
|
|
1537
|
+
children: "Cancel"
|
|
1538
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1539
|
+
type: "button",
|
|
1540
|
+
onClick: () => {
|
|
1541
|
+
deleteScope.mutate(scopeId);
|
|
1542
|
+
setShowDeleteConfirm(false);
|
|
1543
|
+
},
|
|
1544
|
+
className: "rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700",
|
|
1545
|
+
children: "Delete"
|
|
1546
|
+
})]
|
|
1547
|
+
})
|
|
1548
|
+
]
|
|
1549
|
+
})
|
|
1550
|
+
})
|
|
1551
|
+
]
|
|
1552
|
+
});
|
|
1553
|
+
}
|
|
1554
|
+
//#endregion
|
|
1555
|
+
//#region src/frontend/components/access-control/PrincipalCombobox.tsx
|
|
1556
|
+
function PrincipalCombobox({ users, teams, excludeUserIds, excludeTeamIds, selections, onSelect }) {
|
|
1557
|
+
const [query, setQuery] = (0, react.useState)("");
|
|
1558
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
1559
|
+
const containerRef = (0, react.useRef)(null);
|
|
1560
|
+
const selectedUserIds = new Set(selections.filter((s) => s.type === "user").map((s) => s.id));
|
|
1561
|
+
const selectedTeamIds = new Set(selections.filter((s) => s.type === "team").map((s) => s.id));
|
|
1562
|
+
const filteredUsers = users.filter((user) => {
|
|
1563
|
+
if (excludeUserIds.has(user.id)) return false;
|
|
1564
|
+
if (selectedUserIds.has(user.id)) return false;
|
|
1565
|
+
return `${user.name || ""} ${user.email || ""} ${user.id}`.toLowerCase().includes(query.toLowerCase());
|
|
1566
|
+
});
|
|
1567
|
+
const filteredTeams = teams.filter((team) => {
|
|
1568
|
+
if (excludeTeamIds.has(team.id)) return false;
|
|
1569
|
+
if (selectedTeamIds.has(team.id)) return false;
|
|
1570
|
+
return `${team.name} ${team.description || ""}`.toLowerCase().includes(query.toLowerCase());
|
|
1571
|
+
});
|
|
1572
|
+
const toggle = (sel) => {
|
|
1573
|
+
onSelect(selections.some((s) => s.type === sel.type && s.id === sel.id) ? selections.filter((s) => !(s.type === sel.type && s.id === sel.id)) : [...selections, sel]);
|
|
1574
|
+
};
|
|
1575
|
+
const getLabel = (sel) => {
|
|
1576
|
+
if (sel.type === "user") {
|
|
1577
|
+
const user = users.find((u) => u.id === sel.id);
|
|
1578
|
+
return user?.name || user?.email || sel.id;
|
|
1579
|
+
}
|
|
1580
|
+
return teams.find((t) => t.id === sel.id)?.name || sel.id;
|
|
1581
|
+
};
|
|
1582
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1583
|
+
ref: containerRef,
|
|
1584
|
+
className: "relative",
|
|
1585
|
+
onBlur: (e) => {
|
|
1586
|
+
if (!containerRef.current?.contains(e.relatedTarget)) setOpen(false);
|
|
1587
|
+
},
|
|
1588
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1589
|
+
className: "flex min-h-[2.5rem] w-full cursor-text flex-wrap gap-1.5 rounded-md border border-imp-border bg-imp-background px-2 py-1.5 focus-within:border-imp-primary/50",
|
|
1590
|
+
onClick: () => setOpen(true),
|
|
1591
|
+
children: [selections.map((sel) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1592
|
+
className: "inline-flex items-center gap-1 rounded-md border border-imp-border bg-imp-muted px-2 py-0.5 text-xs font-medium text-imp-foreground",
|
|
1593
|
+
children: [
|
|
1594
|
+
sel.type === "user" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-3 w-3 shrink-0 text-imp-muted-foreground" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-3 w-3 shrink-0 text-imp-muted-foreground" }),
|
|
1595
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1596
|
+
className: "max-w-[140px] truncate",
|
|
1597
|
+
children: getLabel(sel)
|
|
1598
|
+
}),
|
|
1599
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1600
|
+
type: "button",
|
|
1601
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1602
|
+
onClick: (e) => {
|
|
1603
|
+
e.stopPropagation();
|
|
1604
|
+
toggle(sel);
|
|
1605
|
+
},
|
|
1606
|
+
className: "ml-0.5 text-imp-muted-foreground hover:text-imp-foreground",
|
|
1607
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "h-3 w-3" })
|
|
1608
|
+
})
|
|
1609
|
+
]
|
|
1610
|
+
}, `${sel.type}:${sel.id}`)), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1611
|
+
value: query,
|
|
1612
|
+
onChange: (e) => {
|
|
1613
|
+
setQuery(e.target.value);
|
|
1614
|
+
setOpen(true);
|
|
1615
|
+
},
|
|
1616
|
+
onFocus: () => setOpen(true),
|
|
1617
|
+
placeholder: selections.length === 0 ? "Search users or teams…" : "Add more…",
|
|
1618
|
+
className: "min-w-[120px] flex-1 bg-transparent text-sm outline-none placeholder:text-imp-muted-foreground"
|
|
1619
|
+
})]
|
|
1620
|
+
}), open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1621
|
+
className: "absolute left-0 z-20 mt-1 w-full overflow-y-auto rounded-md border border-imp-border bg-imp-background shadow-lg top-full max-h-52",
|
|
1622
|
+
children: filteredTeams.length === 0 && filteredUsers.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1623
|
+
className: "px-3 py-2 text-xs text-imp-muted-foreground",
|
|
1624
|
+
children: query ? "No matches." : "No more users or teams to add."
|
|
1625
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [filteredTeams.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1626
|
+
className: "px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-imp-muted-foreground",
|
|
1627
|
+
children: "Teams"
|
|
1628
|
+
}), filteredTeams.map((team) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1629
|
+
type: "button",
|
|
1630
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1631
|
+
onClick: () => {
|
|
1632
|
+
toggle({
|
|
1633
|
+
type: "team",
|
|
1634
|
+
id: team.id
|
|
1635
|
+
});
|
|
1636
|
+
setQuery("");
|
|
1637
|
+
},
|
|
1638
|
+
className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-imp-muted/50",
|
|
1639
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-4 w-4 shrink-0 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1640
|
+
className: "truncate font-medium",
|
|
1641
|
+
children: team.name
|
|
1642
|
+
})]
|
|
1643
|
+
}, team.id))] }), filteredUsers.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1644
|
+
className: "px-3 py-1 text-[10px] font-medium uppercase tracking-wider text-imp-muted-foreground",
|
|
1645
|
+
children: "Users"
|
|
1646
|
+
}), filteredUsers.map((user) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1647
|
+
type: "button",
|
|
1648
|
+
onMouseDown: (e) => e.preventDefault(),
|
|
1649
|
+
onClick: () => {
|
|
1650
|
+
toggle({
|
|
1651
|
+
type: "user",
|
|
1652
|
+
id: user.id
|
|
1653
|
+
});
|
|
1654
|
+
setQuery("");
|
|
1655
|
+
},
|
|
1656
|
+
className: "flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-imp-muted/50",
|
|
1657
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-4 w-4 shrink-0 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1658
|
+
className: "min-w-0 flex-1",
|
|
1659
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1660
|
+
className: "truncate font-medium",
|
|
1661
|
+
children: user.name || user.email || user.id
|
|
1662
|
+
}), user.name && user.email && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1663
|
+
className: "truncate text-xs text-imp-muted-foreground",
|
|
1664
|
+
children: user.email
|
|
1665
|
+
})]
|
|
1666
|
+
})]
|
|
1667
|
+
}, user.id))] })] })
|
|
1668
|
+
})]
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
//#endregion
|
|
1672
|
+
//#region src/frontend/components/access-control/FlowDetailPanel.tsx
|
|
1673
|
+
function FlowDetailPanel({ flowId, flowName, users, userMap, teams, isAdmin }) {
|
|
1674
|
+
const effectiveFlowAccessQuery = useEffectiveFlowAccess(flowId);
|
|
1675
|
+
const grantFlowAccess = useGrantFlowAccess(flowId);
|
|
1676
|
+
const revokeFlowAccess = useRevokeFlowAccess(flowId);
|
|
1677
|
+
const [showGrantDialog, setShowGrantDialog] = (0, react.useState)(false);
|
|
1678
|
+
const [principalSelections, setPrincipalSelections] = (0, react.useState)([]);
|
|
1679
|
+
const [permission, setPermission] = (0, react.useState)("viewer");
|
|
1680
|
+
const effectiveRecords = effectiveFlowAccessQuery.data?.records ?? [];
|
|
1681
|
+
const existingDirectUserIds = new Set(effectiveRecords.filter((record) => record.source === "direct" && record.userId).map((record) => record.userId));
|
|
1682
|
+
const existingDirectTeamIds = new Set(effectiveRecords.filter((record) => record.source === "direct" && record.teamId).map((record) => record.teamId));
|
|
1683
|
+
const owningTeam = teams.find((team) => team.id === effectiveFlowAccessQuery.data?.scopeId) ?? null;
|
|
1684
|
+
const scopePath = (0, react.useMemo)(() => {
|
|
1685
|
+
const path = [];
|
|
1686
|
+
let current = owningTeam;
|
|
1687
|
+
while (current) {
|
|
1688
|
+
path.unshift(current);
|
|
1689
|
+
current = current.parentId ? teams.find((team) => team.id === current?.parentId) ?? null : null;
|
|
1690
|
+
}
|
|
1691
|
+
return path;
|
|
1692
|
+
}, [owningTeam, teams]);
|
|
1693
|
+
const accessRows = (0, react.useMemo)(() => {
|
|
1694
|
+
const rows = effectiveRecords.map((record) => {
|
|
1695
|
+
const isUser = !!record.userId;
|
|
1696
|
+
const userId = record.userId ?? null;
|
|
1697
|
+
const teamId = record.teamId ?? null;
|
|
1698
|
+
return {
|
|
1699
|
+
id: `${record.source}:${record.id}`,
|
|
1700
|
+
label: isUser ? userMap.get(userId ?? "")?.name || userMap.get(userId ?? "")?.email || userId || "Unknown" : teams.find((team) => team.id === teamId)?.name || teamId || "Unknown",
|
|
1701
|
+
kind: isUser ? "user" : "team",
|
|
1702
|
+
permission: record.permission,
|
|
1703
|
+
source: record.source === "direct" ? "Direct grant" : `via ${record.scopeName || "team"}`,
|
|
1704
|
+
group: record.source === "direct" ? "Direct" : "Inherited",
|
|
1705
|
+
canRemove: record.source === "direct" && isAdmin,
|
|
1706
|
+
onPermissionChange: record.source === "direct" ? (permission) => grantFlowAccess.mutate({
|
|
1707
|
+
...userId ? { userId } : teamId ? { teamId } : {},
|
|
1708
|
+
permission
|
|
1709
|
+
}) : void 0,
|
|
1710
|
+
onRemove: () => revokeFlowAccess.mutate(record.id)
|
|
1711
|
+
};
|
|
1712
|
+
});
|
|
1713
|
+
rows.sort((a, b) => {
|
|
1714
|
+
const aIsDirect = a.source === "Direct grant" ? 1 : 0;
|
|
1715
|
+
return (b.source === "Direct grant" ? 1 : 0) - aIsDirect;
|
|
1716
|
+
});
|
|
1717
|
+
return rows;
|
|
1718
|
+
}, [
|
|
1719
|
+
effectiveRecords,
|
|
1720
|
+
userMap,
|
|
1721
|
+
teams,
|
|
1722
|
+
isAdmin,
|
|
1723
|
+
grantFlowAccess,
|
|
1724
|
+
revokeFlowAccess
|
|
1725
|
+
]);
|
|
1726
|
+
const directRows = accessRows.filter((row) => row.group === "Direct");
|
|
1727
|
+
const inheritedRows = accessRows.filter((row) => row.group === "Inherited");
|
|
1728
|
+
const openFlow = () => {
|
|
1729
|
+
const path = window.location.pathname;
|
|
1730
|
+
const accessIdx = path.lastIndexOf("/access");
|
|
1731
|
+
const basePath = accessIdx >= 0 ? path.slice(0, accessIdx) : path;
|
|
1732
|
+
window.open(`${basePath}/flow/${flowId}`, "_blank");
|
|
1733
|
+
};
|
|
1734
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1735
|
+
className: "flex flex-col h-full overflow-hidden",
|
|
1736
|
+
children: [
|
|
1737
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1738
|
+
className: "px-5 py-4 overflow-hidden border-b shrink-0 border-imp-border",
|
|
1739
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1740
|
+
className: "flex items-start gap-3",
|
|
1741
|
+
children: [
|
|
1742
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1743
|
+
className: "flex items-center justify-center flex-none w-10 h-10 rounded-xl bg-imp-primary/10 text-imp-primary",
|
|
1744
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Workflow, { className: "w-5 h-5" })
|
|
1745
|
+
}),
|
|
1746
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1747
|
+
className: "flex-1 min-w-0",
|
|
1748
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1749
|
+
className: "flex items-center gap-2",
|
|
1750
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h2", {
|
|
1751
|
+
className: "flex-1 min-w-0 text-base font-semibold truncate",
|
|
1752
|
+
children: flowName
|
|
1753
|
+
})
|
|
1754
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1755
|
+
className: "mt-1 flex flex-wrap items-center gap-1 text-[11px] text-imp-muted-foreground",
|
|
1756
|
+
children: [
|
|
1757
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "Root" }),
|
|
1758
|
+
scopePath.map((team) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1759
|
+
className: "flex items-center gap-1",
|
|
1760
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronRight, { className: "w-3 h-3" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: team.name })]
|
|
1761
|
+
}, team.id)),
|
|
1762
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1763
|
+
className: "flex items-center gap-1",
|
|
1764
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronRight, { className: "w-3 h-3" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1765
|
+
className: "font-medium text-imp-foreground",
|
|
1766
|
+
children: flowName
|
|
1767
|
+
})]
|
|
1768
|
+
})
|
|
1769
|
+
]
|
|
1770
|
+
})]
|
|
1771
|
+
}),
|
|
1772
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1773
|
+
className: "flex items-center gap-1.5",
|
|
1774
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1775
|
+
type: "button",
|
|
1776
|
+
onClick: openFlow,
|
|
1777
|
+
className: "flex items-center gap-1.5 rounded-md border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
|
|
1778
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ExternalLink, { className: "w-4 h-4" }), " Open in Editor"]
|
|
1779
|
+
})
|
|
1780
|
+
})
|
|
1781
|
+
]
|
|
1782
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1783
|
+
className: "mt-3 text-xs text-imp-muted-foreground",
|
|
1784
|
+
children: "All principals with access and how they got it."
|
|
1785
|
+
})]
|
|
1786
|
+
}),
|
|
1787
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1788
|
+
className: "flex-1 px-5 py-4 overflow-x-hidden overflow-y-auto",
|
|
1789
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1790
|
+
className: "space-y-6",
|
|
1791
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1792
|
+
className: "space-y-2",
|
|
1793
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1794
|
+
className: "flex items-center justify-between",
|
|
1795
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1796
|
+
className: "text-sm font-semibold text-imp-foreground",
|
|
1797
|
+
children: "Direct Access"
|
|
1798
|
+
}), isAdmin ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
1799
|
+
type: "button",
|
|
1800
|
+
onClick: () => setShowGrantDialog(true),
|
|
1801
|
+
className: "flex items-center gap-1.5 rounded-md border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-muted-foreground transition-colors hover:border-imp-primary/50 hover:text-imp-foreground",
|
|
1802
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "w-4 h-4" }), " Grant Access"]
|
|
1803
|
+
}) : null]
|
|
1804
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1805
|
+
className: "overflow-hidden border rounded-xl border-imp-border bg-imp-background/40",
|
|
1806
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTable, {
|
|
1807
|
+
rows: directRows,
|
|
1808
|
+
isLoading: effectiveFlowAccessQuery.isLoading,
|
|
1809
|
+
emptyLabel: "No direct access assigned"
|
|
1810
|
+
})
|
|
1811
|
+
})]
|
|
1812
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
1813
|
+
className: "space-y-2",
|
|
1814
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
1815
|
+
className: "text-sm font-semibold text-imp-foreground",
|
|
1816
|
+
children: "Inherited Access"
|
|
1817
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1818
|
+
className: "overflow-hidden border rounded-xl border-imp-border bg-imp-background/40",
|
|
1819
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AccessTable, {
|
|
1820
|
+
rows: inheritedRows,
|
|
1821
|
+
isLoading: effectiveFlowAccessQuery.isLoading,
|
|
1822
|
+
emptyLabel: "No inherited access"
|
|
1823
|
+
})
|
|
1824
|
+
})]
|
|
1825
|
+
})]
|
|
1826
|
+
})
|
|
1827
|
+
}),
|
|
1828
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FormDialog, {
|
|
1829
|
+
open: showGrantDialog,
|
|
1830
|
+
onClose: () => setShowGrantDialog(false),
|
|
1831
|
+
title: "Grant Flow Access",
|
|
1832
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1833
|
+
className: "flex flex-col gap-3",
|
|
1834
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(PrincipalCombobox, {
|
|
1835
|
+
users,
|
|
1836
|
+
teams,
|
|
1837
|
+
excludeUserIds: existingDirectUserIds,
|
|
1838
|
+
excludeTeamIds: existingDirectTeamIds,
|
|
1839
|
+
selections: principalSelections,
|
|
1840
|
+
onSelect: setPrincipalSelections
|
|
1841
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1842
|
+
className: "flex items-center gap-2",
|
|
1843
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(RoleSelector, {
|
|
1844
|
+
value: permission,
|
|
1845
|
+
onChange: setPermission
|
|
1846
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1847
|
+
type: "button",
|
|
1848
|
+
onClick: () => {
|
|
1849
|
+
if (principalSelections.length === 0) return;
|
|
1850
|
+
for (const sel of principalSelections) grantFlowAccess.mutate({
|
|
1851
|
+
...sel.type === "user" ? { userId: sel.id } : { teamId: sel.id },
|
|
1852
|
+
permission
|
|
1853
|
+
});
|
|
1854
|
+
setPrincipalSelections([]);
|
|
1855
|
+
setShowGrantDialog(false);
|
|
1856
|
+
},
|
|
1857
|
+
disabled: principalSelections.length === 0 || grantFlowAccess.isPending,
|
|
1858
|
+
className: "ml-auto rounded-md bg-imp-primary px-4 py-2 text-sm font-semibold text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
1859
|
+
children: "Grant"
|
|
1860
|
+
})]
|
|
1861
|
+
})]
|
|
1862
|
+
})
|
|
1863
|
+
})
|
|
1864
|
+
]
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
//#endregion
|
|
1868
|
+
//#region src/frontend/components/access-control/MoveConfirmationDialog.tsx
|
|
1869
|
+
function MoveConfirmationDialog({ pendingMove, preview, error, onClose }) {
|
|
1870
|
+
const moveFlow = useMoveFlow(pendingMove?.type === "flow" ? pendingMove.id : "");
|
|
1871
|
+
const reparentScope = useUpdateTeam(pendingMove?.type === "scope" ? pendingMove.id : "");
|
|
1872
|
+
const isSubmitting = moveFlow.isPending || reparentScope.isPending;
|
|
1873
|
+
const handleConfirm = async () => {
|
|
1874
|
+
if (!pendingMove) return;
|
|
1875
|
+
if (pendingMove.type === "flow") await moveFlow.mutateAsync(pendingMove.targetScopeId);
|
|
1876
|
+
else await reparentScope.mutateAsync({ parentId: pendingMove.targetScopeId });
|
|
1877
|
+
onClose();
|
|
1878
|
+
};
|
|
1879
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1880
|
+
className: "fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/35",
|
|
1881
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1882
|
+
className: "w-full max-w-lg rounded-xl border border-imp-border bg-imp-background shadow-[var(--imp-shadow-floating)]",
|
|
1883
|
+
children: [
|
|
1884
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1885
|
+
className: "flex items-center justify-between px-5 py-4 border-b border-imp-border",
|
|
1886
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1887
|
+
className: "flex items-center gap-2 text-sm font-semibold text-imp-foreground",
|
|
1888
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Move, { className: "w-4 h-4 text-imp-primary" }), "Confirm Move"]
|
|
1889
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1890
|
+
className: "mt-1 text-xs text-imp-muted-foreground",
|
|
1891
|
+
children: "Review the access impact before applying this hierarchy change."
|
|
1892
|
+
})] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1893
|
+
type: "button",
|
|
1894
|
+
onClick: onClose,
|
|
1895
|
+
className: "p-1 rounded text-imp-muted-foreground hover:text-imp-foreground",
|
|
1896
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "w-4 h-4" })
|
|
1897
|
+
})]
|
|
1898
|
+
}),
|
|
1899
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1900
|
+
className: "px-5 py-4 space-y-4 text-sm",
|
|
1901
|
+
children: error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1902
|
+
className: "px-3 py-2 text-red-700 border border-red-300 rounded-lg bg-red-50 dark:border-red-900 dark:bg-red-950/20 dark:text-red-300",
|
|
1903
|
+
children: error
|
|
1904
|
+
}) : !preview ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1905
|
+
className: "px-3 py-4 text-center border rounded-lg border-imp-border bg-imp-card text-imp-muted-foreground",
|
|
1906
|
+
children: "Calculating impact…"
|
|
1907
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1908
|
+
className: "px-3 py-3 border rounded-lg border-imp-border bg-imp-card",
|
|
1909
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1910
|
+
className: "font-medium text-imp-foreground",
|
|
1911
|
+
children: [
|
|
1912
|
+
"Move \"",
|
|
1913
|
+
preview.item.name,
|
|
1914
|
+
"\" to",
|
|
1915
|
+
" ",
|
|
1916
|
+
preview.target.path.length > 0 ? preview.target.path.join(" / ") : "Top level"
|
|
1917
|
+
]
|
|
1918
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1919
|
+
className: "mt-1 text-xs text-imp-muted-foreground",
|
|
1920
|
+
children: [
|
|
1921
|
+
preview.affectedFlows,
|
|
1922
|
+
" flow",
|
|
1923
|
+
preview.affectedFlows === 1 ? "" : "s",
|
|
1924
|
+
" affected"
|
|
1925
|
+
]
|
|
1926
|
+
})]
|
|
1927
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
|
|
1928
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1929
|
+
className: "mb-2 text-xs font-medium tracking-wider uppercase text-imp-muted-foreground",
|
|
1930
|
+
children: "Access Changes"
|
|
1931
|
+
}),
|
|
1932
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1933
|
+
className: "p-2 space-y-1 overflow-y-auto border rounded-lg max-h-56 border-imp-border bg-imp-card",
|
|
1934
|
+
children: preview.accessChanges.gained.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1935
|
+
className: "px-2 py-2 text-xs text-imp-muted-foreground",
|
|
1936
|
+
children: "No new principals gain access from this move."
|
|
1937
|
+
}) : preview.accessChanges.gained.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1938
|
+
className: "flex items-center gap-2 rounded-md px-2 py-1.5 text-xs",
|
|
1939
|
+
children: [
|
|
1940
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1941
|
+
className: "flex items-center justify-center w-6 h-6 rounded-full shrink-0 bg-imp-primary/10",
|
|
1942
|
+
children: entry.userId ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "w-3 h-3 text-imp-primary" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "w-3 h-3 text-imp-primary" })
|
|
1943
|
+
}),
|
|
1944
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1945
|
+
className: "flex-1 min-w-0",
|
|
1946
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1947
|
+
className: "font-medium truncate",
|
|
1948
|
+
children: entry.name
|
|
1949
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1950
|
+
className: "truncate text-[10px] text-imp-muted-foreground",
|
|
1951
|
+
children: entry.source
|
|
1952
|
+
})]
|
|
1953
|
+
}),
|
|
1954
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1955
|
+
className: `rounded px-1.5 py-0.5 text-[10px] font-medium ${getPermissionBadgeClasses(entry.permission)}`,
|
|
1956
|
+
children: entry.permission
|
|
1957
|
+
})
|
|
1958
|
+
]
|
|
1959
|
+
}, `${entry.source}-${entry.name}`))
|
|
1960
|
+
}),
|
|
1961
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
|
|
1962
|
+
className: "mt-2 text-xs text-imp-muted-foreground",
|
|
1963
|
+
children: [
|
|
1964
|
+
preview.accessChanges.unchanged,
|
|
1965
|
+
" access grant",
|
|
1966
|
+
preview.accessChanges.unchanged === 1 ? "" : "s",
|
|
1967
|
+
" remain unchanged."
|
|
1968
|
+
]
|
|
1969
|
+
})
|
|
1970
|
+
] })] })
|
|
1971
|
+
}),
|
|
1972
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1973
|
+
className: "flex items-center justify-end gap-2 px-5 py-4 border-t border-imp-border",
|
|
1974
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1975
|
+
type: "button",
|
|
1976
|
+
onClick: onClose,
|
|
1977
|
+
className: "rounded border border-imp-border px-3 py-1.5 text-sm text-imp-muted-foreground hover:text-imp-foreground",
|
|
1978
|
+
children: "Cancel"
|
|
1979
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1980
|
+
type: "button",
|
|
1981
|
+
onClick: handleConfirm,
|
|
1982
|
+
disabled: !preview || isSubmitting || !!error,
|
|
1983
|
+
className: "rounded bg-imp-primary px-3 py-1.5 text-sm font-medium text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
1984
|
+
children: isSubmitting ? "Applying…" : `Move ${pendingMove?.type === "scope" ? "Team" : "Flow"}`
|
|
1985
|
+
})]
|
|
1986
|
+
})
|
|
1987
|
+
]
|
|
1988
|
+
})
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
//#endregion
|
|
1992
|
+
//#region src/frontend/stores/accessControlStore.ts
|
|
1993
|
+
const useAccessControlStore = (0, zustand.create)()((set) => ({
|
|
1994
|
+
selected: null,
|
|
1995
|
+
setSelected: (item) => set({ selected: item }),
|
|
1996
|
+
expandedNodes: /* @__PURE__ */ new Set(),
|
|
1997
|
+
setExpandedNodes: (nodes) => set({ expandedNodes: nodes }),
|
|
1998
|
+
toggleNode: (scopeId) => set((state) => {
|
|
1999
|
+
const next = new Set(state.expandedNodes);
|
|
2000
|
+
if (next.has(scopeId)) next.delete(scopeId);
|
|
2001
|
+
else next.add(scopeId);
|
|
2002
|
+
return { expandedNodes: next };
|
|
2003
|
+
}),
|
|
2004
|
+
expandAll: (ids) => set({ expandedNodes: new Set(ids) }),
|
|
2005
|
+
search: "",
|
|
2006
|
+
setSearch: (q) => set({ search: q }),
|
|
2007
|
+
showNewTeam: false,
|
|
2008
|
+
setShowNewTeam: (show) => set({ showNewTeam: show }),
|
|
2009
|
+
newTeamName: "",
|
|
2010
|
+
setNewTeamName: (name) => set({ newTeamName: name }),
|
|
2011
|
+
draggedItem: null,
|
|
2012
|
+
dropTarget: null,
|
|
2013
|
+
startDrag: (item) => set({
|
|
2014
|
+
draggedItem: item,
|
|
2015
|
+
dropTarget: null
|
|
2016
|
+
}),
|
|
2017
|
+
endDrag: () => set({
|
|
2018
|
+
draggedItem: null,
|
|
2019
|
+
dropTarget: null
|
|
2020
|
+
}),
|
|
2021
|
+
setDropTarget: (targetId) => set({ dropTarget: targetId }),
|
|
2022
|
+
pendingMove: null,
|
|
2023
|
+
movePreview: null,
|
|
2024
|
+
moveError: null,
|
|
2025
|
+
setPendingMove: (move) => set({ pendingMove: move }),
|
|
2026
|
+
setMovePreview: (preview) => set({ movePreview: preview }),
|
|
2027
|
+
setMoveError: (error) => set({ moveError: error }),
|
|
2028
|
+
clearMove: () => set({
|
|
2029
|
+
pendingMove: null,
|
|
2030
|
+
movePreview: null,
|
|
2031
|
+
moveError: null
|
|
2032
|
+
})
|
|
2033
|
+
}));
|
|
2034
|
+
function collectScopeIds(scopes) {
|
|
2035
|
+
return scopes.flatMap((scope) => [scope.id, ...collectScopeIds(scope.children)]);
|
|
2036
|
+
}
|
|
2037
|
+
function findScopeNode$1(scopes, scopeId) {
|
|
2038
|
+
for (const scope of scopes) {
|
|
2039
|
+
if (scope.id === scopeId) return scope;
|
|
2040
|
+
const child = findScopeNode$1(scope.children, scopeId);
|
|
2041
|
+
if (child) return child;
|
|
2042
|
+
}
|
|
2043
|
+
return null;
|
|
2044
|
+
}
|
|
2045
|
+
function scopeContains(scope, potentialChildId) {
|
|
2046
|
+
for (const child of scope.children) if (child.id === potentialChildId || scopeContains(child, potentialChildId)) return true;
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
function isDescendantScope(scopes, draggedScopeId, potentialTargetId) {
|
|
2050
|
+
const draggedScope = findScopeNode$1(scopes, draggedScopeId);
|
|
2051
|
+
if (!draggedScope) return false;
|
|
2052
|
+
return scopeContains(draggedScope, potentialTargetId);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Returns true if dropping `dragged` onto `targetId` is a valid move.
|
|
2056
|
+
* targetId of null means "move to root".
|
|
2057
|
+
*/
|
|
2058
|
+
function canDropOnTarget(dragged, targetId, allScopes) {
|
|
2059
|
+
if (dragged.type === "scope" && dragged.id === targetId) return false;
|
|
2060
|
+
if (dragged.type === "scope" && targetId !== null && isDescendantScope(allScopes, dragged.id, targetId)) return false;
|
|
2061
|
+
if (dragged.parentId === targetId) return false;
|
|
2062
|
+
return true;
|
|
2063
|
+
}
|
|
2064
|
+
//#endregion
|
|
2065
|
+
//#region src/frontend/components/access-control/AccessControlPage.tsx
|
|
2066
|
+
function findScopeNode(scopes, scopeId) {
|
|
2067
|
+
for (const scope of scopes) {
|
|
2068
|
+
if (scope.id === scopeId) return scope;
|
|
2069
|
+
const child = findScopeNode(scope.children, scopeId);
|
|
2070
|
+
if (child) return child;
|
|
2071
|
+
}
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
function filterScopes(scopes, query) {
|
|
2075
|
+
return scopes.reduce((acc, scope) => {
|
|
2076
|
+
const matchesScope = scope.name.toLowerCase().includes(query) || (scope.description?.toLowerCase().includes(query) ?? false);
|
|
2077
|
+
const matchingChildren = filterScopes(scope.children, query);
|
|
2078
|
+
const matchingFlows = scope.flows.filter((flow) => flow.name.toLowerCase().includes(query));
|
|
2079
|
+
if (matchesScope || matchingChildren.length > 0 || matchingFlows.length > 0) acc.push({
|
|
2080
|
+
...scope,
|
|
2081
|
+
children: matchingChildren,
|
|
2082
|
+
flows: matchingFlows
|
|
2083
|
+
});
|
|
2084
|
+
return acc;
|
|
2085
|
+
}, []);
|
|
2086
|
+
}
|
|
2087
|
+
function findSelectedName(scopes, flows, selected) {
|
|
2088
|
+
if (!selected) return null;
|
|
2089
|
+
if (selected.kind === "team") return findScopeNode(scopes, selected.id)?.name ?? selected.name;
|
|
2090
|
+
const stack = [...flows];
|
|
2091
|
+
const scopeStack = [...scopes];
|
|
2092
|
+
while (scopeStack.length > 0) {
|
|
2093
|
+
const current = scopeStack.pop();
|
|
2094
|
+
if (!current) break;
|
|
2095
|
+
stack.push(...current.flows);
|
|
2096
|
+
scopeStack.push(...current.children);
|
|
2097
|
+
}
|
|
2098
|
+
return stack.find((flow) => flow.id === selected.id)?.name ?? selected.name;
|
|
2099
|
+
}
|
|
2100
|
+
function FlowTreeRow({ flow, isSelected, onSelect }) {
|
|
2101
|
+
const { draggedItem, startDrag, endDrag } = useAccessControlStore();
|
|
2102
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2103
|
+
draggable: true,
|
|
2104
|
+
onDragStart: (event) => {
|
|
2105
|
+
event.dataTransfer.effectAllowed = "move";
|
|
2106
|
+
startDrag({
|
|
2107
|
+
type: "flow",
|
|
2108
|
+
id: flow.id,
|
|
2109
|
+
name: flow.name,
|
|
2110
|
+
parentId: flow.scopeId ?? null
|
|
2111
|
+
});
|
|
2112
|
+
},
|
|
2113
|
+
onDragEnd: endDrag,
|
|
2114
|
+
onClick: () => onSelect(flow),
|
|
2115
|
+
className: (0, clsx.clsx)("group flex cursor-pointer items-center gap-2 rounded-md px-4 py-1.5 transition-colors", "hover:bg-imp-muted/50", draggedItem?.type === "flow" && draggedItem.id === flow.id && "opacity-40", isSelected && "bg-imp-primary/10 text-imp-primary ring-1 ring-imp-primary/30"),
|
|
2116
|
+
children: [
|
|
2117
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Workflow, { className: (0, clsx.clsx)("h-4 w-4 shrink-0", isSelected ? "text-imp-primary" : "text-imp-muted-foreground") }),
|
|
2118
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2119
|
+
className: "flex-1 min-w-0 text-sm font-medium truncate",
|
|
2120
|
+
children: flow.name
|
|
2121
|
+
}),
|
|
2122
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.GripVertical, { className: "h-3.5 w-3.5 shrink-0 text-imp-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" })
|
|
2123
|
+
]
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
function ScopeTreeRow({ scope, allScopes, depth, onSelectScope, onSelectFlow, onTriggerMove }) {
|
|
2127
|
+
const { draggedItem, dropTarget, selected, expandedNodes, startDrag, endDrag, setDropTarget, toggleNode } = useAccessControlStore();
|
|
2128
|
+
const isExpanded = expandedNodes.has(scope.id);
|
|
2129
|
+
const isSelected = selected?.kind === "team" && selected.id === scope.id;
|
|
2130
|
+
const isDragging = draggedItem?.type === "scope" && draggedItem.id === scope.id;
|
|
2131
|
+
const hasChildren = scope.children.length > 0 || scope.flows.length > 0;
|
|
2132
|
+
const canDrop = draggedItem !== null && canDropOnTarget(draggedItem, scope.id, allScopes);
|
|
2133
|
+
const isDropTarget = dropTarget === scope.id && canDrop;
|
|
2134
|
+
const handleDragOver = (event) => {
|
|
2135
|
+
if (!draggedItem || !canDrop) return;
|
|
2136
|
+
event.preventDefault();
|
|
2137
|
+
event.stopPropagation();
|
|
2138
|
+
if (dropTarget !== scope.id) setDropTarget(scope.id);
|
|
2139
|
+
};
|
|
2140
|
+
const handleDragLeave = (event) => {
|
|
2141
|
+
const related = event.relatedTarget;
|
|
2142
|
+
if (related && event.currentTarget.contains(related)) return;
|
|
2143
|
+
if (dropTarget === scope.id) setDropTarget(null);
|
|
2144
|
+
};
|
|
2145
|
+
const handleDrop = (event) => {
|
|
2146
|
+
event.preventDefault();
|
|
2147
|
+
event.stopPropagation();
|
|
2148
|
+
if (!draggedItem || !canDrop) return;
|
|
2149
|
+
onTriggerMove(draggedItem, scope.id);
|
|
2150
|
+
};
|
|
2151
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2152
|
+
onDragOver: handleDragOver,
|
|
2153
|
+
onDragLeave: handleDragLeave,
|
|
2154
|
+
onDrop: handleDrop,
|
|
2155
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2156
|
+
draggable: true,
|
|
2157
|
+
onDragStart: (event) => {
|
|
2158
|
+
event.dataTransfer.effectAllowed = "move";
|
|
2159
|
+
startDrag({
|
|
2160
|
+
type: "scope",
|
|
2161
|
+
id: scope.id,
|
|
2162
|
+
name: scope.name,
|
|
2163
|
+
parentId: scope.parentId ?? null
|
|
2164
|
+
});
|
|
2165
|
+
},
|
|
2166
|
+
onDragEnd: endDrag,
|
|
2167
|
+
onClick: () => onSelectScope(scope),
|
|
2168
|
+
className: (0, clsx.clsx)("group flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2 transition-colors", isDragging && "opacity-40", isDropTarget && "ring-2 ring-imp-primary/40 bg-imp-primary/5", isSelected ? "text-imp-primary ring-1 ring-imp-primary/30" : "hover:bg-accent"),
|
|
2169
|
+
children: [
|
|
2170
|
+
hasChildren ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2171
|
+
type: "button",
|
|
2172
|
+
onClick: (event) => {
|
|
2173
|
+
event.stopPropagation();
|
|
2174
|
+
toggleNode(scope.id);
|
|
2175
|
+
},
|
|
2176
|
+
className: "rounded p-0.5 text-imp-muted-foreground hover:bg-imp-muted",
|
|
2177
|
+
children: isExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "w-4 h-4" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronRight, { className: "w-4 h-4" })
|
|
2178
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "w-5 shrink-0" }),
|
|
2179
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: (0, clsx.clsx)("h-4 w-4 shrink-0", isSelected ? "text-imp-primary" : "text-imp-muted-foreground") }),
|
|
2180
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2181
|
+
className: "flex-1 min-w-0 text-sm font-medium truncate",
|
|
2182
|
+
children: scope.name
|
|
2183
|
+
}),
|
|
2184
|
+
scope.teamPermission ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2185
|
+
className: (0, clsx.clsx)("inline-flex shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-medium", getPermissionBadgeClasses(scope.teamPermission)),
|
|
2186
|
+
children: formatPermissionLabel(scope.teamPermission)
|
|
2187
|
+
}) : null,
|
|
2188
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.GripVertical, { className: "h-3.5 w-3.5 shrink-0 text-imp-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" })
|
|
2189
|
+
]
|
|
2190
|
+
}), isExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2191
|
+
className: "space-y-1 border-l border-imp-border",
|
|
2192
|
+
style: { marginLeft: "22px" },
|
|
2193
|
+
children: [scope.flows.map((flow) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FlowTreeRow, {
|
|
2194
|
+
flow,
|
|
2195
|
+
isSelected: selected?.kind === "flow" && selected.id === flow.id,
|
|
2196
|
+
onSelect: onSelectFlow
|
|
2197
|
+
}, flow.id)), scope.children.map((child) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeTreeRow, {
|
|
2198
|
+
scope: child,
|
|
2199
|
+
allScopes,
|
|
2200
|
+
depth: depth + 1,
|
|
2201
|
+
onSelectScope,
|
|
2202
|
+
onSelectFlow,
|
|
2203
|
+
onTriggerMove
|
|
2204
|
+
}, child.id))]
|
|
2205
|
+
}) : null]
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
const EMPTY_SCOPES = [];
|
|
2209
|
+
const EMPTY_FLOWS = [];
|
|
2210
|
+
function AccessControlPage() {
|
|
2211
|
+
const { isAuthenticated, checkPermission } = useRbac();
|
|
2212
|
+
const isAdmin = isAuthenticated && checkPermission("admin:*");
|
|
2213
|
+
const users = useUsers$1();
|
|
2214
|
+
const userMap = (0, react.useMemo)(() => {
|
|
2215
|
+
const map = /* @__PURE__ */ new Map();
|
|
2216
|
+
for (const user of users) map.set(user.id, user);
|
|
2217
|
+
return map;
|
|
2218
|
+
}, [users]);
|
|
2219
|
+
const teams = useTeams().data?.teams ?? [];
|
|
2220
|
+
const scopeTreeQuery = useScopeTree();
|
|
2221
|
+
const createTeam = useCreateTeam();
|
|
2222
|
+
const previewMove = usePreviewMove();
|
|
2223
|
+
const { selected, setSelected, search, setSearch, expandedNodes, expandAll, showNewTeam, setShowNewTeam, newTeamName, setNewTeamName, draggedItem, dropTarget, setDropTarget, endDrag, pendingMove, setPendingMove, movePreview, setMovePreview, moveError, setMoveError, clearMove } = useAccessControlStore();
|
|
2224
|
+
const scopes = scopeTreeQuery.data?.scopes ?? EMPTY_SCOPES;
|
|
2225
|
+
const unscopedFlows = scopeTreeQuery.data?.unscopedFlows ?? EMPTY_FLOWS;
|
|
2226
|
+
(0, react.useEffect)(() => {
|
|
2227
|
+
if (scopes.length > 0 && expandedNodes.size === 0) expandAll(collectScopeIds(scopes));
|
|
2228
|
+
}, [scopes]);
|
|
2229
|
+
(0, react.useEffect)(() => {
|
|
2230
|
+
scopeTreeQuery.refetch();
|
|
2231
|
+
}, []);
|
|
2232
|
+
const normalizedSearch = search.trim().toLowerCase();
|
|
2233
|
+
const filteredScopes = (0, react.useMemo)(() => normalizedSearch ? filterScopes(scopes, normalizedSearch) : scopes, [scopes, normalizedSearch]);
|
|
2234
|
+
const filteredUnscopedFlows = (0, react.useMemo)(() => normalizedSearch ? unscopedFlows.filter((f) => f.name.toLowerCase().includes(normalizedSearch)) : unscopedFlows, [unscopedFlows, normalizedSearch]);
|
|
2235
|
+
const selectedName = (0, react.useMemo)(() => findSelectedName(scopes, unscopedFlows, selected), [
|
|
2236
|
+
scopes,
|
|
2237
|
+
unscopedFlows,
|
|
2238
|
+
selected
|
|
2239
|
+
]);
|
|
2240
|
+
const hasTreeItems = filteredScopes.length > 0 || filteredUnscopedFlows.length > 0;
|
|
2241
|
+
const handleTriggerMove = (0, react.useCallback)(async (item, targetScopeId) => {
|
|
2242
|
+
endDrag();
|
|
2243
|
+
if (!canDropOnTarget(item, targetScopeId, scopes)) return;
|
|
2244
|
+
setPendingMove({
|
|
2245
|
+
type: item.type,
|
|
2246
|
+
id: item.id,
|
|
2247
|
+
name: item.name,
|
|
2248
|
+
targetScopeId
|
|
2249
|
+
});
|
|
2250
|
+
setMovePreview(null);
|
|
2251
|
+
setMoveError(null);
|
|
2252
|
+
try {
|
|
2253
|
+
setMovePreview(await previewMove.mutateAsync({
|
|
2254
|
+
type: item.type,
|
|
2255
|
+
id: item.id,
|
|
2256
|
+
targetScopeId
|
|
2257
|
+
}));
|
|
2258
|
+
} catch (err) {
|
|
2259
|
+
setMoveError(err instanceof Error ? err.message : "Failed to preview move");
|
|
2260
|
+
}
|
|
2261
|
+
}, [
|
|
2262
|
+
scopes,
|
|
2263
|
+
endDrag,
|
|
2264
|
+
previewMove,
|
|
2265
|
+
setPendingMove,
|
|
2266
|
+
setMovePreview,
|
|
2267
|
+
setMoveError
|
|
2268
|
+
]);
|
|
2269
|
+
if (!isAuthenticated) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_invect_frontend.PageLayout, {
|
|
2270
|
+
title: "Access Control",
|
|
2271
|
+
subtitle: "Manage team hierarchy and flow-level access grants.",
|
|
2272
|
+
icon: lucide_react.Shield,
|
|
2273
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2274
|
+
className: "text-sm text-imp-muted-foreground",
|
|
2275
|
+
children: "Please sign in to access this page."
|
|
2276
|
+
})
|
|
2277
|
+
});
|
|
2278
|
+
const isLoading = scopeTreeQuery.isLoading;
|
|
2279
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.PageLayout, {
|
|
2280
|
+
title: "Access Control",
|
|
2281
|
+
subtitle: "Manage team hierarchy and flow-level access grants.",
|
|
2282
|
+
icon: lucide_react.Shield,
|
|
2283
|
+
children: [
|
|
2284
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2285
|
+
className: "flex items-center gap-2",
|
|
2286
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2287
|
+
className: "relative flex-1 max-w-sm",
|
|
2288
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Search, { className: "absolute w-3.5 h-3.5 pointer-events-none left-3 top-1/2 -translate-y-1/2 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2289
|
+
value: search,
|
|
2290
|
+
onChange: (event) => setSearch(event.target.value),
|
|
2291
|
+
placeholder: "Search teams and flows…",
|
|
2292
|
+
className: "w-full py-2 pr-3 text-sm border rounded-lg outline-none pl-9 border-imp-border bg-imp-background placeholder:text-imp-muted-foreground focus:border-imp-primary/50"
|
|
2293
|
+
})]
|
|
2294
|
+
}), isAdmin ? showNewTeam ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2295
|
+
className: "flex items-center gap-1.5",
|
|
2296
|
+
children: [
|
|
2297
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2298
|
+
value: newTeamName,
|
|
2299
|
+
onChange: (event) => setNewTeamName(event.target.value),
|
|
2300
|
+
placeholder: "Team name",
|
|
2301
|
+
className: "px-2.5 py-2 text-sm border rounded-lg border-imp-border bg-imp-background",
|
|
2302
|
+
autoFocus: true,
|
|
2303
|
+
onKeyDown: (event) => {
|
|
2304
|
+
if (event.key === "Enter" && newTeamName.trim()) {
|
|
2305
|
+
createTeam.mutate({ name: newTeamName.trim() });
|
|
2306
|
+
setNewTeamName("");
|
|
2307
|
+
setShowNewTeam(false);
|
|
2308
|
+
}
|
|
2309
|
+
if (event.key === "Escape") {
|
|
2310
|
+
setShowNewTeam(false);
|
|
2311
|
+
setNewTeamName("");
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
}),
|
|
2315
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2316
|
+
type: "button",
|
|
2317
|
+
onClick: () => {
|
|
2318
|
+
if (!newTeamName.trim()) return;
|
|
2319
|
+
createTeam.mutate({ name: newTeamName.trim() });
|
|
2320
|
+
setNewTeamName("");
|
|
2321
|
+
setShowNewTeam(false);
|
|
2322
|
+
},
|
|
2323
|
+
disabled: !newTeamName.trim(),
|
|
2324
|
+
className: "px-3 py-2 text-sm font-medium rounded-lg bg-imp-primary text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
2325
|
+
children: "Create"
|
|
2326
|
+
}),
|
|
2327
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2328
|
+
type: "button",
|
|
2329
|
+
onClick: () => {
|
|
2330
|
+
setShowNewTeam(false);
|
|
2331
|
+
setNewTeamName("");
|
|
2332
|
+
},
|
|
2333
|
+
className: "px-3 py-2 text-sm rounded-lg text-imp-muted-foreground hover:text-imp-foreground",
|
|
2334
|
+
children: "Cancel"
|
|
2335
|
+
})
|
|
2336
|
+
]
|
|
2337
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2338
|
+
type: "button",
|
|
2339
|
+
onClick: () => setShowNewTeam(true),
|
|
2340
|
+
className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-2 text-sm font-medium text-imp-muted-foreground hover:border-imp-primary/50 hover:text-imp-foreground",
|
|
2341
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "w-4 h-4" }), " New Team"]
|
|
2342
|
+
}) : null]
|
|
2343
|
+
}),
|
|
2344
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2345
|
+
className: "grid h-[calc(100vh-220px)] min-h-130 border rounded-lg overflow-hidden",
|
|
2346
|
+
style: { gridTemplateColumns: "320px 1fr" },
|
|
2347
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2348
|
+
className: "flex flex-col overflow-hidden bg-imp-muted/20",
|
|
2349
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2350
|
+
className: "flex-1 px-1 py-2 overflow-y-auto",
|
|
2351
|
+
onDragOver: (event) => {
|
|
2352
|
+
if (!draggedItem) return;
|
|
2353
|
+
if (dropTarget === null) {
|
|
2354
|
+
event.preventDefault();
|
|
2355
|
+
setDropTarget("root");
|
|
2356
|
+
}
|
|
2357
|
+
},
|
|
2358
|
+
onDragLeave: (event) => {
|
|
2359
|
+
const related = event.relatedTarget;
|
|
2360
|
+
if (related && event.currentTarget.contains(related)) return;
|
|
2361
|
+
if (dropTarget === "root") setDropTarget(null);
|
|
2362
|
+
},
|
|
2363
|
+
onDrop: (event) => {
|
|
2364
|
+
event.preventDefault();
|
|
2365
|
+
if (!draggedItem) return;
|
|
2366
|
+
handleTriggerMove(draggedItem, null);
|
|
2367
|
+
},
|
|
2368
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2369
|
+
className: (0, clsx.clsx)("min-h-full rounded-lg py-1 transition-colors", dropTarget === "root" ? "bg-imp-primary/5 ring-1 ring-imp-primary/20 ring-inset" : ""),
|
|
2370
|
+
children: isLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2371
|
+
className: "flex items-center justify-center px-4 py-12 text-sm text-center text-imp-muted-foreground",
|
|
2372
|
+
children: "Loading hierarchy…"
|
|
2373
|
+
}) : !hasTreeItems ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2374
|
+
className: "flex flex-col items-center justify-center px-6 py-16 text-center",
|
|
2375
|
+
children: [
|
|
2376
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.FolderInput, { className: "w-10 h-10 mb-3 opacity-30 text-imp-muted-foreground" }),
|
|
2377
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
|
|
2378
|
+
className: "text-base font-medium text-imp-foreground",
|
|
2379
|
+
children: normalizedSearch ? "No matching teams or flows" : "No teams or flows yet"
|
|
2380
|
+
}),
|
|
2381
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2382
|
+
className: "max-w-sm mt-2 text-sm text-imp-muted-foreground",
|
|
2383
|
+
children: normalizedSearch ? "Adjust the search to find a team or flow in the hierarchy." : "Create a team to start organizing flows and access scopes."
|
|
2384
|
+
})
|
|
2385
|
+
]
|
|
2386
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2387
|
+
className: "space-y-1",
|
|
2388
|
+
children: [filteredUnscopedFlows.map((flow) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FlowTreeRow, {
|
|
2389
|
+
flow,
|
|
2390
|
+
isSelected: selected?.kind === "flow" && selected.id === flow.id,
|
|
2391
|
+
onSelect: (nextFlow) => setSelected({
|
|
2392
|
+
kind: "flow",
|
|
2393
|
+
id: nextFlow.id,
|
|
2394
|
+
name: nextFlow.name
|
|
2395
|
+
})
|
|
2396
|
+
}, flow.id)), filteredScopes.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeTreeRow, {
|
|
2397
|
+
scope,
|
|
2398
|
+
allScopes: scopes,
|
|
2399
|
+
depth: 0,
|
|
2400
|
+
onSelectScope: (nextScope) => setSelected({
|
|
2401
|
+
kind: "team",
|
|
2402
|
+
id: nextScope.id,
|
|
2403
|
+
name: nextScope.name
|
|
2404
|
+
}),
|
|
2405
|
+
onSelectFlow: (nextFlow) => setSelected({
|
|
2406
|
+
kind: "flow",
|
|
2407
|
+
id: nextFlow.id,
|
|
2408
|
+
name: nextFlow.name
|
|
2409
|
+
}),
|
|
2410
|
+
onTriggerMove: handleTriggerMove
|
|
2411
|
+
}, scope.id))]
|
|
2412
|
+
})
|
|
2413
|
+
})
|
|
2414
|
+
})
|
|
2415
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("aside", {
|
|
2416
|
+
className: "flex flex-col overflow-hidden border-l border-imp-border bg-imp-muted/30",
|
|
2417
|
+
children: !selected ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2418
|
+
className: "flex flex-col items-center justify-center h-full gap-4 px-8 text-center text-imp-muted-foreground",
|
|
2419
|
+
children: [
|
|
2420
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2421
|
+
className: "flex items-center justify-center w-12 h-12 rounded-xl bg-imp-primary/5",
|
|
2422
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Shield, { className: "w-6 h-6 text-imp-primary/40" })
|
|
2423
|
+
}),
|
|
2424
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2425
|
+
className: "text-sm font-medium text-imp-foreground",
|
|
2426
|
+
children: "Select a team or flow"
|
|
2427
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2428
|
+
className: "mt-1.5 text-xs text-imp-muted-foreground max-w-[280px]",
|
|
2429
|
+
children: "Choose an item from the tree to manage its members and permissions."
|
|
2430
|
+
})] }),
|
|
2431
|
+
hasTreeItems ? null : isAdmin ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2432
|
+
type: "button",
|
|
2433
|
+
onClick: () => setShowNewTeam(true),
|
|
2434
|
+
className: "flex items-center gap-1.5 rounded-lg border border-imp-border px-3 py-1.5 text-xs font-medium text-imp-foreground transition-colors hover:border-imp-primary/50 hover:bg-imp-muted/50",
|
|
2435
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "h-3.5 w-3.5" }), " Create your first team"]
|
|
2436
|
+
}) : null
|
|
2437
|
+
]
|
|
2438
|
+
}) : selected.kind === "team" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ScopeDetailPanel, {
|
|
2439
|
+
scopeId: selected.id,
|
|
2440
|
+
scopeName: selectedName ?? selected.name,
|
|
2441
|
+
users,
|
|
2442
|
+
userMap,
|
|
2443
|
+
teams,
|
|
2444
|
+
isAdmin: !!isAdmin
|
|
2445
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FlowDetailPanel, {
|
|
2446
|
+
flowId: selected.id,
|
|
2447
|
+
flowName: selectedName ?? selected.name,
|
|
2448
|
+
users,
|
|
2449
|
+
userMap,
|
|
2450
|
+
teams,
|
|
2451
|
+
isAdmin: !!isAdmin
|
|
2452
|
+
})
|
|
2453
|
+
})]
|
|
2454
|
+
}),
|
|
2455
|
+
pendingMove ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MoveConfirmationDialog, {
|
|
2456
|
+
pendingMove,
|
|
2457
|
+
preview: movePreview,
|
|
2458
|
+
error: moveError,
|
|
2459
|
+
onClose: clearMove
|
|
2460
|
+
}) : null
|
|
2461
|
+
]
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
//#endregion
|
|
2465
|
+
//#region src/frontend/components/TeamsPage.tsx
|
|
2466
|
+
/**
|
|
2467
|
+
* TeamsPage — Team management page with create/edit/delete teams and member management.
|
|
2468
|
+
*/
|
|
2469
|
+
function useUsers() {
|
|
2470
|
+
const api = (0, _invect_frontend.useApiClient)();
|
|
2471
|
+
const [users, setUsers] = (0, react.useState)([]);
|
|
2472
|
+
(0, react.useEffect)(() => {
|
|
2473
|
+
let cancelled = false;
|
|
2474
|
+
fetch(`${api.getBaseURL()}/plugins/auth/users?limit=200`, { credentials: "include" }).then((r) => r.ok ? r.json() : null).then((data) => {
|
|
2475
|
+
if (!cancelled && data?.users) setUsers(data.users);
|
|
2476
|
+
}).catch(() => {});
|
|
2477
|
+
return () => {
|
|
2478
|
+
cancelled = true;
|
|
2479
|
+
};
|
|
2480
|
+
}, [api]);
|
|
2481
|
+
return { users };
|
|
2482
|
+
}
|
|
2483
|
+
function TeamsPage() {
|
|
2484
|
+
const { isAuthenticated, checkPermission } = useRbac();
|
|
2485
|
+
const teamsQuery = useTeams();
|
|
2486
|
+
const createTeam = useCreateTeam();
|
|
2487
|
+
const deleteTeam = useDeleteTeam();
|
|
2488
|
+
const { users } = useUsers();
|
|
2489
|
+
const [expandedTeamId, setExpandedTeamId] = (0, react.useState)(null);
|
|
2490
|
+
const [teamSearch, setTeamSearch] = (0, react.useState)("");
|
|
2491
|
+
const [showCreateForm, setShowCreateForm] = (0, react.useState)(false);
|
|
2492
|
+
const [newTeamName, setNewTeamName] = (0, react.useState)("");
|
|
2493
|
+
const [newTeamDescription, setNewTeamDescription] = (0, react.useState)("");
|
|
2494
|
+
const teams = teamsQuery.data?.teams ?? [];
|
|
2495
|
+
const isAdmin = isAuthenticated && checkPermission("admin:*");
|
|
2496
|
+
const userMap = (0, react.useMemo)(() => {
|
|
2497
|
+
const map = /* @__PURE__ */ new Map();
|
|
2498
|
+
for (const u of users) map.set(u.id, u);
|
|
2499
|
+
return map;
|
|
2500
|
+
}, [users]);
|
|
2501
|
+
const filteredTeams = (0, react.useMemo)(() => {
|
|
2502
|
+
const q = teamSearch.trim().toLowerCase();
|
|
2503
|
+
if (!q) return teams;
|
|
2504
|
+
return teams.filter((t) => t.name.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q));
|
|
2505
|
+
}, [teams, teamSearch]);
|
|
2506
|
+
const handleCreate = async () => {
|
|
2507
|
+
if (!newTeamName.trim()) return;
|
|
2508
|
+
await createTeam.mutateAsync({
|
|
2509
|
+
name: newTeamName.trim(),
|
|
2510
|
+
description: newTeamDescription.trim() || void 0
|
|
2511
|
+
});
|
|
2512
|
+
setNewTeamName("");
|
|
2513
|
+
setNewTeamDescription("");
|
|
2514
|
+
setShowCreateForm(false);
|
|
2515
|
+
};
|
|
2516
|
+
const handleDelete = async (teamId) => {
|
|
2517
|
+
await deleteTeam.mutateAsync(teamId);
|
|
2518
|
+
if (expandedTeamId === teamId) setExpandedTeamId(null);
|
|
2519
|
+
};
|
|
2520
|
+
if (!isAuthenticated) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2521
|
+
className: "flex items-center justify-center w-full h-full min-h-0 overflow-y-auto imp-page bg-imp-background text-imp-foreground",
|
|
2522
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2523
|
+
className: "text-sm text-imp-muted-foreground",
|
|
2524
|
+
children: "Please sign in to access this page."
|
|
2525
|
+
})
|
|
2526
|
+
});
|
|
2527
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(_invect_frontend.PageLayout, {
|
|
2528
|
+
title: "Teams",
|
|
2529
|
+
icon: lucide_react.Users,
|
|
2530
|
+
actions: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2531
|
+
className: "flex items-center gap-2",
|
|
2532
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
2533
|
+
className: "text-xs text-imp-muted-foreground",
|
|
2534
|
+
children: [
|
|
2535
|
+
filteredTeams.length,
|
|
2536
|
+
" team",
|
|
2537
|
+
filteredTeams.length !== 1 ? "s" : ""
|
|
2538
|
+
]
|
|
2539
|
+
}), isAdmin && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2540
|
+
type: "button",
|
|
2541
|
+
onClick: () => setShowCreateForm(!showCreateForm),
|
|
2542
|
+
className: "flex items-center gap-1 px-2 py-1 text-xs font-medium rounded bg-imp-primary text-imp-primary-foreground hover:bg-imp-primary/90",
|
|
2543
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Plus, { className: "w-3 h-3" }), "New Team"]
|
|
2544
|
+
})]
|
|
2545
|
+
}),
|
|
2546
|
+
children: [
|
|
2547
|
+
showCreateForm && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2548
|
+
className: "p-3 border rounded-lg border-imp-border bg-imp-card",
|
|
2549
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2550
|
+
className: "mb-2 text-xs font-medium text-imp-foreground",
|
|
2551
|
+
children: "Create Team"
|
|
2552
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2553
|
+
className: "space-y-2",
|
|
2554
|
+
children: [
|
|
2555
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2556
|
+
type: "text",
|
|
2557
|
+
value: newTeamName,
|
|
2558
|
+
onChange: (e) => setNewTeamName(e.target.value),
|
|
2559
|
+
placeholder: "Team name",
|
|
2560
|
+
className: "w-full rounded border border-imp-border bg-imp-background px-2 py-1.5 text-sm placeholder:text-imp-muted-foreground",
|
|
2561
|
+
onKeyDown: (e) => {
|
|
2562
|
+
if (e.key === "Enter") handleCreate();
|
|
2563
|
+
}
|
|
2564
|
+
}),
|
|
2565
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2566
|
+
type: "text",
|
|
2567
|
+
value: newTeamDescription,
|
|
2568
|
+
onChange: (e) => setNewTeamDescription(e.target.value),
|
|
2569
|
+
placeholder: "Description (optional)",
|
|
2570
|
+
className: "w-full rounded border border-imp-border bg-imp-background px-2 py-1.5 text-sm placeholder:text-imp-muted-foreground",
|
|
2571
|
+
onKeyDown: (e) => {
|
|
2572
|
+
if (e.key === "Enter") handleCreate();
|
|
2573
|
+
}
|
|
2574
|
+
}),
|
|
2575
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2576
|
+
className: "flex items-center gap-2",
|
|
2577
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2578
|
+
type: "button",
|
|
2579
|
+
onClick: handleCreate,
|
|
2580
|
+
disabled: createTeam.isPending || !newTeamName.trim(),
|
|
2581
|
+
className: "px-3 py-1 text-xs font-medium rounded bg-imp-primary text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
2582
|
+
children: createTeam.isPending ? "Creating…" : "Create"
|
|
2583
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2584
|
+
type: "button",
|
|
2585
|
+
onClick: () => setShowCreateForm(false),
|
|
2586
|
+
className: "px-3 py-1 text-xs rounded text-imp-muted-foreground hover:text-imp-foreground",
|
|
2587
|
+
children: "Cancel"
|
|
2588
|
+
})]
|
|
2589
|
+
})
|
|
2590
|
+
]
|
|
2591
|
+
})]
|
|
2592
|
+
}),
|
|
2593
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2594
|
+
className: "relative",
|
|
2595
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Search, { className: "pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2596
|
+
value: teamSearch,
|
|
2597
|
+
onChange: (e) => setTeamSearch(e.target.value),
|
|
2598
|
+
placeholder: "Search teams…",
|
|
2599
|
+
className: "w-full rounded-lg border border-imp-border bg-transparent py-2 pl-9 pr-3 text-sm outline-none placeholder:text-imp-muted-foreground focus:border-imp-primary/50"
|
|
2600
|
+
})]
|
|
2601
|
+
}),
|
|
2602
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2603
|
+
className: "border rounded-lg border-imp-border bg-imp-card",
|
|
2604
|
+
children: teamsQuery.isLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2605
|
+
className: "px-4 py-8 text-sm text-center text-imp-muted-foreground",
|
|
2606
|
+
children: "Loading…"
|
|
2607
|
+
}) : teamsQuery.error ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2608
|
+
className: "px-4 py-8 text-sm text-center text-red-500",
|
|
2609
|
+
children: teamsQuery.error instanceof Error ? teamsQuery.error.message : "Failed to load teams"
|
|
2610
|
+
}) : filteredTeams.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2611
|
+
className: "px-4 py-8 text-sm text-center text-imp-muted-foreground",
|
|
2612
|
+
children: teamSearch ? "No teams match this search." : "No teams yet. Create one to get started."
|
|
2613
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2614
|
+
className: "divide-y divide-imp-border",
|
|
2615
|
+
children: filteredTeams.map((team) => {
|
|
2616
|
+
const isExpanded = expandedTeamId === team.id;
|
|
2617
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2618
|
+
className: "flex items-center gap-3 px-3 py-2",
|
|
2619
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2620
|
+
type: "button",
|
|
2621
|
+
className: "flex items-center flex-1 gap-3 text-left transition-opacity hover:opacity-80",
|
|
2622
|
+
onClick: () => setExpandedTeamId(isExpanded ? null : team.id),
|
|
2623
|
+
children: [
|
|
2624
|
+
isExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronDown, { className: "h-3.5 w-3.5 shrink-0 text-imp-muted-foreground" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.ChevronRight, { className: "h-3.5 w-3.5 shrink-0 text-imp-muted-foreground" }),
|
|
2625
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Users, { className: "h-3.5 w-3.5 shrink-0 text-imp-primary" }),
|
|
2626
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2627
|
+
className: "flex-1 min-w-0",
|
|
2628
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2629
|
+
className: "block text-sm font-medium truncate",
|
|
2630
|
+
children: team.name
|
|
2631
|
+
}), team.description && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2632
|
+
className: "block text-xs truncate text-imp-muted-foreground",
|
|
2633
|
+
children: team.description
|
|
2634
|
+
})]
|
|
2635
|
+
})
|
|
2636
|
+
]
|
|
2637
|
+
}), isAdmin && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2638
|
+
type: "button",
|
|
2639
|
+
onClick: () => handleDelete(team.id),
|
|
2640
|
+
className: "p-1 rounded shrink-0 text-imp-muted-foreground hover:text-red-500",
|
|
2641
|
+
title: "Delete team",
|
|
2642
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "h-3.5 w-3.5" })
|
|
2643
|
+
})]
|
|
2644
|
+
}), isExpanded && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TeamMembersPanel, {
|
|
2645
|
+
teamId: team.id,
|
|
2646
|
+
users,
|
|
2647
|
+
userMap,
|
|
2648
|
+
isAdmin: !!isAdmin
|
|
2649
|
+
})] }, team.id);
|
|
2650
|
+
})
|
|
2651
|
+
})
|
|
2652
|
+
})
|
|
2653
|
+
]
|
|
2654
|
+
});
|
|
2655
|
+
}
|
|
2656
|
+
function TeamMembersPanel({ teamId, users, userMap, isAdmin }) {
|
|
2657
|
+
const { data, isLoading } = useTeam(teamId);
|
|
2658
|
+
const addMember = useAddTeamMember(teamId);
|
|
2659
|
+
const removeMember = useRemoveTeamMember(teamId);
|
|
2660
|
+
const members = data?.members ?? [];
|
|
2661
|
+
const existingUserIds = new Set(members.map((m) => m.userId));
|
|
2662
|
+
const getUserLabel = (userId) => {
|
|
2663
|
+
const u = userMap.get(userId);
|
|
2664
|
+
return u?.name || u?.email || userId;
|
|
2665
|
+
};
|
|
2666
|
+
const [selectedUserId, setSelectedUserId] = (0, react.useState)(null);
|
|
2667
|
+
const handleAdd = async () => {
|
|
2668
|
+
if (!selectedUserId) return;
|
|
2669
|
+
await addMember.mutateAsync({ userId: selectedUserId });
|
|
2670
|
+
setSelectedUserId(null);
|
|
2671
|
+
};
|
|
2672
|
+
const handleRemove = async (userId) => {
|
|
2673
|
+
await removeMember.mutateAsync(userId);
|
|
2674
|
+
};
|
|
2675
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2676
|
+
className: "px-3 pt-2 pb-3 border-t border-imp-border bg-imp-muted/20",
|
|
2677
|
+
children: [isLoading ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2678
|
+
className: "py-3 text-xs text-center text-imp-muted-foreground",
|
|
2679
|
+
children: "Loading…"
|
|
2680
|
+
}) : members.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2681
|
+
className: "py-2 text-xs text-imp-muted-foreground",
|
|
2682
|
+
children: "No members yet."
|
|
2683
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2684
|
+
className: "mb-2 space-y-1",
|
|
2685
|
+
children: members.map((member) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2686
|
+
className: "flex items-center gap-2 px-2 py-1 text-xs rounded",
|
|
2687
|
+
children: [
|
|
2688
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2689
|
+
className: "flex items-center justify-center w-5 h-5 rounded-full shrink-0 bg-imp-primary/10",
|
|
2690
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "w-3 h-3 text-imp-primary" })
|
|
2691
|
+
}),
|
|
2692
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2693
|
+
className: "flex-1 min-w-0 truncate",
|
|
2694
|
+
children: getUserLabel(member.userId)
|
|
2695
|
+
}),
|
|
2696
|
+
isAdmin && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2697
|
+
type: "button",
|
|
2698
|
+
onClick: () => handleRemove(member.userId),
|
|
2699
|
+
className: "shrink-0 rounded p-0.5 text-imp-muted-foreground hover:text-red-500",
|
|
2700
|
+
title: "Remove member",
|
|
2701
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Trash2, { className: "w-3 h-3" })
|
|
2702
|
+
})
|
|
2703
|
+
]
|
|
2704
|
+
}, member.id))
|
|
2705
|
+
}), isAdmin && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2706
|
+
className: "flex items-center gap-1.5",
|
|
2707
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(MemberSearchCombobox, {
|
|
2708
|
+
users,
|
|
2709
|
+
excludeIds: existingUserIds,
|
|
2710
|
+
selectedUserId,
|
|
2711
|
+
onSelect: setSelectedUserId
|
|
2712
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
2713
|
+
type: "button",
|
|
2714
|
+
onClick: handleAdd,
|
|
2715
|
+
disabled: addMember.isPending || !selectedUserId,
|
|
2716
|
+
className: "shrink-0 rounded bg-imp-primary px-2.5 py-1 text-xs font-medium text-imp-primary-foreground hover:bg-imp-primary/90 disabled:opacity-50",
|
|
2717
|
+
children: addMember.isPending ? "…" : "Add"
|
|
2718
|
+
})]
|
|
2719
|
+
})]
|
|
2720
|
+
});
|
|
2721
|
+
}
|
|
2722
|
+
function MemberSearchCombobox({ users, excludeIds, selectedUserId, onSelect }) {
|
|
2723
|
+
const [open, setOpen] = (0, react.useState)(false);
|
|
2724
|
+
const [query, setQuery] = (0, react.useState)("");
|
|
2725
|
+
const containerRef = (0, react.useRef)(null);
|
|
2726
|
+
const inputRef = (0, react.useRef)(null);
|
|
2727
|
+
const selectedUser = selectedUserId ? users.find((u) => u.id === selectedUserId) : null;
|
|
2728
|
+
const filtered = (0, react.useMemo)(() => {
|
|
2729
|
+
const q = query.toLowerCase();
|
|
2730
|
+
return users.filter((u) => {
|
|
2731
|
+
if (excludeIds.has(u.id)) return false;
|
|
2732
|
+
if (!q) return true;
|
|
2733
|
+
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q) || u.id.toLowerCase().includes(q);
|
|
2734
|
+
});
|
|
2735
|
+
}, [
|
|
2736
|
+
users,
|
|
2737
|
+
excludeIds,
|
|
2738
|
+
query
|
|
2739
|
+
]);
|
|
2740
|
+
(0, react.useEffect)(() => {
|
|
2741
|
+
const handleClick = (e) => {
|
|
2742
|
+
if (containerRef.current && !containerRef.current.contains(e.target)) setOpen(false);
|
|
2743
|
+
};
|
|
2744
|
+
if (open) {
|
|
2745
|
+
document.addEventListener("mousedown", handleClick);
|
|
2746
|
+
return () => document.removeEventListener("mousedown", handleClick);
|
|
2747
|
+
}
|
|
2748
|
+
}, [open]);
|
|
2749
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2750
|
+
ref: containerRef,
|
|
2751
|
+
className: "relative flex-1 min-w-0",
|
|
2752
|
+
children: [selectedUser ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2753
|
+
type: "button",
|
|
2754
|
+
onClick: () => {
|
|
2755
|
+
onSelect(null);
|
|
2756
|
+
setQuery("");
|
|
2757
|
+
},
|
|
2758
|
+
className: "flex w-full items-center gap-1.5 rounded border border-imp-border bg-imp-background px-2 py-1 text-xs text-left",
|
|
2759
|
+
children: [
|
|
2760
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "w-3 h-3 shrink-0 text-imp-muted-foreground" }),
|
|
2761
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2762
|
+
className: "flex-1 min-w-0 truncate",
|
|
2763
|
+
children: selectedUser.name || selectedUser.email || selectedUser.id
|
|
2764
|
+
}),
|
|
2765
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.X, { className: "w-3 h-3 shrink-0 text-imp-muted-foreground" })
|
|
2766
|
+
]
|
|
2767
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2768
|
+
className: "relative",
|
|
2769
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.Search, { className: "absolute w-3 h-3 -translate-y-1/2 pointer-events-none left-2 top-1/2 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
2770
|
+
ref: inputRef,
|
|
2771
|
+
type: "text",
|
|
2772
|
+
value: query,
|
|
2773
|
+
onChange: (e) => {
|
|
2774
|
+
setQuery(e.target.value);
|
|
2775
|
+
if (!open) setOpen(true);
|
|
2776
|
+
},
|
|
2777
|
+
onFocus: () => setOpen(true),
|
|
2778
|
+
placeholder: "Search users…",
|
|
2779
|
+
className: "w-full py-1 pl-6 pr-2 text-xs border rounded border-imp-border bg-imp-background placeholder:text-imp-muted-foreground"
|
|
2780
|
+
})]
|
|
2781
|
+
}), open && !selectedUser && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2782
|
+
className: "absolute left-0 z-50 w-full mt-1 border rounded-md shadow-lg top-full border-imp-border bg-imp-background",
|
|
2783
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2784
|
+
className: "py-1 overflow-y-auto max-h-40",
|
|
2785
|
+
children: filtered.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2786
|
+
className: "px-3 py-2 text-xs text-imp-muted-foreground",
|
|
2787
|
+
children: "No users found."
|
|
2788
|
+
}) : filtered.map((u) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
2789
|
+
type: "button",
|
|
2790
|
+
onClick: () => {
|
|
2791
|
+
onSelect(u.id);
|
|
2792
|
+
setQuery("");
|
|
2793
|
+
setOpen(false);
|
|
2794
|
+
},
|
|
2795
|
+
className: "flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-imp-muted/50 transition-colors",
|
|
2796
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(lucide_react.User, { className: "h-3.5 w-3.5 shrink-0 text-imp-muted-foreground" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
2797
|
+
className: "flex-1 min-w-0 truncate",
|
|
2798
|
+
children: u.name || u.email || u.id
|
|
2799
|
+
})]
|
|
2800
|
+
}, u.id))
|
|
2801
|
+
})
|
|
2802
|
+
})]
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
//#endregion
|
|
2806
|
+
//#region src/frontend/components/UserMenuSection.tsx
|
|
2807
|
+
/**
|
|
2808
|
+
* UserMenuSection — Sidebar footer component showing current user info.
|
|
2809
|
+
*
|
|
2810
|
+
* Displays the authenticated user's name and role in the sidebar footer area.
|
|
2811
|
+
* This is a standalone component — not directly injected via plugin system
|
|
2812
|
+
* but available for host apps to use in custom sidebar layouts.
|
|
2813
|
+
*/
|
|
2814
|
+
function UserMenuSection({ collapsed = false }) {
|
|
2815
|
+
const { user, isAuthenticated, isLoading } = useRbac();
|
|
2816
|
+
if (isLoading || !isAuthenticated || !user) return null;
|
|
2817
|
+
const initials = (user.name ?? user.id)[0]?.toUpperCase() ?? "?";
|
|
2818
|
+
if (collapsed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2819
|
+
className: "flex justify-center px-2 py-2",
|
|
2820
|
+
title: `${user.name ?? user.id} (${user.role ?? "user"})`,
|
|
2821
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2822
|
+
className: "flex h-8 w-8 items-center justify-center rounded-full bg-imp-primary/10 text-xs font-medium text-imp-primary",
|
|
2823
|
+
children: initials
|
|
2824
|
+
})
|
|
2825
|
+
});
|
|
2826
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2827
|
+
className: "flex items-center gap-3 px-3 py-2",
|
|
2828
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2829
|
+
className: "flex h-8 w-8 items-center justify-center rounded-full bg-imp-primary/10 text-xs font-medium text-imp-primary",
|
|
2830
|
+
children: initials
|
|
2831
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
2832
|
+
className: "min-w-0 flex-1",
|
|
2833
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2834
|
+
className: "truncate text-sm font-medium",
|
|
2835
|
+
children: user.name ?? user.id
|
|
2836
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
2837
|
+
className: "truncate text-xs text-imp-muted-foreground capitalize",
|
|
2838
|
+
children: user.role ?? "user"
|
|
2839
|
+
})]
|
|
2840
|
+
})]
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
function UserAvatar({ className }) {
|
|
2844
|
+
const { user, isAuthenticated } = useRbac();
|
|
2845
|
+
if (!isAuthenticated || !user) return null;
|
|
2846
|
+
const initials = (user.name ?? user.id)[0]?.toUpperCase() ?? "?";
|
|
2847
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2848
|
+
className: `flex items-center justify-center rounded-full bg-imp-primary/10 text-xs font-medium text-imp-primary ${className ?? "h-6 w-6"}`,
|
|
2849
|
+
title: user.name ?? user.id,
|
|
2850
|
+
children: initials
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2853
|
+
//#endregion
|
|
2854
|
+
//#region src/frontend/index.ts
|
|
2855
|
+
/**
|
|
2856
|
+
* @invect/rbac/ui — Frontend Plugin Entry Point
|
|
2857
|
+
*
|
|
2858
|
+
* This is the browser-safe entry point that exports the RBAC frontend plugin.
|
|
2859
|
+
* Import via: `import { rbacFrontendPlugin } from '@invect/rbac/ui'`
|
|
2860
|
+
*
|
|
2861
|
+
* No Node.js dependencies. No @invect/core runtime imports.
|
|
2862
|
+
*/
|
|
2863
|
+
const rbacFrontendPlugin = {
|
|
2864
|
+
id: "rbac",
|
|
2865
|
+
name: "Role-Based Access Control",
|
|
2866
|
+
providers: [RbacProvider],
|
|
2867
|
+
sidebar: [{
|
|
2868
|
+
label: "Access Control",
|
|
2869
|
+
icon: lucide_react.Shield,
|
|
2870
|
+
path: "/access",
|
|
2871
|
+
position: "top",
|
|
2872
|
+
permission: "flow:read"
|
|
2873
|
+
}],
|
|
2874
|
+
routes: [{
|
|
2875
|
+
path: "/access",
|
|
2876
|
+
component: AccessControlPage
|
|
2877
|
+
}],
|
|
2878
|
+
panelTabs: [{
|
|
2879
|
+
context: "flowEditor",
|
|
2880
|
+
label: "Access",
|
|
2881
|
+
icon: lucide_react.Shield,
|
|
2882
|
+
component: FlowAccessPanel,
|
|
2883
|
+
permission: "flow:read"
|
|
2884
|
+
}],
|
|
2885
|
+
headerActions: [{
|
|
2886
|
+
context: "flowHeader",
|
|
2887
|
+
component: ShareButton,
|
|
2888
|
+
permission: "flow:read"
|
|
2889
|
+
}],
|
|
2890
|
+
components: {
|
|
2891
|
+
"rbac.AccessControlPage": AccessControlPage,
|
|
2892
|
+
"rbac.FlowAccessPanel": FlowAccessPanel,
|
|
2893
|
+
"rbac.ShareButton": ShareButton
|
|
2894
|
+
},
|
|
2895
|
+
checkPermission: (_permission, _context) => {}
|
|
2896
|
+
};
|
|
2897
|
+
//#endregion
|
|
2898
|
+
exports.AccessControlPage = AccessControlPage;
|
|
2899
|
+
exports.FlowAccessPanel = FlowAccessPanel;
|
|
2900
|
+
exports.RbacProvider = RbacProvider;
|
|
2901
|
+
exports.ShareButton = ShareButton;
|
|
2902
|
+
exports.ShareFlowModal = ShareFlowModal;
|
|
2903
|
+
exports.TeamsPage = TeamsPage;
|
|
2904
|
+
exports.UserAvatar = UserAvatar;
|
|
2905
|
+
exports.UserMenuSection = UserMenuSection;
|
|
2906
|
+
exports.rbacFrontendPlugin = rbacFrontendPlugin;
|
|
2907
|
+
exports.useAccessibleFlows = useAccessibleFlows;
|
|
2908
|
+
exports.useAddTeamMember = useAddTeamMember;
|
|
2909
|
+
exports.useCreateTeam = useCreateTeam;
|
|
2910
|
+
exports.useDeleteTeam = useDeleteTeam;
|
|
2911
|
+
exports.useEffectiveFlowAccess = useEffectiveFlowAccess;
|
|
2912
|
+
exports.useFlowAccess = useFlowAccess;
|
|
2913
|
+
exports.useGrantFlowAccess = useGrantFlowAccess;
|
|
2914
|
+
exports.useGrantScopeAccess = useGrantScopeAccess;
|
|
2915
|
+
exports.useMoveFlow = useMoveFlow;
|
|
2916
|
+
exports.useMyTeams = useMyTeams;
|
|
2917
|
+
exports.usePreviewMove = usePreviewMove;
|
|
2918
|
+
exports.useRbac = useRbac;
|
|
2919
|
+
exports.useRemoveTeamMember = useRemoveTeamMember;
|
|
2920
|
+
exports.useRevokeFlowAccess = useRevokeFlowAccess;
|
|
2921
|
+
exports.useRevokeScopeAccess = useRevokeScopeAccess;
|
|
2922
|
+
exports.useScopeAccess = useScopeAccess;
|
|
2923
|
+
exports.useScopeTree = useScopeTree;
|
|
2924
|
+
exports.useTeam = useTeam;
|
|
2925
|
+
exports.useTeams = useTeams;
|
|
2926
|
+
exports.useUpdateTeam = useUpdateTeam;
|
|
2927
|
+
|
|
2928
|
+
//# sourceMappingURL=index.cjs.map
|