@treenity/react 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/dist/AclEditor.d.ts +11 -0
  2. package/dist/AclEditor.d.ts.map +1 -0
  3. package/dist/AclEditor.js +152 -0
  4. package/dist/AclEditor.js.map +1 -0
  5. package/dist/App.d.ts +2 -0
  6. package/dist/App.d.ts.map +1 -0
  7. package/dist/App.js +521 -0
  8. package/dist/App.js.map +1 -0
  9. package/dist/Inspector.d.ts +12 -0
  10. package/dist/Inspector.d.ts.map +1 -0
  11. package/dist/Inspector.js +360 -0
  12. package/dist/Inspector.js.map +1 -0
  13. package/dist/Tree.d.ts +16 -0
  14. package/dist/Tree.d.ts.map +1 -0
  15. package/dist/Tree.js +100 -0
  16. package/dist/Tree.js.map +1 -0
  17. package/dist/ViewPage.d.ts +5 -0
  18. package/dist/ViewPage.d.ts.map +1 -0
  19. package/dist/ViewPage.js +13 -0
  20. package/dist/ViewPage.js.map +1 -0
  21. package/dist/bind/computed.d.ts +9 -0
  22. package/dist/bind/computed.d.ts.map +1 -0
  23. package/dist/bind/computed.js +61 -0
  24. package/dist/bind/computed.js.map +1 -0
  25. package/dist/bind/engine.d.ts +3 -0
  26. package/dist/bind/engine.d.ts.map +1 -0
  27. package/dist/bind/engine.js +184 -0
  28. package/dist/bind/engine.js.map +1 -0
  29. package/dist/bind/eval.d.ts +13 -0
  30. package/dist/bind/eval.d.ts.map +1 -0
  31. package/dist/bind/eval.js +97 -0
  32. package/dist/bind/eval.js.map +1 -0
  33. package/dist/bind/hook.d.ts +8 -0
  34. package/dist/bind/hook.d.ts.map +1 -0
  35. package/dist/bind/hook.js +99 -0
  36. package/dist/bind/hook.js.map +1 -0
  37. package/dist/bind/parse.d.ts +19 -0
  38. package/dist/bind/parse.d.ts.map +1 -0
  39. package/dist/bind/parse.js +86 -0
  40. package/dist/bind/parse.js.map +1 -0
  41. package/dist/bind/pipes.d.ts +4 -0
  42. package/dist/bind/pipes.d.ts.map +1 -0
  43. package/dist/bind/pipes.js +43 -0
  44. package/dist/bind/pipes.js.map +1 -0
  45. package/dist/cache.d.ts +27 -0
  46. package/dist/cache.d.ts.map +1 -0
  47. package/dist/cache.js +236 -0
  48. package/dist/cache.js.map +1 -0
  49. package/dist/client-tree.d.ts +9 -0
  50. package/dist/client-tree.d.ts.map +1 -0
  51. package/dist/client-tree.js +14 -0
  52. package/dist/client-tree.js.map +1 -0
  53. package/dist/client.d.ts +2 -0
  54. package/dist/client.d.ts.map +1 -0
  55. package/dist/client.js +10 -0
  56. package/dist/client.js.map +1 -0
  57. package/dist/components/ui/accordion.d.ts +8 -0
  58. package/dist/components/ui/accordion.d.ts.map +1 -0
  59. package/dist/components/ui/accordion.js +18 -0
  60. package/dist/components/ui/accordion.js.map +1 -0
  61. package/dist/components/ui/badge.d.ts +10 -0
  62. package/dist/components/ui/badge.d.ts.map +1 -0
  63. package/dist/components/ui/badge.js +19 -0
  64. package/dist/components/ui/badge.js.map +1 -0
  65. package/dist/components/ui/button.d.ts +11 -0
  66. package/dist/components/ui/button.d.ts.map +1 -0
  67. package/dist/components/ui/button.js +31 -0
  68. package/dist/components/ui/button.js.map +1 -0
  69. package/dist/components/ui/checkbox.d.ts +4 -0
  70. package/dist/components/ui/checkbox.d.ts.map +1 -0
  71. package/dist/components/ui/checkbox.js +7 -0
  72. package/dist/components/ui/checkbox.js.map +1 -0
  73. package/dist/components/ui/dialog.d.ts +18 -0
  74. package/dist/components/ui/dialog.d.ts.map +1 -0
  75. package/dist/components/ui/dialog.js +37 -0
  76. package/dist/components/ui/dialog.js.map +1 -0
  77. package/dist/components/ui/drawer.d.ts +14 -0
  78. package/dist/components/ui/drawer.d.ts.map +1 -0
  79. package/dist/components/ui/drawer.js +35 -0
  80. package/dist/components/ui/drawer.js.map +1 -0
  81. package/dist/components/ui/input.d.ts +4 -0
  82. package/dist/components/ui/input.d.ts.map +1 -0
  83. package/dist/components/ui/input.js +7 -0
  84. package/dist/components/ui/input.js.map +1 -0
  85. package/dist/components/ui/label.d.ts +5 -0
  86. package/dist/components/ui/label.d.ts.map +1 -0
  87. package/dist/components/ui/label.js +8 -0
  88. package/dist/components/ui/label.js.map +1 -0
  89. package/dist/components/ui/popover.d.ts +11 -0
  90. package/dist/components/ui/popover.d.ts.map +1 -0
  91. package/dist/components/ui/popover.js +26 -0
  92. package/dist/components/ui/popover.js.map +1 -0
  93. package/dist/components/ui/progress.d.ts +5 -0
  94. package/dist/components/ui/progress.d.ts.map +1 -0
  95. package/dist/components/ui/progress.js +9 -0
  96. package/dist/components/ui/progress.js.map +1 -0
  97. package/dist/components/ui/select.d.ts +16 -0
  98. package/dist/components/ui/select.d.ts.map +1 -0
  99. package/dist/components/ui/select.js +39 -0
  100. package/dist/components/ui/select.js.map +1 -0
  101. package/dist/components/ui/slider.d.ts +5 -0
  102. package/dist/components/ui/slider.d.ts.map +1 -0
  103. package/dist/components/ui/slider.js +15 -0
  104. package/dist/components/ui/slider.js.map +1 -0
  105. package/dist/components/ui/sonner.d.ts +4 -0
  106. package/dist/components/ui/sonner.d.ts.map +1 -0
  107. package/dist/components/ui/sonner.js +21 -0
  108. package/dist/components/ui/sonner.js.map +1 -0
  109. package/dist/components/ui/switch.d.ts +7 -0
  110. package/dist/components/ui/switch.d.ts.map +1 -0
  111. package/dist/components/ui/switch.js +9 -0
  112. package/dist/components/ui/switch.js.map +1 -0
  113. package/dist/components/ui/textarea.d.ts +4 -0
  114. package/dist/components/ui/textarea.d.ts.map +1 -0
  115. package/dist/components/ui/textarea.js +7 -0
  116. package/dist/components/ui/textarea.js.map +1 -0
  117. package/dist/components/ui/tooltip.d.ts +8 -0
  118. package/dist/components/ui/tooltip.d.ts.map +1 -0
  119. package/dist/components/ui/tooltip.js +18 -0
  120. package/dist/components/ui/tooltip.js.map +1 -0
  121. package/dist/context/index.d.ts +31 -0
  122. package/dist/context/index.d.ts.map +1 -0
  123. package/dist/context/index.js +98 -0
  124. package/dist/context/index.js.map +1 -0
  125. package/dist/context.d.ts +2 -0
  126. package/dist/context.d.ts.map +1 -0
  127. package/dist/context.js +2 -0
  128. package/dist/context.js.map +1 -0
  129. package/dist/hooks.d.ts +21 -0
  130. package/dist/hooks.d.ts.map +1 -0
  131. package/dist/hooks.js +156 -0
  132. package/dist/hooks.js.map +1 -0
  133. package/dist/idb.d.ts +13 -0
  134. package/dist/idb.d.ts.map +1 -0
  135. package/dist/idb.js +67 -0
  136. package/dist/idb.js.map +1 -0
  137. package/dist/lib/minimd.d.ts +3 -0
  138. package/dist/lib/minimd.d.ts.map +1 -0
  139. package/dist/lib/minimd.js +97 -0
  140. package/dist/lib/minimd.js.map +1 -0
  141. package/dist/lib/utils.d.ts +3 -0
  142. package/dist/lib/utils.d.ts.map +1 -0
  143. package/dist/lib/utils.js +6 -0
  144. package/dist/lib/utils.js.map +1 -0
  145. package/dist/load-client.d.ts +2 -0
  146. package/dist/load-client.d.ts.map +1 -0
  147. package/dist/load-client.js +6 -0
  148. package/dist/load-client.js.map +1 -0
  149. package/dist/main.d.ts +4 -0
  150. package/dist/main.d.ts.map +1 -0
  151. package/dist/main.js +16 -0
  152. package/dist/main.js.map +1 -0
  153. package/dist/mods/editor-ui/client.d.ts +6 -0
  154. package/dist/mods/editor-ui/client.d.ts.map +1 -0
  155. package/dist/mods/editor-ui/client.js +8 -0
  156. package/dist/mods/editor-ui/client.js.map +1 -0
  157. package/dist/mods/editor-ui/default-view.d.ts +2 -0
  158. package/dist/mods/editor-ui/default-view.d.ts.map +1 -0
  159. package/dist/mods/editor-ui/default-view.js +71 -0
  160. package/dist/mods/editor-ui/default-view.js.map +1 -0
  161. package/dist/mods/editor-ui/dir-view.d.ts +2 -0
  162. package/dist/mods/editor-ui/dir-view.d.ts.map +1 -0
  163. package/dist/mods/editor-ui/dir-view.js +42 -0
  164. package/dist/mods/editor-ui/dir-view.js.map +1 -0
  165. package/dist/mods/editor-ui/form-fields.d.ts +6 -0
  166. package/dist/mods/editor-ui/form-fields.d.ts.map +1 -0
  167. package/dist/mods/editor-ui/form-fields.js +401 -0
  168. package/dist/mods/editor-ui/form-fields.js.map +1 -0
  169. package/dist/mods/editor-ui/layout-view.d.ts +2 -0
  170. package/dist/mods/editor-ui/layout-view.d.ts.map +1 -0
  171. package/dist/mods/editor-ui/layout-view.js +22 -0
  172. package/dist/mods/editor-ui/layout-view.js.map +1 -0
  173. package/dist/mods/editor-ui/list-items.d.ts +2 -0
  174. package/dist/mods/editor-ui/list-items.d.ts.map +1 -0
  175. package/dist/mods/editor-ui/list-items.js +38 -0
  176. package/dist/mods/editor-ui/list-items.js.map +1 -0
  177. package/dist/mods/editor-ui/node-utils.d.ts +10 -0
  178. package/dist/mods/editor-ui/node-utils.d.ts.map +1 -0
  179. package/dist/mods/editor-ui/node-utils.js +76 -0
  180. package/dist/mods/editor-ui/node-utils.js.map +1 -0
  181. package/dist/mods/editor-ui/user-view.d.ts +2 -0
  182. package/dist/mods/editor-ui/user-view.d.ts.map +1 -0
  183. package/dist/mods/editor-ui/user-view.js +47 -0
  184. package/dist/mods/editor-ui/user-view.js.map +1 -0
  185. package/dist/mods/treenity/client.d.ts +4 -0
  186. package/dist/mods/treenity/client.d.ts.map +1 -0
  187. package/dist/mods/treenity/client.js +6 -0
  188. package/dist/mods/treenity/client.js.map +1 -0
  189. package/dist/mods/treenity/groups/index.d.ts +2 -0
  190. package/dist/mods/treenity/groups/index.d.ts.map +1 -0
  191. package/dist/mods/treenity/groups/index.js +27 -0
  192. package/dist/mods/treenity/groups/index.js.map +1 -0
  193. package/dist/mods/treenity/preview.d.ts +6 -0
  194. package/dist/mods/treenity/preview.d.ts.map +1 -0
  195. package/dist/mods/treenity/preview.js +95 -0
  196. package/dist/mods/treenity/preview.js.map +1 -0
  197. package/dist/mods/treenity/ref-view.d.ts +2 -0
  198. package/dist/mods/treenity/ref-view.d.ts.map +1 -0
  199. package/dist/mods/treenity/ref-view.js +29 -0
  200. package/dist/mods/treenity/ref-view.js.map +1 -0
  201. package/dist/mods/treenity/schema-form.d.ts +2 -0
  202. package/dist/mods/treenity/schema-form.d.ts.map +1 -0
  203. package/dist/mods/treenity/schema-form.js +38 -0
  204. package/dist/mods/treenity/schema-form.js.map +1 -0
  205. package/dist/mods/treenity/seed.d.ts +2 -0
  206. package/dist/mods/treenity/seed.d.ts.map +1 -0
  207. package/dist/mods/treenity/seed.js +53 -0
  208. package/dist/mods/treenity/seed.js.map +1 -0
  209. package/dist/mods/treenity/server.d.ts +2 -0
  210. package/dist/mods/treenity/server.d.ts.map +1 -0
  211. package/dist/mods/treenity/server.js +2 -0
  212. package/dist/mods/treenity/server.js.map +1 -0
  213. package/dist/mods/treenity/type-view.d.ts +2 -0
  214. package/dist/mods/treenity/type-view.d.ts.map +1 -0
  215. package/dist/mods/treenity/type-view.js +36 -0
  216. package/dist/mods/treenity/type-view.js.map +1 -0
  217. package/dist/remote-tree.d.ts +6 -0
  218. package/dist/remote-tree.d.ts.map +1 -0
  219. package/dist/remote-tree.js +18 -0
  220. package/dist/remote-tree.js.map +1 -0
  221. package/dist/schema-loader.d.ts +19 -0
  222. package/dist/schema-loader.d.ts.map +1 -0
  223. package/dist/schema-loader.js +63 -0
  224. package/dist/schema-loader.js.map +1 -0
  225. package/dist/trpc.d.ts +187 -0
  226. package/dist/trpc.d.ts.map +1 -0
  227. package/dist/trpc.js +21 -0
  228. package/dist/trpc.js.map +1 -0
  229. package/package.json +88 -0
  230. package/src/AclEditor.tsx +330 -0
  231. package/src/App.tsx +775 -0
  232. package/src/CLAUDE.md +16 -0
  233. package/src/Inspector.tsx +857 -0
  234. package/src/Tree.tsx +237 -0
  235. package/src/ViewPage.tsx +45 -0
  236. package/src/bind/bind.test.ts +316 -0
  237. package/src/bind/computed.ts +64 -0
  238. package/src/bind/engine.ts +198 -0
  239. package/src/bind/eval.ts +108 -0
  240. package/src/bind/hook.ts +112 -0
  241. package/src/bind/parse.ts +104 -0
  242. package/src/bind/pipes.ts +71 -0
  243. package/src/cache.test.ts +139 -0
  244. package/src/cache.ts +244 -0
  245. package/src/client-tree.test.ts +116 -0
  246. package/src/client-tree.ts +24 -0
  247. package/src/client.ts +11 -0
  248. package/src/components/ui/accordion.tsx +63 -0
  249. package/src/components/ui/badge.tsx +27 -0
  250. package/src/components/ui/button.tsx +44 -0
  251. package/src/components/ui/checkbox.tsx +19 -0
  252. package/src/components/ui/dialog.tsx +156 -0
  253. package/src/components/ui/drawer.tsx +132 -0
  254. package/src/components/ui/input.tsx +19 -0
  255. package/src/components/ui/label.tsx +21 -0
  256. package/src/components/ui/popover.tsx +86 -0
  257. package/src/components/ui/progress.tsx +30 -0
  258. package/src/components/ui/select.tsx +189 -0
  259. package/src/components/ui/slider.tsx +62 -0
  260. package/src/components/ui/sonner.tsx +32 -0
  261. package/src/components/ui/switch.tsx +34 -0
  262. package/src/components/ui/textarea.tsx +17 -0
  263. package/src/components/ui/tooltip.tsx +56 -0
  264. package/src/context/index.tsx +131 -0
  265. package/src/context.ts +1 -0
  266. package/src/hooks.ts +208 -0
  267. package/src/idb.ts +80 -0
  268. package/src/index.html +14 -0
  269. package/src/lib/minimd.css +28 -0
  270. package/src/lib/minimd.ts +95 -0
  271. package/src/lib/utils.ts +6 -0
  272. package/src/load-client.ts +5 -0
  273. package/src/main.tsx +22 -0
  274. package/src/mods/editor-ui/CLAUDE.md +3 -0
  275. package/src/mods/editor-ui/client.ts +8 -0
  276. package/src/mods/editor-ui/default-view.tsx +148 -0
  277. package/src/mods/editor-ui/dir-view.tsx +91 -0
  278. package/src/mods/editor-ui/form-fields.tsx +861 -0
  279. package/src/mods/editor-ui/layout-view.tsx +62 -0
  280. package/src/mods/editor-ui/list-items.tsx +63 -0
  281. package/src/mods/editor-ui/node-utils.ts +84 -0
  282. package/src/mods/editor-ui/user-view.tsx +101 -0
  283. package/src/mods/treenity/CLAUDE.md +7 -0
  284. package/src/mods/treenity/client.ts +6 -0
  285. package/src/mods/treenity/groups/index.tsx +65 -0
  286. package/src/mods/treenity/preview.tsx +133 -0
  287. package/src/mods/treenity/ref-view.tsx +87 -0
  288. package/src/mods/treenity/schema-form.tsx +65 -0
  289. package/src/mods/treenity/seed.ts +56 -0
  290. package/src/mods/treenity/server.ts +1 -0
  291. package/src/mods/treenity/type-view.tsx +116 -0
  292. package/src/remote-tree.test.ts +142 -0
  293. package/src/remote-tree.ts +25 -0
  294. package/src/schema-loader.ts +84 -0
  295. package/src/style.css +1269 -0
  296. package/src/trpc.ts +27 -0
  297. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,330 @@
