@dowel-ui/react 0.4.0 → 0.7.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 (335) hide show
  1. package/README.md +9 -5
  2. package/dist/blocks/admin-dashboard/meta.js +31 -0
  3. package/dist/blocks/admin-dashboard/meta.js.map +1 -0
  4. package/dist/blocks/agent-console/meta.js +27 -0
  5. package/dist/blocks/agent-console/meta.js.map +1 -0
  6. package/dist/blocks/ai-dashboard/meta.js +25 -0
  7. package/dist/blocks/ai-dashboard/meta.js.map +1 -0
  8. package/dist/blocks/ai-workspace/meta.js +33 -0
  9. package/dist/blocks/ai-workspace/meta.js.map +1 -0
  10. package/dist/blocks/analytics/meta.js +24 -0
  11. package/dist/blocks/analytics/meta.js.map +1 -0
  12. package/dist/blocks/billing/meta.js +25 -0
  13. package/dist/blocks/billing/meta.js.map +1 -0
  14. package/dist/blocks/command-center/meta.js +28 -0
  15. package/dist/blocks/command-center/meta.js.map +1 -0
  16. package/dist/blocks/crm/meta.js +32 -0
  17. package/dist/blocks/crm/meta.js.map +1 -0
  18. package/dist/blocks/onboarding/meta.js +23 -0
  19. package/dist/blocks/onboarding/meta.js.map +1 -0
  20. package/dist/components/accordion/accordion.js +1 -1
  21. package/dist/components/accordion/accordion.js.map +1 -1
  22. package/dist/components/activity-feed/activity-feed.js +1 -1
  23. package/dist/components/activity-feed/activity-feed.js.map +1 -1
  24. package/dist/components/ai-agent-plan/ai-agent-plan.js +1 -1
  25. package/dist/components/ai-agent-plan/ai-agent-plan.js.map +1 -1
  26. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts +52 -0
  27. package/dist/components/ai-extraction-review/ai-extraction-review.d.ts.map +1 -0
  28. package/dist/components/ai-extraction-review/ai-extraction-review.js +249 -0
  29. package/dist/components/ai-extraction-review/ai-extraction-review.js.map +1 -0
  30. package/dist/components/ai-extraction-review/extraction-model.d.ts +94 -0
  31. package/dist/components/ai-extraction-review/extraction-model.d.ts.map +1 -0
  32. package/dist/components/ai-extraction-review/extraction-model.js +91 -0
  33. package/dist/components/ai-extraction-review/extraction-model.js.map +1 -0
  34. package/dist/components/ai-extraction-review/index.d.ts +3 -0
  35. package/dist/components/ai-extraction-review/index.js +3 -0
  36. package/dist/components/ai-extraction-review/meta.js +17 -0
  37. package/dist/components/ai-extraction-review/meta.js.map +1 -0
  38. package/dist/components/ai-prompt-input/ai-prompt-input.js +1 -1
  39. package/dist/components/ai-prompt-input/ai-prompt-input.js.map +1 -1
  40. package/dist/components/ai-reasoning/ai-reasoning.js +1 -1
  41. package/dist/components/ai-reasoning/ai-reasoning.js.map +1 -1
  42. package/dist/components/ai-response/ai-response.js +2 -2
  43. package/dist/components/ai-response/ai-response.js.map +1 -1
  44. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts +72 -0
  45. package/dist/components/ai-suggested-value/ai-suggested-value.d.ts.map +1 -0
  46. package/dist/components/ai-suggested-value/ai-suggested-value.js +126 -0
  47. package/dist/components/ai-suggested-value/ai-suggested-value.js.map +1 -0
  48. package/dist/components/ai-suggested-value/index.d.ts +2 -0
  49. package/dist/components/ai-suggested-value/index.js +2 -0
  50. package/dist/components/ai-suggested-value/meta.js +17 -0
  51. package/dist/components/ai-suggested-value/meta.js.map +1 -0
  52. package/dist/components/ai-tool/ai-tool.js +2 -2
  53. package/dist/components/ai-tool/ai-tool.js.map +1 -1
  54. package/dist/components/breadcrumb/breadcrumb.d.ts +52 -0
  55. package/dist/components/breadcrumb/breadcrumb.d.ts.map +1 -0
  56. package/dist/components/breadcrumb/breadcrumb.js +141 -0
  57. package/dist/components/breadcrumb/breadcrumb.js.map +1 -0
  58. package/dist/components/breadcrumb/index.d.ts +2 -0
  59. package/dist/components/breadcrumb/index.js +2 -0
  60. package/dist/components/breadcrumb/meta.js +17 -0
  61. package/dist/components/breadcrumb/meta.js.map +1 -0
  62. package/dist/components/calendar/calendar.js +2 -2
  63. package/dist/components/calendar/calendar.js.map +1 -1
  64. package/dist/components/code-block/code-block.js +1 -1
  65. package/dist/components/code-block/code-block.js.map +1 -1
  66. package/dist/components/collapsible/collapsible.d.ts +30 -0
  67. package/dist/components/collapsible/collapsible.d.ts.map +1 -0
  68. package/dist/components/collapsible/collapsible.js +38 -0
  69. package/dist/components/collapsible/collapsible.js.map +1 -0
  70. package/dist/components/collapsible/index.d.ts +2 -0
  71. package/dist/components/collapsible/index.js +2 -0
  72. package/dist/components/collapsible/meta.js +17 -0
  73. package/dist/components/collapsible/meta.js.map +1 -0
  74. package/dist/components/combobox/combobox.js +2 -2
  75. package/dist/components/combobox/combobox.js.map +1 -1
  76. package/dist/components/command/command.js +1 -1
  77. package/dist/components/command/command.js.map +1 -1
  78. package/dist/components/confirm-typed/confirm-typed.d.ts +47 -0
  79. package/dist/components/confirm-typed/confirm-typed.d.ts.map +1 -0
  80. package/dist/components/confirm-typed/confirm-typed.js +106 -0
  81. package/dist/components/confirm-typed/confirm-typed.js.map +1 -0
  82. package/dist/components/confirm-typed/index.d.ts +2 -0
  83. package/dist/components/confirm-typed/index.js +2 -0
  84. package/dist/components/confirm-typed/meta.js +17 -0
  85. package/dist/components/confirm-typed/meta.js.map +1 -0
  86. package/dist/components/cron-editor/cron-editor.d.ts +36 -0
  87. package/dist/components/cron-editor/cron-editor.d.ts.map +1 -0
  88. package/dist/components/cron-editor/cron-editor.js +493 -0
  89. package/dist/components/cron-editor/cron-editor.js.map +1 -0
  90. package/dist/components/cron-editor/cron-expression.d.ts +82 -0
  91. package/dist/components/cron-editor/cron-expression.d.ts.map +1 -0
  92. package/dist/components/cron-editor/cron-expression.js +352 -0
  93. package/dist/components/cron-editor/cron-expression.js.map +1 -0
  94. package/dist/components/cron-editor/index.d.ts +3 -0
  95. package/dist/components/cron-editor/index.js +3 -0
  96. package/dist/components/cron-editor/meta.js +17 -0
  97. package/dist/components/cron-editor/meta.js.map +1 -0
  98. package/dist/components/data-table/data-table.d.ts.map +1 -1
  99. package/dist/components/data-table/data-table.js +4 -1
  100. package/dist/components/data-table/data-table.js.map +1 -1
  101. package/dist/components/dialog/dialog.js +2 -2
  102. package/dist/components/dialog/dialog.js.map +1 -1
  103. package/dist/components/diff-viewer/diff-viewer.js +4 -4
  104. package/dist/components/diff-viewer/diff-viewer.js.map +1 -1
  105. package/dist/components/direction/direction.d.ts +35 -0
  106. package/dist/components/direction/direction.d.ts.map +1 -0
  107. package/dist/components/direction/direction.js +14 -0
  108. package/dist/components/direction/direction.js.map +1 -0
  109. package/dist/components/direction/index.d.ts +2 -0
  110. package/dist/components/direction/index.js +2 -0
  111. package/dist/components/direction/meta.js +17 -0
  112. package/dist/components/direction/meta.js.map +1 -0
  113. package/dist/components/dns-record/dns-record.d.ts +61 -0
  114. package/dist/components/dns-record/dns-record.d.ts.map +1 -0
  115. package/dist/components/dns-record/dns-record.js +227 -0
  116. package/dist/components/dns-record/dns-record.js.map +1 -0
  117. package/dist/components/dns-record/index.d.ts +2 -0
  118. package/dist/components/dns-record/index.js +2 -0
  119. package/dist/components/dns-record/meta.js +17 -0
  120. package/dist/components/dns-record/meta.js.map +1 -0
  121. package/dist/components/drawer/drawer.js +1 -1
  122. package/dist/components/drawer/drawer.js.map +1 -1
  123. package/dist/components/dropdown-menu/dropdown-menu.d.ts.map +1 -1
  124. package/dist/components/dropdown-menu/dropdown-menu.js +9 -8
  125. package/dist/components/dropdown-menu/dropdown-menu.js.map +1 -1
  126. package/dist/components/input/input.js +1 -1
  127. package/dist/components/input/input.js.map +1 -1
  128. package/dist/components/log-viewer/log-viewer.js +2 -2
  129. package/dist/components/log-viewer/log-viewer.js.map +1 -1
  130. package/dist/components/meter/meter.js +1 -1
  131. package/dist/components/meter/meter.js.map +1 -1
  132. package/dist/components/pagination/pagination.d.ts.map +1 -1
  133. package/dist/components/pagination/pagination.js +3 -2
  134. package/dist/components/pagination/pagination.js.map +1 -1
  135. package/dist/components/permission-matrix/index.d.ts +3 -0
  136. package/dist/components/permission-matrix/index.js +3 -0
  137. package/dist/components/permission-matrix/meta.js +17 -0
  138. package/dist/components/permission-matrix/meta.js.map +1 -0
  139. package/dist/components/permission-matrix/permission-matrix.d.ts +46 -0
  140. package/dist/components/permission-matrix/permission-matrix.d.ts.map +1 -0
  141. package/dist/components/permission-matrix/permission-matrix.js +199 -0
  142. package/dist/components/permission-matrix/permission-matrix.js.map +1 -0
  143. package/dist/components/permission-matrix/permission-model.d.ts +53 -0
  144. package/dist/components/permission-matrix/permission-model.d.ts.map +1 -0
  145. package/dist/components/permission-matrix/permission-model.js +64 -0
  146. package/dist/components/permission-matrix/permission-model.js.map +1 -0
  147. package/dist/components/record-diff/record-diff.js +4 -4
  148. package/dist/components/record-diff/record-diff.js.map +1 -1
  149. package/dist/components/secret-field/index.d.ts +2 -0
  150. package/dist/components/secret-field/index.js +2 -0
  151. package/dist/components/secret-field/meta.js +17 -0
  152. package/dist/components/secret-field/meta.js.map +1 -0
  153. package/dist/components/secret-field/secret-field.d.ts +58 -0
  154. package/dist/components/secret-field/secret-field.d.ts.map +1 -0
  155. package/dist/components/secret-field/secret-field.js +168 -0
  156. package/dist/components/secret-field/secret-field.js.map +1 -0
  157. package/dist/components/select/select.js +2 -2
  158. package/dist/components/select/select.js.map +1 -1
  159. package/dist/components/session-expiry/index.d.ts +2 -0
  160. package/dist/components/session-expiry/index.js +2 -0
  161. package/dist/components/session-expiry/meta.js +17 -0
  162. package/dist/components/session-expiry/meta.js.map +1 -0
  163. package/dist/components/session-expiry/session-expiry.d.ts +64 -0
  164. package/dist/components/session-expiry/session-expiry.d.ts.map +1 -0
  165. package/dist/components/session-expiry/session-expiry.js +185 -0
  166. package/dist/components/session-expiry/session-expiry.js.map +1 -0
  167. package/dist/components/sheet/sheet.d.ts.map +1 -1
  168. package/dist/components/sheet/sheet.js +2 -2
  169. package/dist/components/sheet/sheet.js.map +1 -1
  170. package/dist/components/shortcut-recorder/index.d.ts +3 -0
  171. package/dist/components/shortcut-recorder/index.js +3 -0
  172. package/dist/components/shortcut-recorder/meta.js +17 -0
  173. package/dist/components/shortcut-recorder/meta.js.map +1 -0
  174. package/dist/components/shortcut-recorder/shortcut-model.d.ts +67 -0
  175. package/dist/components/shortcut-recorder/shortcut-model.d.ts.map +1 -0
  176. package/dist/components/shortcut-recorder/shortcut-model.js +168 -0
  177. package/dist/components/shortcut-recorder/shortcut-model.js.map +1 -0
  178. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts +47 -0
  179. package/dist/components/shortcut-recorder/shortcut-recorder.d.ts.map +1 -0
  180. package/dist/components/shortcut-recorder/shortcut-recorder.js +212 -0
  181. package/dist/components/shortcut-recorder/shortcut-recorder.js.map +1 -0
  182. package/dist/components/sidebar/index.d.ts +2 -0
  183. package/dist/components/sidebar/index.js +2 -0
  184. package/dist/components/sidebar/meta.js +17 -0
  185. package/dist/components/sidebar/meta.js.map +1 -0
  186. package/dist/components/sidebar/sidebar.d.ts +69 -0
  187. package/dist/components/sidebar/sidebar.d.ts.map +1 -0
  188. package/dist/components/sidebar/sidebar.js +256 -0
  189. package/dist/components/sidebar/sidebar.js.map +1 -0
  190. package/dist/components/sync-status/index.d.ts +2 -0
  191. package/dist/components/sync-status/index.js +2 -0
  192. package/dist/components/sync-status/meta.js +17 -0
  193. package/dist/components/sync-status/meta.js.map +1 -0
  194. package/dist/components/sync-status/sync-status.d.ts +65 -0
  195. package/dist/components/sync-status/sync-status.d.ts.map +1 -0
  196. package/dist/components/sync-status/sync-status.js +103 -0
  197. package/dist/components/sync-status/sync-status.js.map +1 -0
  198. package/dist/components/table/table.js +2 -2
  199. package/dist/components/table/table.js.map +1 -1
  200. package/dist/components/tags-input/tags-input.js +1 -1
  201. package/dist/components/tags-input/tags-input.js.map +1 -1
  202. package/dist/components/textarea/index.d.ts +2 -0
  203. package/dist/components/textarea/index.js +2 -0
  204. package/dist/components/textarea/meta.js +17 -0
  205. package/dist/components/textarea/meta.js.map +1 -0
  206. package/dist/components/textarea/textarea.d.ts +43 -0
  207. package/dist/components/textarea/textarea.d.ts.map +1 -0
  208. package/dist/components/textarea/textarea.js +119 -0
  209. package/dist/components/textarea/textarea.js.map +1 -0
  210. package/dist/components/time-range-picker/time-range-picker.js +3 -3
  211. package/dist/components/time-range-picker/time-range-picker.js.map +1 -1
  212. package/dist/components/toast/toast.d.ts.map +1 -1
  213. package/dist/components/toast/toast.js +2 -2
  214. package/dist/components/toast/toast.js.map +1 -1
  215. package/dist/index.d.ts +36 -2
  216. package/dist/index.js +22 -3
  217. package/dist/lib/styles.d.ts +16 -1
  218. package/dist/lib/styles.d.ts.map +1 -1
  219. package/dist/lib/styles.js +16 -1
  220. package/dist/lib/styles.js.map +1 -1
  221. package/dist/registry/blocks.d.ts.map +1 -1
  222. package/dist/registry/blocks.js +27 -9
  223. package/dist/registry/blocks.js.map +1 -1
  224. package/dist/registry/components.d.ts.map +1 -1
  225. package/dist/registry/components.js +84 -54
  226. package/dist/registry/components.js.map +1 -1
  227. package/dist/registry/schema.d.ts +8 -0
  228. package/dist/registry/schema.d.ts.map +1 -1
  229. package/dist/registry/schema.js.map +1 -1
  230. package/package.json +5 -4
  231. package/src/components/accordion/accordion.tsx +1 -1
  232. package/src/components/activity-feed/activity-feed.tsx +1 -1
  233. package/src/components/ai-agent-plan/ai-agent-plan.tsx +0 -0
  234. package/src/components/ai-extraction-review/ai-extraction-review.tsx +492 -0
  235. package/src/components/ai-extraction-review/extraction-model.ts +159 -0
  236. package/src/components/ai-extraction-review/index.ts +13 -0
  237. package/src/components/ai-extraction-review/meta.ts +24 -0
  238. package/src/components/ai-prompt-input/ai-prompt-input.tsx +1 -1
  239. package/src/components/ai-reasoning/ai-reasoning.tsx +1 -1
  240. package/src/components/ai-response/ai-response.tsx +2 -2
  241. package/src/components/ai-suggested-value/ai-suggested-value.tsx +295 -0
  242. package/src/components/ai-suggested-value/index.ts +6 -0
  243. package/src/components/ai-suggested-value/meta.ts +24 -0
  244. package/src/components/ai-tool/ai-tool.tsx +2 -2
  245. package/src/components/breadcrumb/breadcrumb.tsx +157 -0
  246. package/src/components/breadcrumb/index.ts +10 -0
  247. package/src/components/breadcrumb/meta.ts +19 -0
  248. package/src/components/calendar/calendar.tsx +2 -2
  249. package/src/components/code-block/code-block.tsx +1 -1
  250. package/src/components/collapsible/collapsible.tsx +63 -0
  251. package/src/components/collapsible/index.ts +6 -0
  252. package/src/components/collapsible/meta.ts +16 -0
  253. package/src/components/combobox/combobox.tsx +2 -2
  254. package/src/components/command/command.tsx +1 -1
  255. package/src/components/confirm-typed/confirm-typed.tsx +191 -0
  256. package/src/components/confirm-typed/index.ts +1 -0
  257. package/src/components/confirm-typed/meta.ts +22 -0
  258. package/src/components/cron-editor/cron-editor.tsx +676 -0
  259. package/src/components/cron-editor/cron-expression.ts +552 -0
  260. package/src/components/cron-editor/index.ts +23 -0
  261. package/src/components/cron-editor/meta.ts +23 -0
  262. package/src/components/data-table/data-table.tsx +14 -3
  263. package/src/components/dialog/dialog.tsx +2 -2
  264. package/src/components/diff-viewer/diff-viewer.tsx +4 -4
  265. package/src/components/direction/direction.tsx +38 -0
  266. package/src/components/direction/index.ts +1 -0
  267. package/src/components/direction/meta.ts +19 -0
  268. package/src/components/dns-record/dns-record.tsx +315 -0
  269. package/src/components/dns-record/index.ts +7 -0
  270. package/src/components/dns-record/meta.ts +21 -0
  271. package/src/components/drawer/drawer.tsx +1 -1
  272. package/src/components/dropdown-menu/dropdown-menu.tsx +14 -8
  273. package/src/components/input/input.tsx +1 -1
  274. package/src/components/log-viewer/log-viewer.tsx +2 -2
  275. package/src/components/meter/meter.tsx +1 -1
  276. package/src/components/pagination/pagination.tsx +13 -2
  277. package/src/components/permission-matrix/index.ts +14 -0
  278. package/src/components/permission-matrix/meta.ts +23 -0
  279. package/src/components/permission-matrix/permission-matrix.tsx +368 -0
  280. package/src/components/permission-matrix/permission-model.ts +120 -0
  281. package/src/components/record-diff/record-diff.tsx +4 -4
  282. package/src/components/secret-field/index.ts +6 -0
  283. package/src/components/secret-field/meta.ts +21 -0
  284. package/src/components/secret-field/secret-field.tsx +284 -0
  285. package/src/components/select/select.tsx +2 -2
  286. package/src/components/session-expiry/index.ts +9 -0
  287. package/src/components/session-expiry/meta.ts +23 -0
  288. package/src/components/session-expiry/session-expiry.tsx +321 -0
  289. package/src/components/sheet/sheet.tsx +8 -4
  290. package/src/components/shortcut-recorder/index.ts +19 -0
  291. package/src/components/shortcut-recorder/meta.ts +22 -0
  292. package/src/components/shortcut-recorder/shortcut-model.ts +204 -0
  293. package/src/components/shortcut-recorder/shortcut-recorder.tsx +336 -0
  294. package/src/components/sidebar/index.ts +19 -0
  295. package/src/components/sidebar/meta.ts +25 -0
  296. package/src/components/sidebar/sidebar.tsx +374 -0
  297. package/src/components/sync-status/index.ts +9 -0
  298. package/src/components/sync-status/meta.ts +21 -0
  299. package/src/components/sync-status/sync-status.tsx +217 -0
  300. package/src/components/table/table.tsx +3 -3
  301. package/src/components/tags-input/tags-input.tsx +1 -1
  302. package/src/components/textarea/index.ts +1 -0
  303. package/src/components/textarea/meta.ts +21 -0
  304. package/src/components/textarea/textarea.tsx +210 -0
  305. package/src/components/time-range-picker/time-range-picker.tsx +3 -3
  306. package/src/components/toast/toast.tsx +14 -6
  307. package/src/index.ts +15 -0
  308. package/src/lib/styles.ts +16 -0
  309. package/src/registry/blocks.ts +18 -0
  310. package/src/registry/components.ts +30 -0
  311. package/src/registry/schema.ts +8 -0
  312. package/src/blocks/admin-users/admin-users.tsx +0 -251
  313. package/src/blocks/admin-users/index.ts +0 -1
  314. package/src/blocks/admin-users/meta.ts +0 -27
  315. package/src/blocks/ai-chat/ai-chat.tsx +0 -280
  316. package/src/blocks/ai-chat/index.ts +0 -7
  317. package/src/blocks/ai-chat/meta.ts +0 -32
  318. package/src/blocks/dashboard/dashboard.tsx +0 -192
  319. package/src/blocks/dashboard/index.ts +0 -6
  320. package/src/blocks/dashboard/meta.ts +0 -18
  321. package/src/blocks/forgot-password/forgot-password.tsx +0 -158
  322. package/src/blocks/forgot-password/index.ts +0 -1
  323. package/src/blocks/forgot-password/meta.ts +0 -18
  324. package/src/blocks/login/index.ts +0 -1
  325. package/src/blocks/login/login.tsx +0 -178
  326. package/src/blocks/login/meta.ts +0 -18
  327. package/src/blocks/pricing/index.ts +0 -1
  328. package/src/blocks/pricing/meta.ts +0 -18
  329. package/src/blocks/pricing/pricing.tsx +0 -176
  330. package/src/blocks/settings/index.ts +0 -6
  331. package/src/blocks/settings/meta.ts +0 -29
  332. package/src/blocks/settings/settings.tsx +0 -317
  333. package/src/blocks/signup/index.ts +0 -1
  334. package/src/blocks/signup/meta.ts +0 -18
  335. package/src/blocks/signup/signup.tsx +0 -240
