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