@carlonicora/nextjs-jsonapi 1.77.3 → 1.79.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 (146) hide show
  1. package/dist/AssistantInterface-BYgI5z1-.d.mts +12 -0
  2. package/dist/AssistantInterface-DfDcz0gJ.d.ts +12 -0
  3. package/dist/AssistantMessageInterface-DWnbd6J7.d.ts +36 -0
  4. package/dist/AssistantMessageInterface-Mla6kgPe.d.mts +36 -0
  5. package/dist/{AuthComponent-Blbs06ud.d.ts → AuthComponent-B6DIk8Vf.d.ts} +1 -1
  6. package/dist/{AuthComponent-huIaK5rm.d.mts → AuthComponent-BKI0ZbtD.d.mts} +1 -1
  7. package/dist/{BlockNoteEditor-7HAAXN3H.mjs → BlockNoteEditor-6CBDTVKV.mjs} +4 -4
  8. package/dist/{BlockNoteEditor-UB7T7V67.js → BlockNoteEditor-EH4HWI7H.js} +14 -14
  9. package/dist/{BlockNoteEditor-UB7T7V67.js.map → BlockNoteEditor-EH4HWI7H.js.map} +1 -1
  10. package/dist/RbacTypes-BTbr27Ew.d.mts +43 -0
  11. package/dist/RbacTypes-BTbr27Ew.d.ts +43 -0
  12. package/dist/{auth.interface-CQJ6A2Cj.d.ts → auth.interface-BBUgMZzs.d.ts} +1 -1
  13. package/dist/{auth.interface-Bdq7-8iV.d.mts → auth.interface-XYEREOD6.d.mts} +1 -1
  14. package/dist/billing/index.js +346 -346
  15. package/dist/billing/index.mjs +3 -3
  16. package/dist/{chunk-FKLP4NED.js → chunk-5IEWLLLD.js} +379 -18
  17. package/dist/chunk-5IEWLLLD.js.map +1 -0
  18. package/dist/{chunk-XI35ALWY.mjs → chunk-BKM5U3DE.mjs} +362 -1
  19. package/dist/chunk-BKM5U3DE.mjs.map +1 -0
  20. package/dist/{chunk-F44ET4AC.mjs → chunk-ENRSFVOS.mjs} +2657 -2264
  21. package/dist/chunk-ENRSFVOS.mjs.map +1 -0
  22. package/dist/{chunk-JOJZRGZL.mjs → chunk-MEWXQEVE.mjs} +38 -29
  23. package/dist/{chunk-JOJZRGZL.mjs.map → chunk-MEWXQEVE.mjs.map} +1 -1
  24. package/dist/{chunk-OTZEXASK.js → chunk-TWDSDTHU.js} +39 -30
  25. package/dist/chunk-TWDSDTHU.js.map +1 -0
  26. package/dist/{chunk-CV7UOUKQ.js → chunk-ZDP3MBUI.js} +1813 -1420
  27. package/dist/chunk-ZDP3MBUI.js.map +1 -0
  28. package/dist/client/index.d.mts +6 -24
  29. package/dist/client/index.d.ts +6 -24
  30. package/dist/client/index.js +4 -10
  31. package/dist/client/index.js.map +1 -1
  32. package/dist/client/index.mjs +3 -9
  33. package/dist/components/index.d.mts +51 -34
  34. package/dist/components/index.d.ts +51 -34
  35. package/dist/components/index.js +4 -4
  36. package/dist/components/index.js.map +1 -1
  37. package/dist/components/index.mjs +9 -9
  38. package/dist/{config-B3jKt9P7.d.ts → config-B5oBQVEA.d.ts} +1 -1
  39. package/dist/{config-DkHF61xA.d.mts → config-Bx_uh22h.d.mts} +1 -1
  40. package/dist/contexts/index.d.mts +65 -4
  41. package/dist/contexts/index.d.ts +65 -4
  42. package/dist/contexts/index.js +12 -4
  43. package/dist/contexts/index.js.map +1 -1
  44. package/dist/contexts/index.mjs +11 -3
  45. package/dist/core/index.d.mts +126 -11
  46. package/dist/core/index.d.ts +126 -11
  47. package/dist/core/index.js +16 -2
  48. package/dist/core/index.js.map +1 -1
  49. package/dist/core/index.mjs +15 -1
  50. package/dist/index.d.mts +118 -20
  51. package/dist/index.d.ts +118 -20
  52. package/dist/index.js +19 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/index.mjs +18 -2
  55. package/dist/{notification.interface-DG6obXUH.d.mts → notification.interface-DLZGtV7Z.d.mts} +1 -1
  56. package/dist/{notification.interface-DcSuc9CL.d.ts → notification.interface-aLEJbA_g.d.ts} +1 -1
  57. package/dist/{s3.service-DGilbikH.d.mts → s3.service-CVgLWaDc.d.mts} +2 -2
  58. package/dist/{s3.service-DjwEQJPe.d.ts → s3.service-SLlX0Zbz.d.ts} +2 -2
  59. package/dist/server/index.d.mts +3 -3
  60. package/dist/server/index.d.ts +3 -3
  61. package/dist/server/index.js +3 -3
  62. package/dist/server/index.mjs +1 -1
  63. package/dist/useDataListRetriever-BqJSFBck.d.mts +33 -0
  64. package/dist/useDataListRetriever-BqJSFBck.d.ts +33 -0
  65. package/dist/{useSocket-CmzVtg32.d.mts → useSocket-BkxHHujj.d.mts} +1 -1
  66. package/dist/{useSocket-8eUtnL7J.d.ts → useSocket-CMDjWFYm.d.ts} +1 -1
  67. package/package.json +1 -1
  68. package/src/client/index.ts +0 -4
  69. package/src/components/index.ts +2 -3
  70. package/src/contexts/index.ts +2 -0
  71. package/src/core/index.ts +4 -0
  72. package/src/core/registry/ModuleRegistry.ts +10 -0
  73. package/src/features/assistant/AssistantModule.ts +19 -0
  74. package/src/features/assistant/components/containers/AssistantContainer.tsx +56 -0
  75. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +101 -0
  76. package/src/features/assistant/components/index.ts +1 -0
  77. package/src/features/assistant/components/parts/AssistantComposer.tsx +56 -0
  78. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +47 -0
  79. package/src/features/assistant/components/parts/AssistantSidebar.tsx +64 -0
  80. package/src/features/assistant/components/parts/AssistantStatusLine.tsx +19 -0
  81. package/src/features/assistant/components/parts/AssistantThread.tsx +36 -0
  82. package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +91 -0
  83. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +32 -0
  84. package/src/features/assistant/components/parts/__tests__/AssistantEmptyState.spec.tsx +27 -0
  85. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +58 -0
  86. package/src/features/assistant/components/parts/__tests__/AssistantStatusLine.spec.tsx +19 -0
  87. package/src/features/assistant/components/parts/__tests__/AssistantThread.spec.tsx +39 -0
  88. package/src/features/assistant/components/parts/__tests__/AssistantThreadHeader.spec.tsx +67 -0
  89. package/src/features/assistant/contexts/AssistantContext.tsx +255 -0
  90. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +375 -0
  91. package/src/features/assistant/data/Assistant.ts +37 -0
  92. package/src/features/assistant/data/AssistantInterface.ts +11 -0
  93. package/src/features/assistant/data/AssistantService.ts +79 -0
  94. package/src/features/assistant/data/index.ts +3 -0
  95. package/src/features/assistant/index.ts +2 -0
  96. package/src/features/assistant/utils/__tests__/groupThreadsByBucket.spec.ts +24 -0
  97. package/src/features/assistant/utils/__tests__/resolveReferenceableModules.spec.ts +92 -0
  98. package/src/features/assistant/utils/groupThreadsByBucket.ts +26 -0
  99. package/src/features/assistant/utils/resolveReferenceableModules.ts +14 -0
  100. package/src/features/assistant-message/AssistantMessageModule.ts +28 -0
  101. package/src/features/assistant-message/components/MessageItem.tsx +60 -0
  102. package/src/features/assistant-message/components/MessageList.tsx +38 -0
  103. package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +108 -0
  104. package/src/features/assistant-message/components/index.ts +2 -0
  105. package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +46 -0
  106. package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +52 -0
  107. package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +59 -0
  108. package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +29 -0
  109. package/src/features/assistant-message/data/AssistantMessage.ts +95 -0
  110. package/src/features/assistant-message/data/AssistantMessageInterface.ts +21 -0
  111. package/src/features/assistant-message/data/AssistantMessageService.ts +40 -0
  112. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +158 -0
  113. package/src/features/assistant-message/data/index.ts +3 -0
  114. package/src/features/assistant-message/index.ts +2 -0
  115. package/src/features/rbac/components/RbacContainer.tsx +318 -49
  116. package/src/features/rbac/components/RbacPermissionPicker.tsx +144 -121
  117. package/src/features/rbac/contexts/RbacContext.tsx +209 -0
  118. package/src/features/rbac/contexts/index.ts +1 -0
  119. package/src/features/rbac/data/RbacMatrixModel.ts +84 -0
  120. package/src/features/rbac/data/RbacService.ts +61 -33
  121. package/src/features/rbac/data/RbacTypes.ts +28 -0
  122. package/src/features/rbac/data/index.ts +1 -0
  123. package/src/features/rbac/index.ts +1 -10
  124. package/src/features/rbac/rbac.module.ts +13 -0
  125. package/src/features/user/contexts/CurrentUserContext.tsx +5 -13
  126. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +141 -0
  127. package/src/index.ts +4 -0
  128. package/dist/HowToInterface-BKhnkzBp.d.ts +0 -17
  129. package/dist/HowToInterface-Cj8OuQFf.d.mts +0 -17
  130. package/dist/ModulePathsInterface-BrdqgteS.d.mts +0 -31
  131. package/dist/ModulePathsInterface-DJKs7s_s.d.ts +0 -31
  132. package/dist/chunk-CV7UOUKQ.js.map +0 -1
  133. package/dist/chunk-F44ET4AC.mjs.map +0 -1
  134. package/dist/chunk-FKLP4NED.js.map +0 -1
  135. package/dist/chunk-OTZEXASK.js.map +0 -1
  136. package/dist/chunk-XI35ALWY.mjs.map +0 -1
  137. package/dist/useRbacState-C88O-5L8.d.ts +0 -77
  138. package/dist/useRbacState-mqYiRp3J.d.mts +0 -77
  139. package/src/features/rbac/components/RbacFeatureSection.tsx +0 -66
  140. package/src/features/rbac/components/RbacModuleTable.tsx +0 -121
  141. package/src/features/rbac/components/RbacToolbar.tsx +0 -40
  142. package/src/features/rbac/hooks/useRbacState.test.ts +0 -180
  143. package/src/features/rbac/hooks/useRbacState.ts +0 -319
  144. package/src/features/rbac/utils/RbacMigrationGenerator.test.ts +0 -124
  145. package/src/features/rbac/utils/RbacMigrationGenerator.ts +0 -184
  146. /package/dist/{BlockNoteEditor-7HAAXN3H.mjs.map → BlockNoteEditor-6CBDTVKV.mjs.map} +0 -0