@@ -0,0 +1,199 @@
1
+ "use client";
2
+ import { cn } from "../../lib/utils.js";
3
+ import { Checkbox } from "../checkbox/checkbox.js";
4
+ import { cellState, countGranted, groupPermissions, groupState } from "./permission-model.js";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { useId, useMemo, useState } from "react";
7
+ //#region src/components/permission-matrix/permission-matrix.tsx
8
+ const ARROWS = {
9
+ ArrowUp: [-1, 0],
10
+ ArrowDown: [1, 0],
11
+ ArrowLeft: [0, -1],
12
+ ArrowRight: [0, 1]
13
+ };
14
+ function PermissionMatrix({ className, label, roles, permissions, grants, onChange, ...props }) {
15
+ const uid = useId();
16
+ const rows = useMemo(() => groupPermissions(permissions).flatMap((group) => [...group.group !== void 0 ? [{
17
+ kind: "group",
18
+ label: group.group,
19
+ permissions: group.permissions
20
+ }] : [], ...group.permissions.map((permission) => ({
21
+ kind: "permission",
22
+ permission
23
+ }))]), [permissions]);
24
+ const [active, setActive] = useState([0, 0]);
25
+ const onKeyDown = (event) => {
26
+ const target = event.target;
27
+ const row = Number(target.dataset.row);
28
+ const col = Number(target.dataset.col);
29
+ if (Number.isNaN(row) || Number.isNaN(col)) return;
30
+ let next = null;
31
+ const arrow = ARROWS[event.key];
32
+ if (arrow) next = [row + arrow[0], col + arrow[1]];
33
+ else if (event.key === "Home") next = event.ctrlKey ? [0, 0] : [row, 0];
34
+ else if (event.key === "End") next = event.ctrlKey ? [rows.length - 1, roles.length - 1] : [row, roles.length - 1];
35
+ if (!next) return;
36
+ const [nextRow, nextCol] = next;
37
+ if (nextRow < 0 || nextRow >= rows.length || nextCol < 0 || nextCol >= roles.length) {
38
+ event.preventDefault();
39
+ return;
40
+ }
41
+ event.preventDefault();
42
+ event.currentTarget.querySelector(`[data-row="${String(nextRow)}"][data-col="${String(nextCol)}"]`)?.focus();
43
+ };
44
+ return /* @__PURE__ */ jsx("div", {
45
+ className: "relative w-full overflow-x-auto",
46
+ children: /* @__PURE__ */ jsxs("table", {
47
+ "data-slot": "permission-matrix",
48
+ role: "grid",
49
+ "aria-label": label,
50
+ "aria-readonly": onChange ? void 0 : true,
51
+ onKeyDown,
52
+ className: cn("w-full border-collapse text-sm", className),
53
+ ...props,
54
+ children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", {
55
+ role: "row",
56
+ className: "border-b border-border",
57
+ children: [/* @__PURE__ */ jsx("th", {
58
+ role: "columnheader",
59
+ scope: "col",
60
+ className: "px-3 py-2 text-start text-xs font-medium",
61
+ children: "Permission"
62
+ }), roles.map((role) => {
63
+ const granted = countGranted(roles, grants, role, permissions);
64
+ return /* @__PURE__ */ jsxs("th", {
65
+ role: "columnheader",
66
+ scope: "col",
67
+ className: "px-3 py-2 text-center text-xs font-medium",
68
+ children: [role.label, /* @__PURE__ */ jsx("span", {
69
+ className: "block font-normal text-muted-foreground",
70
+ children: role.locked ? "all, locked" : /* @__PURE__ */ jsxs(Fragment, { children: [
71
+ String(granted),
72
+ "/",
73
+ String(permissions.length),
74
+ /* @__PURE__ */ jsx("span", {
75
+ className: "sr-only",
76
+ children: " granted"
77
+ })
78
+ ] })
79
+ })]
80
+ }, role.id);
81
+ })]
82
+ }) }), /* @__PURE__ */ jsx("tbody", { children: rows.map((row, rowIndex) => row.kind === "group" ? /* @__PURE__ */ jsxs("tr", {
83
+ role: "row",
84
+ className: "border-b border-border bg-muted/40",
85
+ children: [/* @__PURE__ */ jsxs("th", {
86
+ role: "rowheader",
87
+ scope: "row",
88
+ className: "px-3 py-1.5 text-start text-xs font-medium",
89
+ children: [row.label, /* @__PURE__ */ jsx("span", {
90
+ className: "sr-only",
91
+ children: ", all"
92
+ })]
93
+ }), roles.map((role, colIndex) => /* @__PURE__ */ jsx(GroupCell, {
94
+ role,
95
+ roles,
96
+ grants,
97
+ group: row.label,
98
+ permissions: row.permissions,
99
+ position: [rowIndex, colIndex],
100
+ active: active[0] === rowIndex && active[1] === colIndex,
101
+ onActivate: setActive,
102
+ onChange
103
+ }, role.id))]
104
+ }, `group-${row.label}`) : /* @__PURE__ */ jsxs("tr", {
105
+ role: "row",
106
+ className: "border-b border-border last:border-0 hover:bg-muted/30",
107
+ children: [/* @__PURE__ */ jsxs("th", {
108
+ role: "rowheader",
109
+ scope: "row",
110
+ className: cn("px-3 py-2 text-start font-normal", row.permission.group !== void 0 && "ps-6"),
111
+ children: [row.permission.label, row.permission.description ? /* @__PURE__ */ jsx("span", {
112
+ className: "block text-xs text-muted-foreground",
113
+ children: row.permission.description
114
+ }) : null]
115
+ }), roles.map((role, colIndex) => /* @__PURE__ */ jsx(PermissionCell, {
116
+ id: `${uid}-${role.id}-${row.permission.id}`,
117
+ role,
118
+ roles,
119
+ grants,
120
+ permission: row.permission,
121
+ position: [rowIndex, colIndex],
122
+ active: active[0] === rowIndex && active[1] === colIndex,
123
+ onActivate: setActive,
124
+ onChange
125
+ }, role.id))]
126
+ }, row.permission.id)) })]
127
+ })
128
+ });
129
+ }
130
+ function PermissionCell({ id, role, roles, grants, permission, position, active, onActivate, onChange }) {
131
+ const state = cellState(roles, grants, role, permission.id);
132
+ const fixed = state.fixed || !onChange;
133
+ const noteId = `${id}-note`;
134
+ const note = role.locked ? `${role.label} has every permission; this cannot be changed.` : state.inheritedFrom ? `Inherited from ${state.inheritedFrom.label}.` : null;
135
+ return /* @__PURE__ */ jsxs("td", {
136
+ role: "gridcell",
137
+ "data-slot": "permission-cell",
138
+ "data-granted": state.granted || void 0,
139
+ "data-inherited": state.inheritedFrom ? "" : void 0,
140
+ className: "px-3 py-2 text-center align-middle",
141
+ children: [/* @__PURE__ */ jsx(Checkbox, {
142
+ id,
143
+ checked: state.granted,
144
+ "aria-label": `${permission.label} for ${role.label}`,
145
+ "aria-describedby": note ? noteId : void 0,
146
+ "aria-disabled": fixed || void 0,
147
+ tabIndex: active ? 0 : -1,
148
+ "data-row": position[0],
149
+ "data-col": position[1],
150
+ className: cn(fixed && "opacity-55"),
151
+ onFocus: () => {
152
+ onActivate(position);
153
+ },
154
+ onCheckedChange: (next) => {
155
+ if (fixed) return;
156
+ onChange?.(role.id, [permission.id], next === true);
157
+ }
158
+ }), state.inheritedFrom ? /* @__PURE__ */ jsxs("span", {
159
+ id: noteId,
160
+ className: "mt-0.5 block text-2xs text-muted-foreground",
161
+ children: ["via ", state.inheritedFrom.label]
162
+ }) : note ? /* @__PURE__ */ jsx("span", {
163
+ id: noteId,
164
+ className: "sr-only",
165
+ children: note
166
+ }) : null]
167
+ });
168
+ }
169
+ function GroupCell({ role, roles, grants, group, permissions, position, active, onActivate, onChange }) {
170
+ const ids = permissions.map((permission) => permission.id);
171
+ const state = groupState(roles, grants, role, ids);
172
+ const changeable = permissions.filter((permission) => !cellState(roles, grants, role, permission.id).fixed).map((permission) => permission.id);
173
+ const fixed = role.locked || !onChange || changeable.length === 0;
174
+ return /* @__PURE__ */ jsx("td", {
175
+ role: "gridcell",
176
+ "data-slot": "permission-group-cell",
177
+ className: "px-3 py-1.5 text-center align-middle",
178
+ children: /* @__PURE__ */ jsx(Checkbox, {
179
+ checked: state === "all" ? true : state === "some" ? "indeterminate" : false,
180
+ "aria-label": `All ${group} for ${role.label}`,
181
+ "aria-disabled": fixed || void 0,
182
+ tabIndex: active ? 0 : -1,
183
+ "data-row": position[0],
184
+ "data-col": position[1],
185
+ className: cn(fixed && "opacity-55"),
186
+ onFocus: () => {
187
+ onActivate(position);
188
+ },
189
+ onCheckedChange: (next) => {
190
+ if (fixed) return;
191
+ onChange?.(role.id, changeable, next === true);
192
+ }
193
+ })
194
+ });
195
+ }
196
+ //#endregion
197
+ export { PermissionMatrix };
198
+
199
+ //# sourceMappingURL=permission-matrix.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-matrix.js","names":[],"sources":["../../../src/components/permission-matrix/permission-matrix.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n useId,\n useMemo,\n useState,\n type ComponentPropsWithRef,\n type KeyboardEvent,\n} from \"react\";\n\nimport { Checkbox } from \"@/components/checkbox\";\nimport { cn } from \"@/lib/utils\";\n\nimport {\n cellState,\n countGranted,\n groupPermissions,\n groupState,\n type Grants,\n type Permission,\n type Role,\n} from \"./permission-model\";\n\n/**\n * Roles across, permissions down, a checkbox at every crossing.\n *\n * Every admin panel has one and every admin panel builds it, because the\n * hard part is not the checkboxes. It is that a role can inherit from\n * another, so a box can be ticked without anyone having ticked it; that an\n * Owner has everything and none of it can be unticked; that a section of\n * eight permissions wants one control for all of them; and that sixty\n * checkboxes are sixty tab stops unless something is done about it.\n *\n * Something is done about it. This is a grid in the WAI-ARIA sense — one tab\n * stop, arrow keys between cells, Home and End along a row — which is the\n * right call here and was the wrong one for the diff viewer: a diff is read,\n * a matrix is operated. Every checkbox is named by both coordinates, \"Delete\n * projects for Editor\", so a reader arriving by arrow key knows where they\n * are without re-reading the headers.\n *\n * A grant that arrived by inheritance is shown as a checked box that cannot\n * be unchecked here, with the role it came from said beside it and in the\n * box's description. A disabled control would be the obvious rendering, and\n * it would take the box out of the tab order and the arrow-key path, so a\n * keyboard user would step over the one cell whose state needs explaining.\n *\n * Changes are reported, never applied. A group toggle reports every\n * permission it touched in one call, so an application can save it as one\n * change rather than eight.\n */\n\nexport interface PermissionMatrixProps extends Omit<\n ComponentPropsWithRef<\"table\">,\n \"onChange\"\n> {\n /** Names the matrix: \"Permissions for Acme\". */\n label: string;\n roles: Role[];\n permissions: Permission[];\n grants: Grants;\n /**\n * A change to make. Several ids arrive together when a group toggle was\n * used. Omit to render the matrix as a record.\n */\n onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;\n}\n\ntype MatrixRow =\n | { kind: \"group\"; label: string; permissions: Permission[] }\n | { kind: \"permission\"; permission: Permission };\n\nconst ARROWS: Record<string, [number, number]> = {\n ArrowUp: [-1, 0],\n ArrowDown: [1, 0],\n ArrowLeft: [0, -1],\n ArrowRight: [0, 1],\n};\n\nexport function PermissionMatrix({\n className,\n label,\n roles,\n permissions,\n grants,\n onChange,\n ...props\n}: PermissionMatrixProps) {\n const uid = useId();\n\n const rows = useMemo<MatrixRow[]>(\n () =>\n groupPermissions(permissions).flatMap((group): MatrixRow[] => [\n ...(group.group !== undefined\n ? [{ kind: \"group\" as const, label: group.group, permissions: group.permissions }]\n : []),\n ...group.permissions.map((permission) => ({ kind: \"permission\" as const, permission })),\n ]),\n [permissions],\n );\n\n // One tab stop. The cell that last had focus is the one Tab returns to.\n const [active, setActive] = useState<[number, number]>([0, 0]);\n\n const onKeyDown = (event: KeyboardEvent<HTMLTableElement>) => {\n const target = event.target as HTMLElement;\n const row = Number(target.dataset.row);\n const col = Number(target.dataset.col);\n if (Number.isNaN(row) || Number.isNaN(col)) return;\n\n let next: [number, number] | null = null;\n const arrow = ARROWS[event.key];\n if (arrow) {\n next = [row + arrow[0], col + arrow[1]];\n } else if (event.key === \"Home\") {\n next = event.ctrlKey ? [0, 0] : [row, 0];\n } else if (event.key === \"End\") {\n next = event.ctrlKey ? [rows.length - 1, roles.length - 1] : [row, roles.length - 1];\n }\n if (!next) return;\n\n const [nextRow, nextCol] = next;\n if (nextRow < 0 || nextRow >= rows.length || nextCol < 0 || nextCol >= roles.length) {\n event.preventDefault();\n return;\n }\n event.preventDefault();\n const cell = event.currentTarget.querySelector<HTMLElement>(\n `[data-row=\"${String(nextRow)}\"][data-col=\"${String(nextCol)}\"]`,\n );\n cell?.focus();\n };\n\n return (\n <div className=\"relative w-full overflow-x-auto\">\n <table\n data-slot=\"permission-matrix\"\n role=\"grid\"\n aria-label={label}\n aria-readonly={onChange ? undefined : true}\n onKeyDown={onKeyDown}\n className={cn(\"w-full border-collapse text-sm\", className)}\n {...props}\n >\n <thead>\n <tr role=\"row\" className=\"border-b border-border\">\n <th\n role=\"columnheader\"\n scope=\"col\"\n className=\"px-3 py-2 text-start text-xs font-medium\"\n >\n Permission\n </th>\n {roles.map((role) => {\n const granted = countGranted(roles, grants, role, permissions);\n return (\n <th\n key={role.id}\n role=\"columnheader\"\n scope=\"col\"\n className=\"px-3 py-2 text-center text-xs font-medium\"\n >\n {role.label}\n <span className=\"block font-normal text-muted-foreground\">\n {role.locked ? (\n \"all, locked\"\n ) : (\n <>\n {String(granted)}/{String(permissions.length)}\n <span className=\"sr-only\"> granted</span>\n </>\n )}\n </span>\n </th>\n );\n })}\n </tr>\n </thead>\n <tbody>\n {rows.map((row, rowIndex) =>\n row.kind === \"group\" ? (\n <tr\n key={`group-${row.label}`}\n role=\"row\"\n className=\"border-b border-border bg-muted/40\"\n >\n <th\n role=\"rowheader\"\n scope=\"row\"\n className=\"px-3 py-1.5 text-start text-xs font-medium\"\n >\n {row.label}\n <span className=\"sr-only\">, all</span>\n </th>\n {roles.map((role, colIndex) => (\n <GroupCell\n key={role.id}\n role={role}\n roles={roles}\n grants={grants}\n group={row.label}\n permissions={row.permissions}\n position={[rowIndex, colIndex]}\n active={active[0] === rowIndex && active[1] === colIndex}\n onActivate={setActive}\n onChange={onChange}\n />\n ))}\n </tr>\n ) : (\n <tr\n key={row.permission.id}\n role=\"row\"\n className=\"border-b border-border last:border-0 hover:bg-muted/30\"\n >\n <th\n role=\"rowheader\"\n scope=\"row\"\n className={cn(\n \"px-3 py-2 text-start font-normal\",\n row.permission.group !== undefined && \"ps-6\",\n )}\n >\n {row.permission.label}\n {row.permission.description ? (\n <span className=\"block text-xs text-muted-foreground\">\n {row.permission.description}\n </span>\n ) : null}\n </th>\n {roles.map((role, colIndex) => (\n <PermissionCell\n key={role.id}\n id={`${uid}-${role.id}-${row.permission.id}`}\n role={role}\n roles={roles}\n grants={grants}\n permission={row.permission}\n position={[rowIndex, colIndex]}\n active={active[0] === rowIndex && active[1] === colIndex}\n onActivate={setActive}\n onChange={onChange}\n />\n ))}\n </tr>\n ),\n )}\n </tbody>\n </table>\n </div>\n );\n}\n\ninterface CellProps {\n role: Role;\n roles: Role[];\n grants: Grants;\n position: [number, number];\n active: boolean;\n onActivate: (position: [number, number]) => void;\n onChange?: (roleId: string, permissionIds: string[], granted: boolean) => void;\n}\n\nfunction PermissionCell({\n id,\n role,\n roles,\n grants,\n permission,\n position,\n active,\n onActivate,\n onChange,\n}: CellProps & { id: string; permission: Permission }) {\n const state = cellState(roles, grants, role, permission.id);\n const fixed = state.fixed || !onChange;\n const noteId = `${id}-note`;\n\n const note = role.locked\n ? `${role.label} has every permission; this cannot be changed.`\n : state.inheritedFrom\n ? `Inherited from ${state.inheritedFrom.label}.`\n : null;\n\n return (\n <td\n role=\"gridcell\"\n data-slot=\"permission-cell\"\n data-granted={state.granted || undefined}\n data-inherited={state.inheritedFrom ? \"\" : undefined}\n className=\"px-3 py-2 text-center align-middle\"\n >\n <Checkbox\n id={id}\n checked={state.granted}\n aria-label={`${permission.label} for ${role.label}`}\n aria-describedby={note ? noteId : undefined}\n // Fixed cells stay focusable, so the arrow-key path and the tab order\n // do not step over the one cell whose state needs explaining.\n aria-disabled={fixed || undefined}\n tabIndex={active ? 0 : -1}\n data-row={position[0]}\n data-col={position[1]}\n className={cn(fixed && \"opacity-55\")}\n onFocus={() => {\n onActivate(position);\n }}\n onCheckedChange={(next) => {\n if (fixed) return;\n onChange?.(role.id, [permission.id], next === true);\n }}\n />\n {state.inheritedFrom ? (\n <span id={noteId} className=\"mt-0.5 block text-2xs text-muted-foreground\">\n via {state.inheritedFrom.label}\n </span>\n ) : note ? (\n <span id={noteId} className=\"sr-only\">\n {note}\n </span>\n ) : null}\n </td>\n );\n}\n\nfunction GroupCell({\n role,\n roles,\n grants,\n group,\n permissions,\n position,\n active,\n onActivate,\n onChange,\n}: CellProps & { group: string; permissions: Permission[] }) {\n const ids = permissions.map((permission) => permission.id);\n const state = groupState(roles, grants, role, ids);\n // Only what can actually change here: inherited grants stay either way.\n const changeable = permissions\n .filter((permission) => !cellState(roles, grants, role, permission.id).fixed)\n .map((permission) => permission.id);\n const fixed = role.locked || !onChange || changeable.length === 0;\n\n return (\n <td\n role=\"gridcell\"\n data-slot=\"permission-group-cell\"\n className=\"px-3 py-1.5 text-center align-middle\"\n >\n <Checkbox\n checked={state === \"all\" ? true : state === \"some\" ? \"indeterminate\" : false}\n aria-label={`All ${group} for ${role.label}`}\n aria-disabled={fixed || undefined}\n tabIndex={active ? 0 : -1}\n data-row={position[0]}\n data-col={position[1]}\n className={cn(fixed && \"opacity-55\")}\n onFocus={() => {\n onActivate(position);\n }}\n onCheckedChange={(next) => {\n if (fixed) return;\n onChange?.(role.id, changeable, next === true);\n }}\n />\n </td>\n );\n}\n"],"mappings":";;;;;;;AAuEA,MAAM,SAA2C;CAC/C,SAAS,CAAC,IAAI,CAAC;CACf,WAAW,CAAC,GAAG,CAAC;CAChB,WAAW,CAAC,GAAG,EAAE;CACjB,YAAY,CAAC,GAAG,CAAC;AACnB;AAEA,SAAgB,iBAAiB,EAC/B,WACA,OACA,OACA,aACA,QACA,UACA,GAAG,SACqB;CACxB,MAAM,MAAM,MAAM;CAElB,MAAM,OAAO,cAET,iBAAiB,WAAW,CAAC,CAAC,SAAS,UAAuB,CAC5D,GAAI,MAAM,UAAU,KAAA,IAChB,CAAC;EAAE,MAAM;EAAkB,OAAO,MAAM;EAAO,aAAa,MAAM;CAAY,CAAC,IAC/E,CAAC,GACL,GAAG,MAAM,YAAY,KAAK,gBAAgB;EAAE,MAAM;EAAuB;CAAW,EAAE,CACxF,CAAC,GACH,CAAC,WAAW,CACd;CAGA,MAAM,CAAC,QAAQ,aAAa,SAA2B,CAAC,GAAG,CAAC,CAAC;CAE7D,MAAM,aAAa,UAA2C;EAC5D,MAAM,SAAS,MAAM;EACrB,MAAM,MAAM,OAAO,OAAO,QAAQ,GAAG;EACrC,MAAM,MAAM,OAAO,OAAO,QAAQ,GAAG;EACrC,IAAI,OAAO,MAAM,GAAG,KAAK,OAAO,MAAM,GAAG,GAAG;EAE5C,IAAI,OAAgC;EACpC,MAAM,QAAQ,OAAO,MAAM;EAC3B,IAAI,OACF,OAAO,CAAC,MAAM,MAAM,IAAI,MAAM,MAAM,EAAE;OACjC,IAAI,MAAM,QAAQ,QACvB,OAAO,MAAM,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC;OAClC,IAAI,MAAM,QAAQ,OACvB,OAAO,MAAM,UAAU,CAAC,KAAK,SAAS,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,KAAK,MAAM,SAAS,CAAC;EAErF,IAAI,CAAC,MAAM;EAEX,MAAM,CAAC,SAAS,WAAW;EAC3B,IAAI,UAAU,KAAK,WAAW,KAAK,UAAU,UAAU,KAAK,WAAW,MAAM,QAAQ;GACnF,MAAM,eAAe;GACrB;EACF;EACA,MAAM,eAAe;EAIrB,MAHmB,cAAc,cAC/B,cAAc,OAAO,OAAO,EAAE,eAAe,OAAO,OAAO,EAAE,GAE5D,CAAC,EAAE,MAAM;CACd;CAEA,OACE,oBAAC,OAAD;EAAK,WAAU;EACb,UAAA,qBAAC,SAAD;GACE,aAAU;GACV,MAAK;GACL,cAAY;GACZ,iBAAe,WAAW,KAAA,IAAY;GAC3B;GACX,WAAW,GAAG,kCAAkC,SAAS;GACzD,GAAI;GAPN,UAAA,CASE,oBAAC,SAAD,EAAA,UACE,qBAAC,MAAD;IAAI,MAAK;IAAM,WAAU;IAAzB,UAAA,CACE,oBAAC,MAAD;KACE,MAAK;KACL,OAAM;KACN,WAAU;KACX,UAAA;IAEG,CAAA,GACH,MAAM,KAAK,SAAS;KACnB,MAAM,UAAU,aAAa,OAAO,QAAQ,MAAM,WAAW;KAC7D,OACE,qBAAC,MAAD;MAEE,MAAK;MACL,OAAM;MACN,WAAU;MAJZ,UAAA,CAMG,KAAK,OACN,oBAAC,QAAD;OAAM,WAAU;OACb,UAAA,KAAK,SACJ,gBAEA,qBAAA,UAAA,EAAA,UAAA;QACG,OAAO,OAAO;QAAE;QAAE,OAAO,YAAY,MAAM;QAC5C,oBAAC,QAAD;SAAM,WAAU;SAAU,UAAA;QAAc,CAAA;OACxC,EAAA,CAAA;MAEA,CAAA,CACJ;KAhBG,GAAA,KAAK,EAgBR;IAER,CAAC,CACC;GACC,CAAA,EAAA,CAAA,GACP,oBAAC,SAAD,EAAA,UACG,KAAK,KAAK,KAAK,aACd,IAAI,SAAS,UACX,qBAAC,MAAD;IAEE,MAAK;IACL,WAAU;IAHZ,UAAA,CAKE,qBAAC,MAAD;KACE,MAAK;KACL,OAAM;KACN,WAAU;KAHZ,UAAA,CAKG,IAAI,OACL,oBAAC,QAAD;MAAM,WAAU;MAAU,UAAA;KAAW,CAAA,CACnC;IACH,CAAA,GAAA,MAAM,KAAK,MAAM,aAChB,oBAAC,WAAD;KAEQ;KACC;KACC;KACR,OAAO,IAAI;KACX,aAAa,IAAI;KACjB,UAAU,CAAC,UAAU,QAAQ;KAC7B,QAAQ,OAAO,OAAO,YAAY,OAAO,OAAO;KAChD,YAAY;KACF;IACX,GAVM,KAAK,EAUX,CACF,CACC;GA1BG,GAAA,SAAS,IAAI,OA0BhB,IAEJ,qBAAC,MAAD;IAEE,MAAK;IACL,WAAU;IAHZ,UAAA,CAKE,qBAAC,MAAD;KACE,MAAK;KACL,OAAM;KACN,WAAW,GACT,oCACA,IAAI,WAAW,UAAU,KAAA,KAAa,MACxC;KANF,UAAA,CAQG,IAAI,WAAW,OACf,IAAI,WAAW,cACd,oBAAC,QAAD;MAAM,WAAU;MACb,UAAA,IAAI,WAAW;KACZ,CAAA,IACJ,IACF;IACH,CAAA,GAAA,MAAM,KAAK,MAAM,aAChB,oBAAC,gBAAD;KAEE,IAAI,GAAG,IAAI,GAAG,KAAK,GAAG,GAAG,IAAI,WAAW;KAClC;KACC;KACC;KACR,YAAY,IAAI;KAChB,UAAU,CAAC,UAAU,QAAQ;KAC7B,QAAQ,OAAO,OAAO,YAAY,OAAO,OAAO;KAChD,YAAY;KACF;IACX,GAVM,KAAK,EAUX,CACF,CACC;GAjCG,GAAA,IAAI,WAAW,EAiClB,CAER,EACK,CAAA,CACF;;CACJ,CAAA;AAET;AAYA,SAAS,eAAe,EACtB,IACA,MACA,OACA,QACA,YACA,UACA,QACA,YACA,YACqD;CACrD,MAAM,QAAQ,UAAU,OAAO,QAAQ,MAAM,WAAW,EAAE;CAC1D,MAAM,QAAQ,MAAM,SAAS,CAAC;CAC9B,MAAM,SAAS,GAAG,GAAG;CAErB,MAAM,OAAO,KAAK,SACd,GAAG,KAAK,MAAM,kDACd,MAAM,gBACJ,kBAAkB,MAAM,cAAc,MAAM,KAC5C;CAEN,OACE,qBAAC,MAAD;EACE,MAAK;EACL,aAAU;EACV,gBAAc,MAAM,WAAW,KAAA;EAC/B,kBAAgB,MAAM,gBAAgB,KAAK,KAAA;EAC3C,WAAU;EALZ,UAAA,CAOE,oBAAC,UAAD;GACM;GACJ,SAAS,MAAM;GACf,cAAY,GAAG,WAAW,MAAM,OAAO,KAAK;GAC5C,oBAAkB,OAAO,SAAS,KAAA;GAGlC,iBAAe,SAAS,KAAA;GACxB,UAAU,SAAS,IAAI;GACvB,YAAU,SAAS;GACnB,YAAU,SAAS;GACnB,WAAW,GAAG,SAAS,YAAY;GACnC,eAAe;IACb,WAAW,QAAQ;GACrB;GACA,kBAAkB,SAAS;IACzB,IAAI,OAAO;IACX,WAAW,KAAK,IAAI,CAAC,WAAW,EAAE,GAAG,SAAS,IAAI;GACpD;EACD,CAAA,GACA,MAAM,gBACL,qBAAC,QAAD;GAAM,IAAI;GAAQ,WAAU;GAA5B,UAAA,CAA0E,QACnE,MAAM,cAAc,KACrB;EACJ,CAAA,IAAA,OACF,oBAAC,QAAD;GAAM,IAAI;GAAQ,WAAU;GACzB,UAAA;EACG,CAAA,IACJ,IACF;;AAER;AAEA,SAAS,UAAU,EACjB,MACA,OACA,QACA,OACA,aACA,UACA,QACA,YACA,YAC2D;CAC3D,MAAM,MAAM,YAAY,KAAK,eAAe,WAAW,EAAE;CACzD,MAAM,QAAQ,WAAW,OAAO,QAAQ,MAAM,GAAG;CAEjD,MAAM,aAAa,YAChB,QAAQ,eAAe,CAAC,UAAU,OAAO,QAAQ,MAAM,WAAW,EAAE,CAAC,CAAC,KAAK,CAAC,CAC5E,KAAK,eAAe,WAAW,EAAE;CACpC,MAAM,QAAQ,KAAK,UAAU,CAAC,YAAY,WAAW,WAAW;CAEhE,OACE,oBAAC,MAAD;EACE,MAAK;EACL,aAAU;EACV,WAAU;EAEV,UAAA,oBAAC,UAAD;GACE,SAAS,UAAU,QAAQ,OAAO,UAAU,SAAS,kBAAkB;GACvE,cAAY,OAAO,MAAM,OAAO,KAAK;GACrC,iBAAe,SAAS,KAAA;GACxB,UAAU,SAAS,IAAI;GACvB,YAAU,SAAS;GACnB,YAAU,SAAS;GACnB,WAAW,GAAG,SAAS,YAAY;GACnC,eAAe;IACb,WAAW,QAAQ;GACrB;GACA,kBAAkB,SAAS;IACzB,IAAI,OAAO;IACX,WAAW,KAAK,IAAI,YAAY,SAAS,IAAI;GAC/C;EACD,CAAA;CACC,CAAA;AAER"}
@@ -0,0 +1,53 @@
1
+ //#region src/components/permission-matrix/permission-model.d.ts
2
+ /**
3
+ * Roles, permissions and who has what — resolved, so the grid only draws.
4
+ *
5
+ * Pure, so inheritance and the group states are tested without rendering
6
+ * anything, and so a server can answer "may this role do this" from the same
7
+ * grants the matrix edits, with the same rule for inheritance.
8
+ */
9
+ interface Permission {
10
+ id: string;
11
+ label: string;
12
+ description?: string;
13
+ /** Permissions with the same group share a heading and an all-or-none toggle. */
14
+ group?: string;
15
+ }
16
+ interface Role {
17
+ id: string;
18
+ label: string;
19
+ /** Roles whose grants this one includes. Resolved transitively. */
20
+ inherits?: string[];
21
+ /** Has every permission, and none of them can be changed. An Owner. */
22
+ locked?: boolean;
23
+ }
24
+ /** Explicit grants, by role id. Inherited ones are not listed here. */
25
+ type Grants = Record<string, string[]>;
26
+ interface CellState {
27
+ /** Whether the role can do it, by any route. */
28
+ granted: boolean;
29
+ /** The role it comes from, when it is not explicit here. */
30
+ inheritedFrom: Role | null;
31
+ /** Cannot be changed here — locked role, or inherited. */
32
+ fixed: boolean;
33
+ }
34
+ type GroupState = "all" | "some" | "none";
35
+ /**
36
+ * The role a permission is inherited from, or null. Depth-first through
37
+ * `inherits`, cycle-safe, so two roles that inherit each other resolve rather
38
+ * than recurse forever — a configuration a form can produce by accident.
39
+ */
40
+ declare function inheritedFrom(roles: Role[], grants: Grants, role: Role, permissionId: string, seen?: Set<string>): Role | null;
41
+ declare function cellState(roles: Role[], grants: Grants, role: Role, permissionId: string): CellState;
42
+ declare function groupState(roles: Role[], grants: Grants, role: Role, permissionIds: string[]): GroupState;
43
+ declare function countGranted(roles: Role[], grants: Grants, role: Role, permissions: Permission[]): number;
44
+ interface PermissionGroup {
45
+ /** Undefined for permissions declared without one. */
46
+ group: string | undefined;
47
+ permissions: Permission[];
48
+ }
49
+ /** Permissions by group, in the order the groups first appear. */
50
+ declare function groupPermissions(permissions: Permission[]): PermissionGroup[];
51
+ //#endregion
52
+ export { CellState, Grants, GroupState, Permission, PermissionGroup, Role, cellState, countGranted, groupPermissions, groupState, inheritedFrom };
53
+ //# sourceMappingURL=permission-model.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-model.d.ts","names":[],"sources":["../../../src/components/permission-matrix/permission-model.ts"],"mappings":";;;;;;;;UAQiB;EACf;EACA;EACA;;EAEA;;UAGe;EACf;EACA;;EAEA;;EAEA;;;KAIU,SAAS;UAEJ;;EAEf;;EAEA,eAAe;;EAEf;;KAGU;;;;;;iBAWI,cACd,OAAO,QACP,QAAQ,QACR,MAAM,MACN,sBACA,OAAM,cACL;iBAaa,UACd,OAAO,QACP,QAAQ,QACR,MAAM,MACN,uBACC;iBAQa,WACd,OAAO,QACP,QAAQ,QACR,MAAM,MACN,0BACC;iBAQa,aACd,OAAO,QACP,QAAQ,QACR,MAAM,MACN,aAAa;UAOE;;EAEf;EACA,aAAa;;;iBAIC,iBAAiB,aAAa,eAAe"}
@@ -0,0 +1,64 @@
1
+ //#region src/components/permission-matrix/permission-model.ts
2
+ function roleById(roles, id) {
3
+ return roles.find((role) => role.id === id);
4
+ }
5
+ /**
6
+ * The role a permission is inherited from, or null. Depth-first through
7
+ * `inherits`, cycle-safe, so two roles that inherit each other resolve rather
8
+ * than recurse forever — a configuration a form can produce by accident.
9
+ */
10
+ function inheritedFrom(roles, grants, role, permissionId, seen = /* @__PURE__ */ new Set()) {
11
+ seen.add(role.id);
12
+ for (const parentId of role.inherits ?? []) {
13
+ if (seen.has(parentId)) continue;
14
+ const parent = roleById(roles, parentId);
15
+ if (!parent) continue;
16
+ if (parent.locked || (grants[parent.id] ?? []).includes(permissionId)) return parent;
17
+ const further = inheritedFrom(roles, grants, parent, permissionId, seen);
18
+ if (further) return further;
19
+ }
20
+ return null;
21
+ }
22
+ function cellState(roles, grants, role, permissionId) {
23
+ if (role.locked) return {
24
+ granted: true,
25
+ inheritedFrom: null,
26
+ fixed: true
27
+ };
28
+ if ((grants[role.id] ?? []).includes(permissionId)) return {
29
+ granted: true,
30
+ inheritedFrom: null,
31
+ fixed: false
32
+ };
33
+ const from = inheritedFrom(roles, grants, role, permissionId);
34
+ return {
35
+ granted: from !== null,
36
+ inheritedFrom: from,
37
+ fixed: from !== null
38
+ };
39
+ }
40
+ function groupState(roles, grants, role, permissionIds) {
41
+ const granted = permissionIds.filter((id) => cellState(roles, grants, role, id).granted).length;
42
+ if (granted === 0) return "none";
43
+ return granted === permissionIds.length ? "all" : "some";
44
+ }
45
+ function countGranted(roles, grants, role, permissions) {
46
+ return permissions.filter((permission) => cellState(roles, grants, role, permission.id).granted).length;
47
+ }
48
+ /** Permissions by group, in the order the groups first appear. */
49
+ function groupPermissions(permissions) {
50
+ const groups = [];
51
+ for (const permission of permissions) {
52
+ const existing = groups.find((candidate) => candidate.group === permission.group);
53
+ if (existing) existing.permissions.push(permission);
54
+ else groups.push({
55
+ group: permission.group,
56
+ permissions: [permission]
57
+ });
58
+ }
59
+ return groups;
60
+ }
61
+ //#endregion
62
+ export { cellState, countGranted, groupPermissions, groupState, inheritedFrom };
63
+
64
+ //# sourceMappingURL=permission-model.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"permission-model.js","names":[],"sources":["../../../src/components/permission-matrix/permission-model.ts"],"sourcesContent":["/**\n * Roles, permissions and who has what — resolved, so the grid only draws.\n *\n * Pure, so inheritance and the group states are tested without rendering\n * anything, and so a server can answer \"may this role do this\" from the same\n * grants the matrix edits, with the same rule for inheritance.\n */\n\nexport interface Permission {\n id: string;\n label: string;\n description?: string;\n /** Permissions with the same group share a heading and an all-or-none toggle. */\n group?: string;\n}\n\nexport interface Role {\n id: string;\n label: string;\n /** Roles whose grants this one includes. Resolved transitively. */\n inherits?: string[];\n /** Has every permission, and none of them can be changed. An Owner. */\n locked?: boolean;\n}\n\n/** Explicit grants, by role id. Inherited ones are not listed here. */\nexport type Grants = Record<string, string[]>;\n\nexport interface CellState {\n /** Whether the role can do it, by any route. */\n granted: boolean;\n /** The role it comes from, when it is not explicit here. */\n inheritedFrom: Role | null;\n /** Cannot be changed here — locked role, or inherited. */\n fixed: boolean;\n}\n\nexport type GroupState = \"all\" | \"some\" | \"none\";\n\nfunction roleById(roles: Role[], id: string): Role | undefined {\n return roles.find((role) => role.id === id);\n}\n\n/**\n * The role a permission is inherited from, or null. Depth-first through\n * `inherits`, cycle-safe, so two roles that inherit each other resolve rather\n * than recurse forever — a configuration a form can produce by accident.\n */\nexport function inheritedFrom(\n roles: Role[],\n grants: Grants,\n role: Role,\n permissionId: string,\n seen: Set<string> = new Set(),\n): Role | null {\n seen.add(role.id);\n for (const parentId of role.inherits ?? []) {\n if (seen.has(parentId)) continue;\n const parent = roleById(roles, parentId);\n if (!parent) continue;\n if (parent.locked || (grants[parent.id] ?? []).includes(permissionId)) return parent;\n const further = inheritedFrom(roles, grants, parent, permissionId, seen);\n if (further) return further;\n }\n return null;\n}\n\nexport function cellState(\n roles: Role[],\n grants: Grants,\n role: Role,\n permissionId: string,\n): CellState {\n if (role.locked) return { granted: true, inheritedFrom: null, fixed: true };\n const explicit = (grants[role.id] ?? []).includes(permissionId);\n if (explicit) return { granted: true, inheritedFrom: null, fixed: false };\n const from = inheritedFrom(roles, grants, role, permissionId);\n return { granted: from !== null, inheritedFrom: from, fixed: from !== null };\n}\n\nexport function groupState(\n roles: Role[],\n grants: Grants,\n role: Role,\n permissionIds: string[],\n): GroupState {\n const granted = permissionIds.filter(\n (id) => cellState(roles, grants, role, id).granted,\n ).length;\n if (granted === 0) return \"none\";\n return granted === permissionIds.length ? \"all\" : \"some\";\n}\n\nexport function countGranted(\n roles: Role[],\n grants: Grants,\n role: Role,\n permissions: Permission[],\n): number {\n return permissions.filter(\n (permission) => cellState(roles, grants, role, permission.id).granted,\n ).length;\n}\n\nexport interface PermissionGroup {\n /** Undefined for permissions declared without one. */\n group: string | undefined;\n permissions: Permission[];\n}\n\n/** Permissions by group, in the order the groups first appear. */\nexport function groupPermissions(permissions: Permission[]): PermissionGroup[] {\n const groups: PermissionGroup[] = [];\n for (const permission of permissions) {\n const existing = groups.find((candidate) => candidate.group === permission.group);\n if (existing) existing.permissions.push(permission);\n else groups.push({ group: permission.group, permissions: [permission] });\n }\n return groups;\n}\n"],"mappings":";AAuCA,SAAS,SAAS,OAAe,IAA8B;CAC7D,OAAO,MAAM,MAAM,SAAS,KAAK,OAAO,EAAE;AAC5C;;;;;;AAOA,SAAgB,cACd,OACA,QACA,MACA,cACA,uBAAoB,IAAI,IAAI,GACf;CACb,KAAK,IAAI,KAAK,EAAE;CAChB,KAAK,MAAM,YAAY,KAAK,YAAY,CAAC,GAAG;EAC1C,IAAI,KAAK,IAAI,QAAQ,GAAG;EACxB,MAAM,SAAS,SAAS,OAAO,QAAQ;EACvC,IAAI,CAAC,QAAQ;EACb,IAAI,OAAO,WAAW,OAAO,OAAO,OAAO,CAAC,EAAA,CAAG,SAAS,YAAY,GAAG,OAAO;EAC9E,MAAM,UAAU,cAAc,OAAO,QAAQ,QAAQ,cAAc,IAAI;EACvE,IAAI,SAAS,OAAO;CACtB;CACA,OAAO;AACT;AAEA,SAAgB,UACd,OACA,QACA,MACA,cACW;CACX,IAAI,KAAK,QAAQ,OAAO;EAAE,SAAS;EAAM,eAAe;EAAM,OAAO;CAAK;CAE1E,KADkB,OAAO,KAAK,OAAO,CAAC,EAAA,CAAG,SAAS,YACvC,GAAG,OAAO;EAAE,SAAS;EAAM,eAAe;EAAM,OAAO;CAAM;CACxE,MAAM,OAAO,cAAc,OAAO,QAAQ,MAAM,YAAY;CAC5D,OAAO;EAAE,SAAS,SAAS;EAAM,eAAe;EAAM,OAAO,SAAS;CAAK;AAC7E;AAEA,SAAgB,WACd,OACA,QACA,MACA,eACY;CACZ,MAAM,UAAU,cAAc,QAC3B,OAAO,UAAU,OAAO,QAAQ,MAAM,EAAE,CAAC,CAAC,OAC7C,CAAC,CAAC;CACF,IAAI,YAAY,GAAG,OAAO;CAC1B,OAAO,YAAY,cAAc,SAAS,QAAQ;AACpD;AAEA,SAAgB,aACd,OACA,QACA,MACA,aACQ;CACR,OAAO,YAAY,QAChB,eAAe,UAAU,OAAO,QAAQ,MAAM,WAAW,EAAE,CAAC,CAAC,OAChE,CAAC,CAAC;AACJ;;AASA,SAAgB,iBAAiB,aAA8C;CAC7E,MAAM,SAA4B,CAAC;CACnC,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,WAAW,OAAO,MAAM,cAAc,UAAU,UAAU,WAAW,KAAK;EAChF,IAAI,UAAU,SAAS,YAAY,KAAK,UAAU;OAC7C,OAAO,KAAK;GAAE,OAAO,WAAW;GAAO,aAAa,CAAC,UAAU;EAAE,CAAC;CACzE;CACA,OAAO;AACT"}
@@ -121,17 +121,17 @@ function RecordDiffTable({ className, collapseUnchanged = true, ...props }) {
121
121
  children: [
122
122
  /* @__PURE__ */ jsx("th", {
123
123
  scope: "col",
124
- className: "px-3 py-2 text-left text-xs font-medium text-muted-foreground",
124
+ className: "px-3 py-2 text-start text-xs font-medium text-muted-foreground",
125
125
  children: "Field"
126
126
  }),
127
127
  /* @__PURE__ */ jsx("th", {
128
128
  scope: "col",
129
- className: "px-3 py-2 text-left text-xs font-medium text-muted-foreground",
129
+ className: "px-3 py-2 text-start text-xs font-medium text-muted-foreground",
130
130
  children: "Before"
131
131
  }),
132
132
  /* @__PURE__ */ jsx("th", {
133
133
  scope: "col",
134
- className: "px-3 py-2 text-left text-xs font-medium text-muted-foreground",
134
+ className: "px-3 py-2 text-start text-xs font-medium text-muted-foreground",
135
135
  children: "After"
136
136
  })
137
137
  ]
@@ -142,7 +142,7 @@ function RecordDiffTable({ className, collapseUnchanged = true, ...props }) {
142
142
  children: [
143
143
  /* @__PURE__ */ jsxs("th", {
144
144
  scope: "row",
145
- className: "px-3 py-2 text-left align-top font-medium whitespace-nowrap",
145
+ className: "px-3 py-2 text-start align-top font-medium whitespace-nowrap",
146
146
  children: [field.label, /* @__PURE__ */ jsx("span", {
147
147
  className: "sr-only",
148
148
  children: `, ${field.change}`
@@ -1 +1 @@
1
- {"version":3,"file":"record-diff.js","names":[],"sources":["../../../src/components/record-diff/record-diff.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useMemo,\n useState,\n type ComponentPropsWithRef,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * What changed about a record, field by field.\n *\n * Every diff package on npm compares lines of text. That is the wrong shape for\n * an audit entry, a settings revision or a config history, where the change is\n * \"role went from viewer to admin\" and rendering it as two lines of JSON makes\n * the reader do the comparison themselves.\n *\n * Scope is deliberately flat records. Nested objects and arrays are formatted as\n * values rather than recursed into, because a recursive differ is a different\n * and much larger component, and pretending otherwise would produce a shallow\n * one that quietly lies about deep changes. `formatValue` is the escape hatch.\n *\n * Redaction is by key pattern and applied before formatting, so a secret is\n * never handed to a formatter that might log it.\n */\n\nexport type FieldChange = \"added\" | \"removed\" | \"changed\" | \"unchanged\";\n\nexport interface RecordField {\n key: string;\n label: string;\n change: FieldChange;\n before: unknown;\n after: unknown;\n redacted: boolean;\n}\n\n/** Values that read as absent, so \"\" → null is not reported as a change. */\nfunction isEmpty(value: unknown): boolean {\n return value === null || value === undefined || value === \"\";\n}\n\nfunction classify(before: unknown, after: unknown): FieldChange {\n const hadBefore = !isEmpty(before);\n const hasAfter = !isEmpty(after);\n\n if (!hadBefore && hasAfter) return \"added\";\n if (hadBefore && !hasAfter) return \"removed\";\n // Both absent. Falling through would compare \"\" against null, which\n // serialise differently and would report a change that did not happen.\n if (!hadBefore && !hasAfter) return \"unchanged\";\n // Structural comparison, so {a:1} and {a:1} are equal. Key order differences\n // would report a false change, so keys are sorted before comparing.\n return stableStringify(before) === stableStringify(after) ? \"unchanged\" : \"changed\";\n}\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value) ?? \"\";\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;\n const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>\n a.localeCompare(b),\n );\n return `{${entries.map(([k, v]) => `${k}:${stableStringify(v)}`).join(\",\")}}`;\n}\n\nfunction defaultFormatValue(value: unknown): string {\n if (value === null || value === undefined) return \"—\";\n if (value === \"\") return \"empty\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"boolean\") return value ? \"yes\" : \"no\";\n if (typeof value === \"number\" || typeof value === \"bigint\") return String(value);\n if (Array.isArray(value)) {\n return value.length === 0 ? \"none\" : value.map(defaultFormatValue).join(\", \");\n }\n if (typeof value === \"object\") return JSON.stringify(value);\n // Symbols and functions have no sensible representation as a field value, and\n // String() would yield \"[object Object]\" or a function body. Say what it is.\n return `[${typeof value}]`;\n}\n\n/** Keys whose values must never be rendered, however the record was built. */\nconst DEFAULT_REDACT = /password|secret|token|api[-_]?key|authorization|credential/i;\n\nexport interface RecordDiffProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n before: Record<string, unknown>;\n after: Record<string, unknown>;\n /** Overrides the humanised key. */\n labels?: Record<string, string>;\n /** Keys to render as redacted. Defaults to common secret-shaped names. */\n redact?: RegExp;\n /** Only these keys, in this order. Anything else is ignored. */\n fields?: string[];\n /** Hides unchanged fields behind a toggle. */\n collapseUnchanged?: boolean;\n formatValue?: (value: unknown, key: string) => string;\n children?: React.ReactNode;\n}\n\ninterface RecordDiffContextValue {\n fields: RecordField[];\n changedCount: number;\n formatValue: (value: unknown, key: string) => string;\n}\n\nconst RecordDiffContext = createContext<RecordDiffContextValue | null>(null);\n\nfunction useRecordDiffContext(component: string): RecordDiffContextValue {\n const context = useContext(RecordDiffContext);\n if (!context) {\n throw new Error(`${component} must be rendered inside <RecordDiff>.`);\n }\n return context;\n}\n\n/** \"billingEmail\" and \"billing_email\" both read as \"Billing email\". */\nfunction humanise(key: string): string {\n const spaced = key\n .replace(/[_-]+/g, \" \")\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .trim();\n return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();\n}\n\nexport function diffRecords(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n options: { labels?: Record<string, string>; redact?: RegExp; fields?: string[] } = {},\n): RecordField[] {\n const { labels = {}, redact = DEFAULT_REDACT, fields } = options;\n\n // The union of both sides, so a removed key is still reported. Iterating only\n // `after` is how removals go missing from audit logs.\n const keys = fields ?? [...new Set([...Object.keys(before), ...Object.keys(after)])];\n\n return keys.map((key) => ({\n key,\n label: labels[key] ?? humanise(key),\n change: classify(before[key], after[key]),\n before: before[key],\n after: after[key],\n redacted: redact.test(key),\n }));\n}\n\nconst CHANGE_STYLES: Record<FieldChange, string> = {\n added: \"text-success\",\n removed: \"text-destructive\",\n changed: \"text-foreground\",\n unchanged: \"text-muted-foreground\",\n};\n\nexport function RecordDiff({\n className,\n before,\n after,\n labels,\n redact = DEFAULT_REDACT,\n fields: fieldKeys,\n collapseUnchanged = true,\n formatValue = defaultFormatValue,\n children,\n ...props\n}: RecordDiffProps) {\n const fields = useMemo(\n () => diffRecords(before, after, { labels, redact, fields: fieldKeys }),\n [before, after, labels, redact, fieldKeys],\n );\n\n const changedCount = fields.filter((field) => field.change !== \"unchanged\").length;\n\n const context = useMemo<RecordDiffContextValue>(\n () => ({ fields, changedCount, formatValue }),\n [fields, changedCount, formatValue],\n );\n\n return (\n <RecordDiffContext.Provider value={context}>\n <div\n data-slot=\"record-diff\"\n data-changed={changedCount}\n className={cn(\"flex flex-col gap-2\", className)}\n {...props}\n >\n {children ?? (\n <>\n <RecordDiffSummary />\n <RecordDiffTable collapseUnchanged={collapseUnchanged} />\n </>\n )}\n </div>\n </RecordDiffContext.Provider>\n );\n}\n\n/** \"3 fields changed\" — the thing a reviewer scanning a log needs first. */\nexport function RecordDiffSummary({ className, ...props }: ComponentPropsWithRef<\"p\">) {\n const { changedCount, fields } = useRecordDiffContext(\"RecordDiffSummary\");\n\n return (\n <p\n data-slot=\"record-diff-summary\"\n className={cn(\"text-xs text-muted-foreground\", className)}\n {...props}\n >\n {changedCount === 0\n ? \"No fields changed\"\n : `${String(changedCount)} of ${String(fields.length)} field${fields.length === 1 ? \"\" : \"s\"} changed`}\n </p>\n );\n}\n\nexport interface RecordDiffTableProps extends ComponentPropsWithRef<\"table\"> {\n collapseUnchanged?: boolean;\n}\n\nexport function RecordDiffTable({\n className,\n collapseUnchanged = true,\n ...props\n}: RecordDiffTableProps) {\n const { fields, formatValue, changedCount } = useRecordDiffContext(\"RecordDiffTable\");\n const [showUnchanged, setShowUnchanged] = useState(!collapseUnchanged);\n\n const unchangedCount = fields.length - changedCount;\n const visible = showUnchanged\n ? fields\n : fields.filter((field) => field.change !== \"unchanged\");\n\n return (\n <div className=\"flex flex-col gap-2\">\n <div className=\"overflow-x-auto\">\n <table\n data-slot=\"record-diff-table\"\n className={cn(\"w-full border-collapse text-sm\", className)}\n {...props}\n >\n <thead>\n <tr className=\"border-b border-border\">\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-left text-xs font-medium text-muted-foreground\"\n >\n Field\n </th>\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-left text-xs font-medium text-muted-foreground\"\n >\n Before\n </th>\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-left text-xs font-medium text-muted-foreground\"\n >\n After\n </th>\n </tr>\n </thead>\n <tbody>\n {visible.map((field) => (\n <tr\n key={field.key}\n data-slot=\"record-diff-row\"\n data-change={field.change}\n className=\"border-b border-border last:border-0\"\n >\n {/* scope=\"row\" so each value is announced with its field name\n rather than as a loose cell in a wall of table. */}\n <th\n scope=\"row\"\n className=\"px-3 py-2 text-left align-top font-medium whitespace-nowrap\"\n >\n {field.label}\n {/* The change kind must not be carried by row colour alone. */}\n <span className=\"sr-only\">{`, ${field.change}`}</span>\n </th>\n <td className=\"px-3 py-2 align-top text-muted-foreground\">\n {field.redacted ? (\n <RedactedValue />\n ) : field.change === \"added\" ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : (\n <span className=\"line-through decoration-muted-foreground/50\">\n {formatValue(field.before, field.key)}\n </span>\n )}\n </td>\n <td className={cn(\"px-3 py-2 align-top\", CHANGE_STYLES[field.change])}>\n {field.redacted ? (\n <RedactedValue />\n ) : field.change === \"removed\" ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : (\n formatValue(field.after, field.key)\n )}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n\n {collapseUnchanged && unchangedCount > 0 ? (\n <button\n type=\"button\"\n data-slot=\"record-diff-toggle\"\n aria-expanded={showUnchanged}\n onClick={() => {\n setShowUnchanged((shown) => !shown);\n }}\n className={cn(\n \"self-start rounded-md px-1.5 py-0.5 text-xs text-muted-foreground\",\n \"transition-colors hover:text-foreground\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring/55\",\n )}\n >\n {showUnchanged\n ? `Hide ${String(unchangedCount)} unchanged`\n : `Show ${String(unchangedCount)} unchanged`}\n </button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A value that is never rendered.\n *\n * Redaction happens before formatting, so a secret is not passed to a formatter\n * that might log or transform it — the value simply does not reach the DOM.\n */\nfunction RedactedValue() {\n return (\n <span data-slot=\"record-diff-redacted\" className=\"text-muted-foreground italic\">\n redacted\n </span>\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"record-diff.js","names":[],"sources":["../../../src/components/record-diff/record-diff.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useContext,\n useMemo,\n useState,\n type ComponentPropsWithRef,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * What changed about a record, field by field.\n *\n * Every diff package on npm compares lines of text. That is the wrong shape for\n * an audit entry, a settings revision or a config history, where the change is\n * \"role went from viewer to admin\" and rendering it as two lines of JSON makes\n * the reader do the comparison themselves.\n *\n * Scope is deliberately flat records. Nested objects and arrays are formatted as\n * values rather than recursed into, because a recursive differ is a different\n * and much larger component, and pretending otherwise would produce a shallow\n * one that quietly lies about deep changes. `formatValue` is the escape hatch.\n *\n * Redaction is by key pattern and applied before formatting, so a secret is\n * never handed to a formatter that might log it.\n */\n\nexport type FieldChange = \"added\" | \"removed\" | \"changed\" | \"unchanged\";\n\nexport interface RecordField {\n key: string;\n label: string;\n change: FieldChange;\n before: unknown;\n after: unknown;\n redacted: boolean;\n}\n\n/** Values that read as absent, so \"\" → null is not reported as a change. */\nfunction isEmpty(value: unknown): boolean {\n return value === null || value === undefined || value === \"\";\n}\n\nfunction classify(before: unknown, after: unknown): FieldChange {\n const hadBefore = !isEmpty(before);\n const hasAfter = !isEmpty(after);\n\n if (!hadBefore && hasAfter) return \"added\";\n if (hadBefore && !hasAfter) return \"removed\";\n // Both absent. Falling through would compare \"\" against null, which\n // serialise differently and would report a change that did not happen.\n if (!hadBefore && !hasAfter) return \"unchanged\";\n // Structural comparison, so {a:1} and {a:1} are equal. Key order differences\n // would report a false change, so keys are sorted before comparing.\n return stableStringify(before) === stableStringify(after) ? \"unchanged\" : \"changed\";\n}\n\nfunction stableStringify(value: unknown): string {\n if (value === null || typeof value !== \"object\") return JSON.stringify(value) ?? \"\";\n if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;\n const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>\n a.localeCompare(b),\n );\n return `{${entries.map(([k, v]) => `${k}:${stableStringify(v)}`).join(\",\")}}`;\n}\n\nfunction defaultFormatValue(value: unknown): string {\n if (value === null || value === undefined) return \"—\";\n if (value === \"\") return \"empty\";\n if (typeof value === \"string\") return value;\n if (typeof value === \"boolean\") return value ? \"yes\" : \"no\";\n if (typeof value === \"number\" || typeof value === \"bigint\") return String(value);\n if (Array.isArray(value)) {\n return value.length === 0 ? \"none\" : value.map(defaultFormatValue).join(\", \");\n }\n if (typeof value === \"object\") return JSON.stringify(value);\n // Symbols and functions have no sensible representation as a field value, and\n // String() would yield \"[object Object]\" or a function body. Say what it is.\n return `[${typeof value}]`;\n}\n\n/** Keys whose values must never be rendered, however the record was built. */\nconst DEFAULT_REDACT = /password|secret|token|api[-_]?key|authorization|credential/i;\n\nexport interface RecordDiffProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n before: Record<string, unknown>;\n after: Record<string, unknown>;\n /** Overrides the humanised key. */\n labels?: Record<string, string>;\n /** Keys to render as redacted. Defaults to common secret-shaped names. */\n redact?: RegExp;\n /** Only these keys, in this order. Anything else is ignored. */\n fields?: string[];\n /** Hides unchanged fields behind a toggle. */\n collapseUnchanged?: boolean;\n formatValue?: (value: unknown, key: string) => string;\n children?: React.ReactNode;\n}\n\ninterface RecordDiffContextValue {\n fields: RecordField[];\n changedCount: number;\n formatValue: (value: unknown, key: string) => string;\n}\n\nconst RecordDiffContext = createContext<RecordDiffContextValue | null>(null);\n\nfunction useRecordDiffContext(component: string): RecordDiffContextValue {\n const context = useContext(RecordDiffContext);\n if (!context) {\n throw new Error(`${component} must be rendered inside <RecordDiff>.`);\n }\n return context;\n}\n\n/** \"billingEmail\" and \"billing_email\" both read as \"Billing email\". */\nfunction humanise(key: string): string {\n const spaced = key\n .replace(/[_-]+/g, \" \")\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .trim();\n return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();\n}\n\nexport function diffRecords(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n options: { labels?: Record<string, string>; redact?: RegExp; fields?: string[] } = {},\n): RecordField[] {\n const { labels = {}, redact = DEFAULT_REDACT, fields } = options;\n\n // The union of both sides, so a removed key is still reported. Iterating only\n // `after` is how removals go missing from audit logs.\n const keys = fields ?? [...new Set([...Object.keys(before), ...Object.keys(after)])];\n\n return keys.map((key) => ({\n key,\n label: labels[key] ?? humanise(key),\n change: classify(before[key], after[key]),\n before: before[key],\n after: after[key],\n redacted: redact.test(key),\n }));\n}\n\nconst CHANGE_STYLES: Record<FieldChange, string> = {\n added: \"text-success\",\n removed: \"text-destructive\",\n changed: \"text-foreground\",\n unchanged: \"text-muted-foreground\",\n};\n\nexport function RecordDiff({\n className,\n before,\n after,\n labels,\n redact = DEFAULT_REDACT,\n fields: fieldKeys,\n collapseUnchanged = true,\n formatValue = defaultFormatValue,\n children,\n ...props\n}: RecordDiffProps) {\n const fields = useMemo(\n () => diffRecords(before, after, { labels, redact, fields: fieldKeys }),\n [before, after, labels, redact, fieldKeys],\n );\n\n const changedCount = fields.filter((field) => field.change !== \"unchanged\").length;\n\n const context = useMemo<RecordDiffContextValue>(\n () => ({ fields, changedCount, formatValue }),\n [fields, changedCount, formatValue],\n );\n\n return (\n <RecordDiffContext.Provider value={context}>\n <div\n data-slot=\"record-diff\"\n data-changed={changedCount}\n className={cn(\"flex flex-col gap-2\", className)}\n {...props}\n >\n {children ?? (\n <>\n <RecordDiffSummary />\n <RecordDiffTable collapseUnchanged={collapseUnchanged} />\n </>\n )}\n </div>\n </RecordDiffContext.Provider>\n );\n}\n\n/** \"3 fields changed\" — the thing a reviewer scanning a log needs first. */\nexport function RecordDiffSummary({ className, ...props }: ComponentPropsWithRef<\"p\">) {\n const { changedCount, fields } = useRecordDiffContext(\"RecordDiffSummary\");\n\n return (\n <p\n data-slot=\"record-diff-summary\"\n className={cn(\"text-xs text-muted-foreground\", className)}\n {...props}\n >\n {changedCount === 0\n ? \"No fields changed\"\n : `${String(changedCount)} of ${String(fields.length)} field${fields.length === 1 ? \"\" : \"s\"} changed`}\n </p>\n );\n}\n\nexport interface RecordDiffTableProps extends ComponentPropsWithRef<\"table\"> {\n collapseUnchanged?: boolean;\n}\n\nexport function RecordDiffTable({\n className,\n collapseUnchanged = true,\n ...props\n}: RecordDiffTableProps) {\n const { fields, formatValue, changedCount } = useRecordDiffContext(\"RecordDiffTable\");\n const [showUnchanged, setShowUnchanged] = useState(!collapseUnchanged);\n\n const unchangedCount = fields.length - changedCount;\n const visible = showUnchanged\n ? fields\n : fields.filter((field) => field.change !== \"unchanged\");\n\n return (\n <div className=\"flex flex-col gap-2\">\n <div className=\"overflow-x-auto\">\n <table\n data-slot=\"record-diff-table\"\n className={cn(\"w-full border-collapse text-sm\", className)}\n {...props}\n >\n <thead>\n <tr className=\"border-b border-border\">\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-start text-xs font-medium text-muted-foreground\"\n >\n Field\n </th>\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-start text-xs font-medium text-muted-foreground\"\n >\n Before\n </th>\n <th\n scope=\"col\"\n className=\"px-3 py-2 text-start text-xs font-medium text-muted-foreground\"\n >\n After\n </th>\n </tr>\n </thead>\n <tbody>\n {visible.map((field) => (\n <tr\n key={field.key}\n data-slot=\"record-diff-row\"\n data-change={field.change}\n className=\"border-b border-border last:border-0\"\n >\n {/* scope=\"row\" so each value is announced with its field name\n rather than as a loose cell in a wall of table. */}\n <th\n scope=\"row\"\n className=\"px-3 py-2 text-start align-top font-medium whitespace-nowrap\"\n >\n {field.label}\n {/* The change kind must not be carried by row colour alone. */}\n <span className=\"sr-only\">{`, ${field.change}`}</span>\n </th>\n <td className=\"px-3 py-2 align-top text-muted-foreground\">\n {field.redacted ? (\n <RedactedValue />\n ) : field.change === \"added\" ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : (\n <span className=\"line-through decoration-muted-foreground/50\">\n {formatValue(field.before, field.key)}\n </span>\n )}\n </td>\n <td className={cn(\"px-3 py-2 align-top\", CHANGE_STYLES[field.change])}>\n {field.redacted ? (\n <RedactedValue />\n ) : field.change === \"removed\" ? (\n <span className=\"text-muted-foreground\">—</span>\n ) : (\n formatValue(field.after, field.key)\n )}\n </td>\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n\n {collapseUnchanged && unchangedCount > 0 ? (\n <button\n type=\"button\"\n data-slot=\"record-diff-toggle\"\n aria-expanded={showUnchanged}\n onClick={() => {\n setShowUnchanged((shown) => !shown);\n }}\n className={cn(\n \"self-start rounded-md px-1.5 py-0.5 text-xs text-muted-foreground\",\n \"transition-colors hover:text-foreground\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring/55\",\n )}\n >\n {showUnchanged\n ? `Hide ${String(unchangedCount)} unchanged`\n : `Show ${String(unchangedCount)} unchanged`}\n </button>\n ) : null}\n </div>\n );\n}\n\n/**\n * A value that is never rendered.\n *\n * Redaction happens before formatting, so a secret is not passed to a formatter\n * that might log or transform it — the value simply does not reach the DOM.\n */\nfunction RedactedValue() {\n return (\n <span data-slot=\"record-diff-redacted\" className=\"text-muted-foreground italic\">\n redacted\n </span>\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import { SecretField, SecretFieldProps, SecretFieldState, maskSecret } from "./secret-field.js";
2
+ export { SecretField, type SecretFieldProps, type SecretFieldState, maskSecret };
@@ -0,0 +1,2 @@
1
+ import { SecretField, maskSecret } from "./secret-field.js";
2
+ export { SecretField, maskSecret };
@@ -0,0 +1,17 @@
1
+ import { defineMeta } from "../../registry/schema.js";
2
+ //#region src/components/secret-field/meta.ts
3
+ const meta = defineMeta({
4
+ name: "secret-field",
5
+ title: "Secret Field",
6
+ description: "An API key in its three states: shown once, hidden but revealable, and gone.",
7
+ category: "form",
8
+ status: "stable",
9
+ dependencies: [],
10
+ registryDependencies: ["button", "input"],
11
+ files: ["secret-field.tsx"],
12
+ a11y: "The value sits in a read-only input rather than a disabled one, so it can be focused, selected and read, and focusing a shown value selects all of it for copying by hand. The field's state — shown once, hidden, revealed, gone — is a sentence attached as its description, never a colour or an icon. Copying is announced through a status region that exists before the first copy, and failure is announced too, with what to do instead, because the clipboard can be refused. Every button carries the field's label in its name, so a settings page with several keys has several distinct Copy buttons. Regenerating is confirmed inline in a named group that states the consequence, since it revokes the current key."
13
+ });
14
+ //#endregion
15
+ export { meta };
16
+
17
+ //# sourceMappingURL=meta.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"meta.js","names":[],"sources":["../../../src/components/secret-field/meta.ts"],"sourcesContent":["import { defineMeta } from \"@/registry/schema\";\n\nexport const meta = defineMeta({\n name: \"secret-field\",\n title: \"Secret Field\",\n description: \"An API key in its three states: shown once, hidden but revealable, and gone.\",\n category: \"form\",\n status: \"stable\",\n dependencies: [],\n registryDependencies: [\"button\", \"input\"],\n files: [\"secret-field.tsx\"],\n a11y:\n \"The value sits in a read-only input rather than a disabled one, so it can be focused, \" +\n \"selected and read, and focusing a shown value selects all of it for copying by hand. The \" +\n \"field's state — shown once, hidden, revealed, gone — is a sentence attached as its \" +\n \"description, never a colour or an icon. Copying is announced through a status region that \" +\n \"exists before the first copy, and failure is announced too, with what to do instead, because \" +\n \"the clipboard can be refused. Every button carries the field's label in its name, so a \" +\n \"settings page with several keys has several distinct Copy buttons. Regenerating is confirmed \" +\n \"inline in a named group that states the consequence, since it revokes the current key.\",\n});\n"],"mappings":";;AAEA,MAAa,OAAO,WAAW;CAC7B,MAAM;CACN,OAAO;CACP,aAAa;CACb,UAAU;CACV,QAAQ;CACR,cAAc,CAAC;CACf,sBAAsB,CAAC,UAAU,OAAO;CACxC,OAAO,CAAC,kBAAkB;CAC1B,MACE;AAQJ,CAAC"}
@@ -0,0 +1,58 @@
1
+ import { ComponentPropsWithRef, ReactNode } from "react";
2
+ //#region src/components/secret-field/secret-field.d.ts
3
+ /**
4
+ * An API key, token or signing secret, in the three states it actually has.
5
+ *
6
+ * Stripe, GitHub, OpenAI, Vercel and every developer product with a settings
7
+ * page draw this by hand, and the states are the same everywhere: shown once
8
+ * at creation and never again; hidden but revealable, for a secret the server
9
+ * can show again; and gone, where only a prefix and the last four remain and
10
+ * the only thing left to do is regenerate. No component library ships it —
11
+ * the nearest thing is a password input, which is a control for entering a
12
+ * secret you know, not for handling one you have just been given.
13
+ *
14
+ * "Shown once" is a first-class state rather than a toast, because it is the
15
+ * one that costs people money: the key is on screen, the tab closes, and the
16
+ * next hour is spent regenerating it and updating every client. So the field
17
+ * says it in a sentence beside the value, and the way out is a button that
18
+ * says what it means — "I have saved it" — rather than the value quietly
19
+ * vanishing on navigation.
20
+ *
21
+ * While hidden, the secret is not in the DOM. The preview is what is
22
+ * rendered, so a screenshot, an extension or a devtools pane sees the prefix
23
+ * and the last four, which is what the server itself keeps. Reveals are
24
+ * reported, since an audit log of who looked is the reason the hidden state
25
+ * exists. Copy works while hidden, because the point of the key is to be
26
+ * pasted, not read.
27
+ */
28
+ type SecretFieldState = "shown" | "hidden" | "revealed" | "gone";
29
+ interface SecretFieldProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
30
+ label: string;
31
+ /** The secret. Omit once it can no longer be shown. */
32
+ value?: string;
33
+ /** What stands in for it while hidden or gone. Derived from the value when there is one. */
34
+ preview?: string;
35
+ /** This is the only time the value can be seen. */
36
+ once?: boolean;
37
+ /** The reader has saved a shown-once value. Drop `value` in response. */
38
+ onAcknowledge?: () => void;
39
+ /** Called each time the value is shown or hidden, so reveals can be logged. */
40
+ onRevealChange?: (revealed: boolean) => void;
41
+ /** Offers to regenerate. Confirmed inline first, because it revokes the current one. */
42
+ onRegenerate?: () => void;
43
+ /** Said before regenerating. */
44
+ regenerateWarning?: string;
45
+ /** Metadata — when it was created, when it was last used. */
46
+ children?: ReactNode;
47
+ }
48
+ /**
49
+ * A prefix and the last four, the way a server keeps a key it has hashed.
50
+ * The prefix is kept through the last underscore in the first twelve
51
+ * characters — `sk_live_`, `ghp_`, `whsec_` — so the reader can tell which
52
+ * key this is without being shown any of it.
53
+ */
54
+ declare function maskSecret(value: string, keepEnd?: number): string;
55
+ declare function SecretField({ className, label, value, preview, once, onAcknowledge, onRevealChange, onRegenerate, regenerateWarning, children, ...props }: SecretFieldProps): import("react").JSX.Element;
56
+ //#endregion
57
+ export { SecretField, SecretFieldProps, SecretFieldState, maskSecret };
58
+ //# sourceMappingURL=secret-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"secret-field.d.ts","names":[],"sources":["../../../src/components/secret-field/secret-field.tsx"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;KAyCY;UAEK,yBAAyB,KAAK;EAC7C;;EAEA;;EAEA;;EAEA;;EAEA;;EAEA,kBAAkB;;EAElB;;EAEA;;EAEA,WAAW;;;;;;;;iBASG,WAAW,eAAe;iBAS1B,cACd,WACA,OACA,OACA,SACA,MACA,eACA,gBACA,cACA,mBACA,aACG,SACF,mCAAgB,IAAA"}