1
+ import { Button } from '#components/ui/button';
2
+ import { Input } from '#components/ui/input';
3
+ import { A, type GroupPerm, R, S, W } from '@treenity/core/core';
4
+ import { ChevronDown, ChevronRight, X } from 'lucide-react';
5
+ import { useMemo, useState } from 'react';
6
+ import * as cache from './cache';
7
+
8
+ const BITS = [
9
+ { bit: R, label: 'R' },
10
+ { bit: W, label: 'W' },
11
+ { bit: A, label: 'A' },
12
+ { bit: S, label: 'S' },
13
+ ] as const;
14
+
15
+ function permStr(p: number): string {
16
+ return (
17
+ BITS.filter((b) => p & b.bit)
18
+ .map((b) => b.label)
19
+ .join('') || '—'
20
+ );
21
+ }
22
+
23
+ // Compute claims for userId from cache (mirrors server buildClaims — no 'public' for authenticated)
24
+ function getUserClaims(userId: string): Set<string> {
25
+ const claims = new Set([`u:${userId}`, 'authenticated']);
26
+ const userNode = cache.get(`/auth/users/${userId}`);
27
+ if (userNode) {
28
+ const groups = userNode.groups as { list?: string[] } | undefined;
29
+ if (groups?.list) groups.list.forEach((g) => claims.add(g));
30
+ }
31
+ return claims;
32
+ }
33
+
34
+ // Mirrors server resolvePermission: walk root→node accumulating allow/deny per group.
35
+ // Allow rules cascade down; deny is sticky (once denied, can't be re-allowed).
36
+ function computeEffective(
37
+ currentNodeOwner: string,
38
+ currentNodeRules: GroupPerm[],
39
+ userId: string,
40
+ path: string,
41
+ ): { perms: number; matchedGroups: string[] } {
42
+ const claims = getUserClaims(userId);
43
+
44
+ // All paths from root → current node inclusive
45
+ const allPaths =
46
+ path === '/'
47
+ ? ['/']
48
+ : [
49
+ '/',
50
+ ...path
51
+ .split('/')
52
+ .filter(Boolean)
53
+ .reduce<string[]>((acc, seg) => {
54
+ acc.push((acc.length ? acc[acc.length - 1] : '') + '/' + seg);
55
+ return acc;
56
+ }, []),
57
+ ];
58
+
59
+ const groupPerms = new Map<string, number>();
60
+ const denied = new Set<string>();
61
+ const deniedBits = new Map<string, number>();
62
+ let resolvedOwner = '';
63
+
64
+ for (const p of allPaths) {
65
+ let nodeOwner: string | undefined;
66
+ let nodeRules: GroupPerm[] | undefined;
67
+
68
+ if (p === path) {
69
+ // Current node: use local (possibly unsaved) state
70
+ nodeOwner = currentNodeOwner || undefined;
71
+ nodeRules = currentNodeRules;
72
+ } else {
73
+ const cached = cache.get(p);
74
+ if (!cached) continue;
75
+ nodeOwner = cached.$owner as string | undefined;
76
+ nodeRules = cached.$acl as GroupPerm[] | undefined;
77
+ }
78
+
79
+ if (nodeOwner) resolvedOwner = nodeOwner;
80
+ if (!nodeRules || nodeRules.length === 0) continue;
81
+
82
+ for (const { g, p: perm } of nodeRules) {
83
+ const matches = g === 'owner' ? userId === resolvedOwner : claims.has(g);
84
+ if (!matches) continue;
85
+ if (denied.has(g)) continue;
86
+ if (perm < 0) {
87
+ const bits = -perm;
88
+ deniedBits.set(g, (deniedBits.get(g) || 0) | bits);
89
+ } else if (perm === 0) {
90
+ denied.add(g);
91
+ groupPerms.set(g, 0);
92
+ } else {
93
+ groupPerms.set(g, perm & ~(deniedBits.get(g) || 0));
94
+ }
95
+ }
96
+ }
97
+
98
+ let effective = 0;
99
+ const matchedGroups: string[] = [];
100
+ for (const [g, p] of groupPerms.entries()) {
101
+ if (p > 0) matchedGroups.push(g);
102
+ if (p > effective) effective = p;
103
+ }
104
+ return { perms: effective, matchedGroups };
105
+ }
106
+
107
+ type Props = {
108
+ path: string;
109
+ owner: string;
110
+ rules: GroupPerm[];
111
+ currentUserId?: string;
112
+ onChange: (owner: string, rules: GroupPerm[]) => void;
113
+ };
114
+
115
+ function ancestorChain(path: string): { path: string; owner?: string; acl?: GroupPerm[] }[] {
116
+ const parts =
117
+ path === '/'
118
+ ? ['/']
119
+ : [
120
+ '/',
121
+ ...path
122
+ .split('/')
123
+ .filter(Boolean)
124
+ .reduce<string[]>((acc, seg) => {
125
+ acc.push((acc.length ? acc[acc.length - 1] : '') + '/' + seg);
126
+ return acc;
127
+ }, []),
128
+ ];
129
+ const chain: { path: string; owner?: string; acl?: GroupPerm[] }[] = [];
130
+ for (const p of parts) {
131
+ const node = cache.get(p);
132
+ if (node && (node.$acl || node.$owner))
133
+ chain.push({ path: p, owner: node.$owner, acl: node.$acl });
134
+ }
135
+ return chain;
136
+ }
137
+
138
+ export function AclEditor({ path, owner, rules, currentUserId, onChange }: Props) {
139
+ const [open, setOpen] = useState(false);
140
+ const [newGroup, setNewGroup] = useState('');
141
+ const chain = useMemo(() => ancestorChain(path), [path]);
142
+
143
+ const effective = currentUserId ? computeEffective(owner, rules, currentUserId, path) : null;
144
+
145
+ function toggleBit(idx: number, bit: number) {
146
+ const next = [...rules];
147
+ next[idx] = { ...next[idx], p: next[idx].p ^ bit };
148
+ onChange(owner, next);
149
+ }
150
+
151
+ function removeRule(idx: number) {
152
+ onChange(
153
+ owner,
154
+ rules.filter((_, i) => i !== idx),
155
+ );
156
+ }
157
+
158
+ function addRule() {
159
+ if (!newGroup.trim()) return;
160
+ onChange(owner, [...rules, { g: newGroup.trim(), p: R }]);
161
+ setNewGroup('');
162
+ }
163
+
164
+ return (
165
+ <div className="card">
166
+ <div
167
+ className="card-header cursor-pointer select-none"
168
+ onClick={() => setOpen((v) => !v)}
169
+ >
170
+ <span>Access Control</span>
171
+ <span className="flex items-center gap-2 normal-case tracking-normal font-normal">
172
+ {effective && (
173
+ <span className="flex items-center gap-1.5">
174
+ {effective.matchedGroups.length > 0 && (
175
+ <span className="text-[10px] font-mono text-muted-foreground/60 truncate max-w-[120px]">
176
+ {effective.matchedGroups[effective.matchedGroups.length - 1]}
177
+ </span>
178
+ )}
179
+ <span className="flex items-center gap-0.5">
180
+ {BITS.map(({ bit, label }) => (
181
+ <span
182
+ key={label}
183
+ className={`text-[10px] font-mono font-bold px-1 rounded ${
184
+ effective.perms & bit
185
+ ? 'text-primary bg-primary/10'
186
+ : 'text-muted-foreground/30'
187
+ }`}
188
+ >
189
+ {label}
190
+ </span>
191
+ ))}
192
+ </span>
193
+ </span>
194
+ )}
195
+ {open ? (
196
+ <ChevronDown className="h-3 w-3" />
197
+ ) : (
198
+ <ChevronRight className="h-3 w-3" />
199
+ )}
200
+ </span>
201
+ </div>
202
+ {open && (
203
+ <div className="card-body space-y-3">
204
+ <div className="field">
205
+ <label className="text-xs text-muted-foreground">$owner</label>
206
+ <Input value={owner} onChange={(e) => onChange(e.target.value, rules)} />
207
+ </div>
208
+ {rules.length > 0 && (
209
+ <AclTable
210
+ rules={rules}
211
+ editable
212
+ onToggleBit={toggleBit}
213
+ onRemove={removeRule}
214
+ />
215
+ )}
216
+ <div className="flex gap-2">
217
+ <Input
218
+ placeholder="Group name"
219
+ value={newGroup}
220
+ onChange={(e) => setNewGroup(e.target.value)}
221
+ onKeyDown={(e) => e.key === 'Enter' && addRule()}
222
+ />
223
+ <Button variant="outline" size="sm" onClick={addRule}>
224
+ Add
225
+ </Button>
226
+ </div>
227
+
228
+ {chain.length > 0 && (
229
+ <div className="space-y-1.5 pt-1 border-t border-border/40">
230
+ <span className="text-[11px] text-muted-foreground/60 uppercase tracking-wide">Inherited</span>
231
+ <div className="space-y-1.5 pl-2 border-l border-border/40">
232
+ {chain.map((entry) => (
233
+ <div key={entry.path} className="text-xs min-w-0">
234
+ <div className="flex items-baseline gap-1.5 min-w-0 font-mono text-muted-foreground">
235
+ <span className="truncate shrink-0 max-w-[55%]">{entry.path}</span>
236
+ {entry.owner && (
237
+ <span className="truncate text-foreground/50 text-[10px]">owner={entry.owner}</span>
238
+ )}
239
+ </div>
240
+ {entry.acl && entry.acl.length > 0 && (
241
+ <div className="flex flex-wrap gap-1 mt-0.5">
242
+ {entry.acl.map((r, i) => (
243
+ <span
244
+ key={i}
245
+ className="inline-flex items-center gap-0.5 rounded bg-secondary px-1.5 py-0.5 text-[11px]"
246
+ >
247
+ <span className="text-muted-foreground">{r.g}</span>
248
+ <span className={r.p === 0 ? 'text-destructive' : 'text-primary'}>
249
+ {permStr(r.p)}
250
+ </span>
251
+ </span>
252
+ ))}
253
+ </div>
254
+ )}
255
+ </div>
256
+ ))}
257
+ </div>
258
+ </div>
259
+ )}
260
+ </div>
261
+ )}
262
+ </div>
263
+ );
264
+ }
265
+
266
+ // ── Reusable ACL table ──
267
+
268
+ function AclTable({
269
+ rules,
270
+ editable,
271
+ onToggleBit,
272
+ onRemove,
273
+ }: {
274
+ rules: GroupPerm[];
275
+ editable?: boolean;
276
+ onToggleBit?: (i: number, bit: number) => void;
277
+ onRemove?: (i: number) => void;
278
+ }) {
279
+ return (
280
+ <div className="flex flex-col gap-1">
281
+ {rules.map((rule, i) => (
282
+ <div
283
+ key={i}
284
+ className="flex items-center gap-2 px-2 py-1.5 rounded bg-background border border-border/60"
285
+ >
286
+ <span className="flex-1 text-[12px] font-mono text-foreground/70 truncate">
287
+ {rule.g}
288
+ </span>
289
+ <span className="flex items-center gap-0.5">
290
+ {BITS.map(({ bit, label }) => {
291
+ const active = !!(rule.p & bit);
292
+ return editable ? (
293
+ <button
294
+ key={label}
295
+ type="button"
296
+ onClick={() => onToggleBit?.(i, bit)}
297
+ className={`text-[10px] font-mono font-bold w-6 h-5 rounded transition-colors ${
298
+ active
299
+ ? 'text-primary bg-primary/15 border border-primary/30'
300
+ : 'text-muted-foreground/40 bg-transparent border border-transparent hover:border-border'
301
+ }`}
302
+ >
303
+ {label}
304
+ </button>
305
+ ) : (
306
+ <span
307
+ key={label}
308
+ className={`text-[10px] font-mono font-bold w-6 text-center ${
309
+ active ? 'text-primary' : 'text-muted-foreground/20'
310
+ }`}
311
+ >
312
+ {label}
313
+ </span>
314
+ );
315
+ })}
316
+ </span>
317
+ {editable && (
318
+ <button
319
+ type="button"
320
+ className="text-muted-foreground/40 hover:text-destructive transition-colors p-0.5"
321
+ onClick={() => onRemove?.(i)}
322
+ >
323
+ <X className="h-3 w-3" />
324
+ </button>
325
+ )}
326
+ </div>
327
+ ))}
328
+ </div>
329
+ );
330
+ }