@@ -1,130 +1,157 @@
1
1
  "use client";
2
2
 
3
- import { cn } from "../../../lib/utils";
3
+ import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
4
4
  import { CheckIcon, MinusIcon, XIcon } from "lucide-react";
5
5
  import { useTranslations } from "next-intl";
6
- import { useCallback, useState } from "react";
7
- import { Button, Checkbox, Input, Popover, PopoverContent, PopoverTrigger, Separator } from "../../../shadcnui";
6
+ import { useCallback, useEffect, useState } from "react";
7
+ import { cn } from "../../../lib/utils";
8
+ import { Button, Checkbox, Input, Separator } from "../../../shadcnui";
8
9
  import { PermissionValue } from "../data/RbacTypes";
9
- import RbacPermissionCell from "./RbacPermissionCell";
10
10
 
11
+ /**
12
+ * Controlled "global" picker. Exactly ONE instance is rendered by
13
+ * `RbacContainer`; the container holds `activePicker` state and drives this
14
+ * component's `open` + `anchor` props. Cells themselves are plain clickable
15
+ * elements — they just open this picker at their own DOM position.
16
+ *
17
+ * This replaces the previous design that mounted one `<Popover>` per cell
18
+ * (~2,600 total), which was both slow AND caused unreliable click handling
19
+ * across overlapping outside-click listeners.
20
+ */
11
21
  interface RbacPermissionPickerProps {
12
- value: PermissionValue | undefined | null;
13
- originalValue?: PermissionValue | undefined | null;
14
- isRoleColumn?: boolean;
22
+ /** Whether the picker is open. Driven by `activePicker !== null` in the container. */
23
+ open: boolean;
24
+ /** DOM element the popup should anchor to. Required when `open`. */
25
+ anchor: HTMLElement | null;
26
+ /** Current effective value of the active cell (true | string | false | undefined). */
27
+ value: PermissionValue | undefined;
28
+ /** True if the active cell is in a role row (enables the "inherit from defaults" button). */
29
+ isRoleColumn: boolean;
30
+ /** Relationship paths the backend reports for the active cell's module. */
15
31
  knownSegments: string[];
16
32
  onSetValue: (value: PermissionValue) => void;
33
+ /** Only meaningful for role rows — clears the token so the row inherits defaults. */
17
34
  onClear?: () => void;
35
+ /** Called when the picker should close (outside-click, ESC, etc.). */
36
+ onClose: () => void;
18
37
  }
19
38
 
20
- export default function RbacPermissionPicker({
39
+ export function RbacPermissionPicker({
40
+ open,
41
+ anchor,
21
42
  value,
22
- originalValue,
23
- isRoleColumn = false,
43
+ isRoleColumn,
24
44
  knownSegments,
25
45
  onSetValue,
26
46
  onClear,
47
+ onClose,
27
48
  }: RbacPermissionPickerProps) {
28
49
  const t = useTranslations();
29
- const [open, setOpen] = useState(false);
30
50
  const [customSegment, setCustomSegment] = useState("");
31
51
 
32
- // Parse current segments from value if it's a string
52
+ // Reset the custom-segment input when the picker is reopened for a different cell.
53
+ useEffect(() => {
54
+ if (!open) setCustomSegment("");
55
+ }, [open]);
56
+
33
57
  const currentSegments: string[] = typeof value === "string" ? value.split("|").filter(Boolean) : [];
34
58
 
35
59
  const toggleSegment = useCallback(
36
60
  (segment: string) => {
37
- const newSegments = currentSegments.includes(segment)
61
+ const next = currentSegments.includes(segment)
38
62
  ? currentSegments.filter((s) => s !== segment)
39
63
  : [...currentSegments, segment];
40
-
41
- if (newSegments.length === 0) {
42
- onSetValue(false);
43
- } else {
44
- onSetValue(newSegments.join("|"));
45
- }
64
+ if (next.length === 0) onSetValue(false);
65
+ else onSetValue(next.join("|"));
46
66
  },
47
67
  [currentSegments, onSetValue],
48
68
  );
49
69
 
50
70
  const addCustomSegment = useCallback(() => {
51
- if (!customSegment.trim()) return;
52
71
  const segment = customSegment.trim();
72
+ if (!segment) return;
53
73
  if (!currentSegments.includes(segment)) {
54
- const newSegments = [...currentSegments, segment];
55
- onSetValue(newSegments.join("|"));
74
+ onSetValue([...currentSegments, segment].join("|"));
56
75
  }
57
76
  setCustomSegment("");
58
77
  }, [customSegment, currentSegments, onSetValue]);
59
78
 
60
79
  return (
61
- <Popover open={open} onOpenChange={setOpen}>
62
- <PopoverTrigger>
63
- <RbacPermissionCell value={value} originalValue={originalValue} isRoleColumn={isRoleColumn} />
64
- </PopoverTrigger>
65
- <PopoverContent className="w-72 p-3" align="center">
66
- <div className="space-y-3">
67
- {/* Quick toggles */}
68
- <div>
69
- <p className="text-xs font-medium text-muted-foreground mb-2">{t("rbac.quick_value")}</p>
70
- <div className="flex gap-2">
71
- <Button
72
- size="sm"
73
- variant={value === true ? "default" : "outline"}
74
- className={cn("flex-1 gap-1", value === true && "bg-emerald-600 hover:bg-emerald-700")}
75
- onClick={() => {
76
- onSetValue(true);
77
- setOpen(false);
78
- }}
79
- >
80
- <CheckIcon className="h-3 w-3" />
81
- <span>true</span>
82
- </Button>
83
- <Button
84
- size="sm"
85
- variant={value === false ? "destructive" : "outline"}
86
- className="flex-1 gap-1"
87
- onClick={() => {
88
- onSetValue(false);
89
- setOpen(false);
90
- }}
91
- >
92
- <XIcon className="h-3 w-3" />
93
- <span>false</span>
94
- </Button>
80
+ <PopoverPrimitive.Root
81
+ open={open}
82
+ onOpenChange={(next) => {
83
+ if (!next) onClose();
84
+ }}
85
+ >
86
+ <PopoverPrimitive.Portal>
87
+ <PopoverPrimitive.Positioner
88
+ anchor={anchor ?? undefined}
89
+ side="bottom"
90
+ align="center"
91
+ sideOffset={4}
92
+ className="isolate z-50"
93
+ >
94
+ <PopoverPrimitive.Popup className="bg-popover text-popover-foreground ring-foreground/10 z-50 flex w-96 flex-col gap-3 rounded-lg p-3 text-xs shadow-md outline-hidden ring-1 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 duration-100">
95
+ {/* Quick value */}
96
+ <div>
97
+ <p className="text-muted-foreground mb-2 text-xs font-medium">{t("rbac.quick_value")}</p>
98
+ <div className="flex gap-2">
99
+ <Button
100
+ size="sm"
101
+ variant={value === true ? "default" : "outline"}
102
+ className={cn("flex-1 gap-1", value === true && "bg-emerald-600 hover:bg-emerald-700")}
103
+ onClick={() => {
104
+ onSetValue(true);
105
+ onClose();
106
+ }}
107
+ >
108
+ <CheckIcon className="h-3 w-3" />
109
+ <span>true</span>
110
+ </Button>
111
+ <Button
112
+ size="sm"
113
+ variant={value === false ? "destructive" : "outline"}
114
+ className="flex-1 gap-1"
115
+ onClick={() => {
116
+ onSetValue(false);
117
+ onClose();
118
+ }}
119
+ >
120
+ <XIcon className="h-3 w-3" />
121
+ <span>false</span>
122
+ </Button>
123
+ </div>
95
124
  </div>
96
- </div>
97
125
 
98
- {/* Clear / Inherit (role columns only) */}
99
- {isRoleColumn && onClear && (
100
- <>
101
- <Separator />
102
- <Button
103
- size="sm"
104
- variant="ghost"
105
- className="w-full gap-1 text-muted-foreground"
106
- onClick={() => {
107
- onClear();
108
- setOpen(false);
109
- }}
110
- >
111
- <MinusIcon className="h-3 w-3" />
112
- {t("rbac.inherit_from_defaults")}
113
- </Button>
114
- </>
115
- )}
126
+ {/* Inherit from defaults role rows only */}
127
+ {isRoleColumn && onClear && (
128
+ <>
129
+ <Separator />
130
+ <Button
131
+ size="sm"
132
+ variant="ghost"
133
+ className="text-muted-foreground w-full gap-1"
134
+ onClick={() => {
135
+ onClear();
136
+ onClose();
137
+ }}
138
+ >
139
+ <MinusIcon className="h-3 w-3" />
140
+ {t("rbac.inherit_from_defaults")}
141
+ </Button>
142
+ </>
143
+ )}
116
144
 
117
- {/* Relationship path builder */}
118
- {knownSegments.length > 0 && (
119
- <>
120
- <Separator />
121
- <div>
122
- <p className="text-xs font-medium text-muted-foreground mb-2">{t("rbac.relationships")}</p>
145
+ {/* Relationship-path builder — always visible so users can type custom paths */}
146
+ <Separator />
147
+ <div>
148
+ <p className="text-muted-foreground mb-2 text-xs font-medium">{t("rbac.relationships")}</p>
149
+ {knownSegments.length > 0 && (
123
150
  <div className="max-h-40 space-y-1 overflow-y-auto">
124
151
  {knownSegments.map((segment) => (
125
152
  <label
126
153
  key={segment}
127
- className="flex items-center gap-2 rounded px-1 py-0.5 text-sm hover:bg-muted cursor-pointer"
154
+ className="hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-sm"
128
155
  >
129
156
  <Checkbox
130
157
  checked={currentSegments.includes(segment)}
@@ -134,46 +161,42 @@ export default function RbacPermissionPicker({
134
161
  </label>
135
162
  ))}
136
163
  </div>
137
-
138
- {/* Custom segment input */}
139
- <div className="mt-2 flex gap-1">
140
- <Input
141
- value={customSegment}
142
- onChange={(e) => setCustomSegment(e.target.value)}
143
- placeholder={t("rbac.custom_segment_placeholder")}
144
- className="h-7 text-xs font-mono"
145
- onKeyDown={(e) => {
146
- if (e.key === "Enter") {
147
- e.preventDefault();
148
- addCustomSegment();
149
- }
150
- }}
151
- />
152
- <Button
153
- size="sm"
154
- variant="outline"
155
- className="h-7 px-2 text-xs"
156
- onClick={addCustomSegment}
157
- disabled={!customSegment.trim()}
158
- >
159
- +
160
- </Button>
161
- </div>
162
-
163
- {/* Preview */}
164
- {currentSegments.length > 0 && (
165
- <div className="mt-2 rounded bg-muted p-2">
166
- <p className="text-xs text-muted-foreground mb-1">{t("rbac.preview")}</p>
167
- <p className="text-xs font-mono break-all">{currentSegments.join("|")}</p>
168
- </div>
169
- )}
164
+ )}
165
+ <div className="mt-2 flex gap-1">
166
+ <Input
167
+ value={customSegment}
168
+ onChange={(e) => setCustomSegment(e.target.value)}
169
+ placeholder={t("rbac.custom_segment_placeholder")}
170
+ className="h-7 font-mono text-xs"
171
+ onKeyDown={(e) => {
172
+ if (e.key === "Enter") {
173
+ e.preventDefault();
174
+ addCustomSegment();
175
+ }
176
+ }}
177
+ />
178
+ <Button
179
+ size="sm"
180
+ variant="outline"
181
+ className="h-7 px-2 text-xs"
182
+ onClick={addCustomSegment}
183
+ disabled={!customSegment.trim()}
184
+ >
185
+ +
186
+ </Button>
170
187
  </div>
171
- </>
172
- )}
173
- </div>
174
- </PopoverContent>
175
- </Popover>
188
+ {currentSegments.length > 0 && (
189
+ <div className="bg-muted mt-2 rounded p-2">
190
+ <p className="text-muted-foreground mb-1 text-xs">{t("rbac.preview")}</p>
191
+ <p className="font-mono text-xs break-all">{currentSegments.join("|")}</p>
192
+ </div>
193
+ )}
194
+ </div>
195
+ </PopoverPrimitive.Popup>
196
+ </PopoverPrimitive.Positioner>
197
+ </PopoverPrimitive.Portal>
198
+ </PopoverPrimitive.Root>
176
199
  );
177
200
  }
178
201
 
179
- export { RbacPermissionPicker };
202
+ export default RbacPermissionPicker;
@@ -0,0 +1,209 @@
1
+ "use client";
2
+
3
+ import { DownloadIcon, Loader2Icon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
6
+ import { SharedProvider } from "../../../contexts";
7
+ import { Modules } from "../../../core";
8
+ import { usePageUrlGenerator } from "../../../hooks";
9
+ import { BreadcrumbItemData } from "../../../interfaces";
10
+ import { Button } from "../../../shadcnui";
11
+ import { showError, showToast } from "../../../utils/toast";
12
+ import { RbacService } from "../data/RbacService";
13
+ import type { ActionType, PermissionValue, PermToken, RbacMatrix, RbacModuleBlock } from "../data/RbacTypes";
14
+
15
+ const DEFAULT_OUTPUT_PATH = "apps/api/src/rbac/permissions.ts";
16
+
17
+ function upsertToken(tokens: PermToken[] | undefined, action: ActionType, scope: PermissionValue): PermToken[] {
18
+ const next = (tokens ?? []).filter((t) => t.action !== action);
19
+ // `scope === false` has no representation in the matrix — absence of a
20
+ // token for an action IS the "deny" semantics. See RbacContainer helpers.
21
+ if (scope === false) return next;
22
+ next.push({ action, scope });
23
+ return next;
24
+ }
25
+
26
+ function removeToken(tokens: PermToken[] | undefined, action: ActionType): PermToken[] {
27
+ return (tokens ?? []).filter((t) => t.action !== action);
28
+ }
29
+
30
+ interface RbacContextType {
31
+ matrix: RbacMatrix | null;
32
+ modulePaths: Record<string, readonly string[]>;
33
+ loading: boolean;
34
+ error: string | null;
35
+ saving: boolean;
36
+ roleNames?: Record<string, string>;
37
+ moduleNames?: Record<string, string>;
38
+ updateCell: (moduleId: string, rowKey: "default" | string, action: ActionType, value: PermissionValue) => void;
39
+ clearCell: (moduleId: string, roleId: string, action: ActionType) => void;
40
+ }
41
+
42
+ const RbacContext = createContext<RbacContextType | undefined>(undefined);
43
+
44
+ type RbacProviderProps = {
45
+ children: ReactNode;
46
+ /** UUID → PascalCase map for roles (e.g. from `@neural-erp/shared`'s `RoleId`). */
47
+ roleNames?: Record<string, string>;
48
+ /** UUID → PascalCase map for modules (e.g. from `@neural-erp/shared`'s `ModuleId`). */
49
+ moduleNames?: Record<string, string>;
50
+ /**
51
+ * Output path for the serialized `permissions.ts` file. Absolute, or
52
+ * relative to the API's repo root. Defaults to
53
+ * `"apps/api/src/rbac/permissions.ts"`.
54
+ */
55
+ outputPath?: string;
56
+ };
57
+
58
+ /**
59
+ * RbacProvider — owns the matrix state, the save handler, and the breadcrumb +
60
+ * title wiring. Renders the "Save to permissions.ts" button into
61
+ * `title.functions` so it appears in the page header, following the same
62
+ * convention as CompanyContext / other context providers in this codebase.
63
+ *
64
+ * The child `RbacContainer` is stateless — it consumes state via
65
+ * `useRbacContext()`.
66
+ */
67
+ export const RbacProvider = ({
68
+ children,
69
+ roleNames,
70
+ moduleNames,
71
+ outputPath = DEFAULT_OUTPUT_PATH,
72
+ }: RbacProviderProps) => {
73
+ const generateUrl = usePageUrlGenerator();
74
+ const t = useTranslations();
75
+
76
+ const [matrix, setMatrix] = useState<RbacMatrix | null>(null);
77
+ const [modulePaths, setModulePaths] = useState<Record<string, readonly string[]>>({});
78
+ const [loading, setLoading] = useState(true);
79
+ const [error, setError] = useState<string | null>(null);
80
+ const [saving, setSaving] = useState(false);
81
+
82
+ useEffect(() => {
83
+ let cancelled = false;
84
+ setLoading(true);
85
+ RbacService.fetchMatrix()
86
+ .then((result) => {
87
+ if (cancelled) return;
88
+ setMatrix(result.matrix);
89
+ setModulePaths(result.modulePaths);
90
+ })
91
+ .catch((err) => {
92
+ if (cancelled) return;
93
+ console.error("Failed to load RBAC matrix:", err);
94
+ setError(t("rbac.loading_error"));
95
+ })
96
+ .finally(() => {
97
+ if (cancelled) return;
98
+ setLoading(false);
99
+ });
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
+ }, [t]);
104
+
105
+ const updateCell = useCallback<RbacContextType["updateCell"]>((moduleId, rowKey, action, value) => {
106
+ setMatrix((prev) => {
107
+ if (!prev) return prev;
108
+ const prevBlock: RbacModuleBlock = prev[moduleId] ?? { default: [] };
109
+ const prevTokens: PermToken[] | undefined =
110
+ rowKey === "default" ? prevBlock.default : (prevBlock as Record<string, PermToken[]>)[rowKey];
111
+ const nextTokens = upsertToken(prevTokens, action, value);
112
+ const nextBlock: RbacModuleBlock = { ...prevBlock, [rowKey]: nextTokens } as RbacModuleBlock;
113
+ return { ...prev, [moduleId]: nextBlock };
114
+ });
115
+ }, []);
116
+
117
+ const clearCell = useCallback<RbacContextType["clearCell"]>((moduleId, roleId, action) => {
118
+ setMatrix((prev) => {
119
+ if (!prev) return prev;
120
+ const prevBlock: RbacModuleBlock = prev[moduleId] ?? { default: [] };
121
+ const prevTokens: PermToken[] | undefined = (prevBlock as Record<string, PermToken[]>)[roleId];
122
+ if (!prevTokens || prevTokens.length === 0) return prev;
123
+ const nextTokens = removeToken(prevTokens, action);
124
+ const nextBlock: RbacModuleBlock = { ...prevBlock } as RbacModuleBlock;
125
+ if (nextTokens.length === 0) {
126
+ delete (nextBlock as Record<string, PermToken[]>)[roleId];
127
+ } else {
128
+ (nextBlock as Record<string, PermToken[]>)[roleId] = nextTokens;
129
+ }
130
+ return { ...prev, [moduleId]: nextBlock };
131
+ });
132
+ }, []);
133
+
134
+ const canSave = Boolean(roleNames && moduleNames && matrix && !saving);
135
+
136
+ const handleSave = useCallback(async () => {
137
+ if (!matrix || !roleNames || !moduleNames) return;
138
+ setSaving(true);
139
+ try {
140
+ await RbacService.saveMatrix({ matrix, roleNames, moduleNames, outputPath });
141
+ showToast(`Saved to ${outputPath}`);
142
+ } catch (err) {
143
+ console.error("Failed to save RBAC matrix:", err);
144
+ showError(err instanceof Error ? err.message : String(err));
145
+ } finally {
146
+ setSaving(false);
147
+ }
148
+ }, [matrix, roleNames, moduleNames, outputPath]);
149
+
150
+ const breadcrumb = (): BreadcrumbItemData[] => {
151
+ const response: BreadcrumbItemData[] = [];
152
+ response.push({
153
+ name: t(`entities.rbac`, { count: 2 }),
154
+ href: generateUrl({ page: Modules.RbacMatrix }),
155
+ });
156
+ return response;
157
+ };
158
+
159
+ const title = () => {
160
+ const response: {
161
+ type: string;
162
+ functions?: ReactNode;
163
+ } = {
164
+ type: t(`entities.rbac`, { count: 2 }),
165
+ };
166
+
167
+ const functions: ReactNode[] = [];
168
+ functions.push(
169
+ <Button key="rbacSave" size="sm" onClick={handleSave} disabled={!canSave} className="gap-1">
170
+ {saving ? <Loader2Icon className="h-3.5 w-3.5 animate-spin" /> : <DownloadIcon className="h-3.5 w-3.5" />}
171
+ Save to permissions.ts
172
+ </Button>,
173
+ );
174
+ if (functions.length > 0) response.functions = functions;
175
+
176
+ return response;
177
+ };
178
+
179
+ // Memoize the context value so stable-identity child consumers don't
180
+ // re-render when only title/breadcrumbs change.
181
+ const contextValue = useMemo<RbacContextType>(
182
+ () => ({
183
+ matrix,
184
+ modulePaths,
185
+ loading,
186
+ error,
187
+ saving,
188
+ roleNames,
189
+ moduleNames,
190
+ updateCell,
191
+ clearCell,
192
+ }),
193
+ [matrix, modulePaths, loading, error, saving, roleNames, moduleNames, updateCell, clearCell],
194
+ );
195
+
196
+ return (
197
+ <SharedProvider value={{ breadcrumbs: breadcrumb(), title: title() }}>
198
+ <RbacContext.Provider value={contextValue}>{children}</RbacContext.Provider>
199
+ </SharedProvider>
200
+ );
201
+ };
202
+
203
+ export const useRbacContext = (): RbacContextType => {
204
+ const ctx = useContext(RbacContext);
205
+ if (!ctx) {
206
+ throw new Error("useRbacContext must be used within an RbacProvider");
207
+ }
208
+ return ctx;
209
+ };
@@ -0,0 +1 @@
1
+ export * from "./RbacContext";
@@ -0,0 +1,84 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface } from "../../../core";
2
+ import type { RbacMatrix } from "./RbacTypes";
3
+
4
+ /**
5
+ * Input shape accepted by `RbacMatrixModel.createJsonApi()` for a PUT request
6
+ * to the dev matrix endpoint.
7
+ */
8
+ export interface RbacMatrixInput {
9
+ matrix: RbacMatrix;
10
+ roleNames: Record<string, string>;
11
+ moduleNames: Record<string, string>;
12
+ outputPath: string;
13
+ }
14
+
15
+ /**
16
+ * Frontend model for the dev-only `rbac-matrix` JSON:API resource.
17
+ *
18
+ * Backend contract (see `rbac-dev.controller.ts`):
19
+ * - `GET /_dev/rbac/matrix` → `{ data: { type: "rbac-matrix", id: "singleton",
20
+ * attributes: { matrix } } }`
21
+ * - `PUT /_dev/rbac/matrix` body: `{ data: { type: "rbac-matrix", attributes:
22
+ * { matrix, roleNames, moduleNames, outputPath } } }`
23
+ * → `{ data: { type: "rbac-matrix", id: "singleton", attributes: { bytesWritten, path } } }`
24
+ *
25
+ * The resource is a singleton (`id: "singleton"`) so there is no collection
26
+ * listing; the "read" and "write" shapes share a single model with optional
27
+ * fields populated depending on which endpoint produced the response.
28
+ */
29
+ export class RbacMatrixModel extends AbstractApiData {
30
+ private _matrix?: RbacMatrix;
31
+ private _modulePaths?: Record<string, readonly string[]>;
32
+ private _bytesWritten?: number;
33
+ private _path?: string;
34
+
35
+ /** The RBAC matrix object (populated after a GET). */
36
+ get matrix(): RbacMatrix | undefined {
37
+ return this._matrix;
38
+ }
39
+
40
+ /**
41
+ * UUID-keyed map of each module's known BFS relationship paths (populated
42
+ * after a GET). Fed to the permission picker as scope suggestions.
43
+ */
44
+ get modulePaths(): Record<string, readonly string[]> | undefined {
45
+ return this._modulePaths;
46
+ }
47
+
48
+ /** Bytes written to the permissions.ts file (populated after a PUT). */
49
+ get bytesWritten(): number | undefined {
50
+ return this._bytesWritten;
51
+ }
52
+
53
+ /** Resolved absolute output path (populated after a PUT). */
54
+ get path(): string | undefined {
55
+ return this._path;
56
+ }
57
+
58
+ rehydrate(data: JsonApiHydratedDataInterface): this {
59
+ super.rehydrate(data);
60
+
61
+ const attrs = data.jsonApi.attributes ?? {};
62
+ this._matrix = attrs.matrix;
63
+ this._modulePaths = attrs.modulePaths;
64
+ this._bytesWritten = attrs.bytesWritten;
65
+ this._path = attrs.path;
66
+
67
+ return this;
68
+ }
69
+
70
+ createJsonApi(data: RbacMatrixInput): any {
71
+ return {
72
+ data: {
73
+ type: "rbac-matrix",
74
+ id: "singleton",
75
+ attributes: {
76
+ matrix: data.matrix,
77
+ roleNames: data.roleNames,
78
+ moduleNames: data.moduleNames,
79
+ outputPath: data.outputPath,
80
+ },
81
+ },
82
+ };
83
+ }
84
+ }