@carlonicora/nextjs-jsonapi 1.78.0 → 1.80.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 (128) hide show
  1. package/dist/{AssistantMessageInterface-DS_tyJTV.d.ts → AssistantMessageInterface-BpEhx2pC.d.ts} +19 -2
  2. package/dist/{AssistantMessageInterface-D0Kwf8CR.d.mts → AssistantMessageInterface-DJ3Me16Y.d.mts} +19 -2
  3. package/dist/{AuthComponent-Blbs06ud.d.ts → AuthComponent-B6DIk8Vf.d.ts} +1 -1
  4. package/dist/{AuthComponent-huIaK5rm.d.mts → AuthComponent-BKI0ZbtD.d.mts} +1 -1
  5. package/dist/{BlockNoteEditor-JXK3JGKJ.mjs → BlockNoteEditor-3M5PD3BZ.mjs} +4 -4
  6. package/dist/{BlockNoteEditor-2G5UYALC.js → BlockNoteEditor-YLTPJPTV.js} +14 -14
  7. package/dist/{BlockNoteEditor-2G5UYALC.js.map → BlockNoteEditor-YLTPJPTV.js.map} +1 -1
  8. package/dist/RbacTypes-BTbr27Ew.d.mts +43 -0
  9. package/dist/RbacTypes-BTbr27Ew.d.ts +43 -0
  10. package/dist/{auth.interface-CQJ6A2Cj.d.ts → auth.interface-BBUgMZzs.d.ts} +1 -1
  11. package/dist/{auth.interface-Bdq7-8iV.d.mts → auth.interface-XYEREOD6.d.mts} +1 -1
  12. package/dist/billing/index.js +346 -346
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/{chunk-ZEDB6JVB.js → chunk-4NOQNTFI.js} +1585 -1405
  15. package/dist/chunk-4NOQNTFI.js.map +1 -0
  16. package/dist/{chunk-I65SSQ5Z.mjs → chunk-6UMB5LTQ.mjs} +157 -7
  17. package/dist/chunk-6UMB5LTQ.mjs.map +1 -0
  18. package/dist/{chunk-FDJQRIMY.js → chunk-N4YZ45SK.js} +174 -24
  19. package/dist/chunk-N4YZ45SK.js.map +1 -0
  20. package/dist/{chunk-NB6TIKHK.mjs → chunk-NQV5RDCK.mjs} +2524 -2344
  21. package/dist/chunk-NQV5RDCK.mjs.map +1 -0
  22. package/dist/{chunk-NZOUEN67.mjs → chunk-PV5V6CVW.mjs} +38 -29
  23. package/dist/{chunk-NZOUEN67.mjs.map → chunk-PV5V6CVW.mjs.map} +1 -1
  24. package/dist/{chunk-X4YDETTD.js → chunk-ZEJSPTHS.js} +39 -30
  25. package/dist/chunk-ZEJSPTHS.js.map +1 -0
  26. package/dist/client/index.d.mts +6 -24
  27. package/dist/client/index.d.ts +6 -24
  28. package/dist/client/index.js +4 -10
  29. package/dist/client/index.js.map +1 -1
  30. package/dist/client/index.mjs +3 -9
  31. package/dist/components/index.d.mts +55 -39
  32. package/dist/components/index.d.ts +55 -39
  33. package/dist/components/index.js +4 -8
  34. package/dist/components/index.js.map +1 -1
  35. package/dist/components/index.mjs +5 -9
  36. package/dist/{config-B3jKt9P7.d.ts → config-B5oBQVEA.d.ts} +1 -1
  37. package/dist/{config-DkHF61xA.d.mts → config-Bx_uh22h.d.mts} +1 -1
  38. package/dist/contexts/index.d.mts +41 -4
  39. package/dist/contexts/index.d.ts +41 -4
  40. package/dist/contexts/index.js +8 -4
  41. package/dist/contexts/index.js.map +1 -1
  42. package/dist/contexts/index.mjs +7 -3
  43. package/dist/core/index.d.mts +51 -11
  44. package/dist/core/index.d.ts +51 -11
  45. package/dist/core/index.js +8 -2
  46. package/dist/core/index.js.map +1 -1
  47. package/dist/core/index.mjs +7 -1
  48. package/dist/index.d.mts +117 -20
  49. package/dist/index.d.ts +117 -20
  50. package/dist/index.js +11 -3
  51. package/dist/index.js.map +1 -1
  52. package/dist/index.mjs +10 -2
  53. package/dist/{notification.interface-DG6obXUH.d.mts → notification.interface-DLZGtV7Z.d.mts} +1 -1
  54. package/dist/{notification.interface-DcSuc9CL.d.ts → notification.interface-aLEJbA_g.d.ts} +1 -1
  55. package/dist/{s3.service-DGilbikH.d.mts → s3.service-CVgLWaDc.d.mts} +2 -2
  56. package/dist/{s3.service-DjwEQJPe.d.ts → s3.service-SLlX0Zbz.d.ts} +2 -2
  57. package/dist/server/index.d.mts +3 -3
  58. package/dist/server/index.d.ts +3 -3
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.mjs +1 -1
  61. package/dist/useDataListRetriever-BqJSFBck.d.mts +33 -0
  62. package/dist/useDataListRetriever-BqJSFBck.d.ts +33 -0
  63. package/dist/{useSocket-CmzVtg32.d.mts → useSocket-BkxHHujj.d.mts} +1 -1
  64. package/dist/{useSocket-8eUtnL7J.d.ts → useSocket-CMDjWFYm.d.ts} +1 -1
  65. package/package.json +1 -1
  66. package/src/client/index.ts +0 -4
  67. package/src/components/index.ts +0 -3
  68. package/src/contexts/index.ts +1 -0
  69. package/src/core/index.ts +2 -0
  70. package/src/core/registry/ModuleRegistry.ts +2 -0
  71. package/src/features/assistant/components/parts/AssistantThread.tsx +1 -1
  72. package/src/features/assistant-message/AssistantMessageModule.ts +4 -0
  73. package/src/features/assistant-message/components/MessageItem.tsx +7 -7
  74. package/src/features/assistant-message/components/MessageList.tsx +1 -1
  75. package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +11 -7
  76. package/src/features/assistant-message/components/index.ts +1 -0
  77. package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +135 -0
  78. package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +151 -0
  79. package/src/features/assistant-message/components/parts/RelevanceMeter.tsx +29 -0
  80. package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +70 -0
  81. package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +105 -0
  82. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +88 -0
  83. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +51 -0
  84. package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +24 -0
  85. package/src/features/assistant-message/components/parts/tabs/UsersTab.tsx +142 -0
  86. package/src/features/assistant-message/data/AssistantMessage.ts +20 -0
  87. package/src/features/assistant-message/data/AssistantMessageInterface.ts +2 -0
  88. package/src/features/assistant-message/data/AssistantMessageService.ts +13 -4
  89. package/src/features/assistant-message/data/__tests__/AssistantMessage.citations.spec.ts +65 -0
  90. package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +8 -0
  91. package/src/features/chunk/ChunkModule.ts +18 -0
  92. package/src/features/chunk/data/Chunk.ts +49 -0
  93. package/src/features/chunk/data/ChunkInput.ts +3 -0
  94. package/src/features/chunk/data/ChunkInterface.ts +18 -0
  95. package/src/features/chunk/data/__tests__/Chunk.spec.ts +83 -0
  96. package/src/features/chunk/data/index.ts +3 -0
  97. package/src/features/chunk/index.ts +2 -0
  98. package/src/features/rbac/components/RbacContainer.tsx +318 -49
  99. package/src/features/rbac/components/RbacPermissionPicker.tsx +144 -121
  100. package/src/features/rbac/contexts/RbacContext.tsx +209 -0
  101. package/src/features/rbac/contexts/index.ts +1 -0
  102. package/src/features/rbac/data/RbacMatrixModel.ts +84 -0
  103. package/src/features/rbac/data/RbacService.ts +61 -33
  104. package/src/features/rbac/data/RbacTypes.ts +28 -0
  105. package/src/features/rbac/data/index.ts +1 -0
  106. package/src/features/rbac/index.ts +1 -10
  107. package/src/features/rbac/rbac.module.ts +13 -0
  108. package/dist/ModulePathsInterface-BrdqgteS.d.mts +0 -31
  109. package/dist/ModulePathsInterface-DJKs7s_s.d.ts +0 -31
  110. package/dist/chunk-FDJQRIMY.js.map +0 -1
  111. package/dist/chunk-I65SSQ5Z.mjs.map +0 -1
  112. package/dist/chunk-NB6TIKHK.mjs.map +0 -1
  113. package/dist/chunk-X4YDETTD.js.map +0 -1
  114. package/dist/chunk-ZEDB6JVB.js.map +0 -1
  115. package/dist/useRbacState-C88O-5L8.d.ts +0 -77
  116. package/dist/useRbacState-mqYiRp3J.d.mts +0 -77
  117. package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +0 -46
  118. package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +0 -52
  119. package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +0 -59
  120. package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +0 -29
  121. package/src/features/rbac/components/RbacFeatureSection.tsx +0 -66
  122. package/src/features/rbac/components/RbacModuleTable.tsx +0 -121
  123. package/src/features/rbac/components/RbacToolbar.tsx +0 -40
  124. package/src/features/rbac/hooks/useRbacState.test.ts +0 -180
  125. package/src/features/rbac/hooks/useRbacState.ts +0 -319
  126. package/src/features/rbac/utils/RbacMigrationGenerator.test.ts +0 -124
  127. package/src/features/rbac/utils/RbacMigrationGenerator.ts +0 -184
  128. /package/dist/{BlockNoteEditor-JXK3JGKJ.mjs.map → BlockNoteEditor-3M5PD3BZ.mjs.map} +0 -0
@@ -0,0 +1,18 @@
1
+ import { FileTextIcon } from "lucide-react";
2
+ import { createJsonApiInclusion } from "../../core";
3
+ import { ModuleFactory } from "../../permissions";
4
+ import { Chunk } from "./data/Chunk";
5
+
6
+ export const ChunkModule = (factory: ModuleFactory) =>
7
+ factory({
8
+ pageUrl: "/chunks",
9
+ name: "chunks",
10
+ model: Chunk,
11
+ moduleId: "9b1c2f64-7ea3-4df5-8b2a-91b6a8d0e3a4",
12
+ icon: FileTextIcon,
13
+ inclusions: {
14
+ lists: {
15
+ fields: [createJsonApiInclusion<Chunk>("chunks", ["content", "nodeId", "nodeType", "imagePath", "source"])],
16
+ },
17
+ },
18
+ });
@@ -0,0 +1,49 @@
1
+ import { AbstractApiData, ApiDataInterface, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { resolveReferenceableModules } from "../../assistant/utils/resolveReferenceableModules";
3
+ import { ChunkInterface } from "./ChunkInterface";
4
+
5
+ export class Chunk extends AbstractApiData implements ChunkInterface {
6
+ private _content?: string;
7
+ private _nodeId?: string;
8
+ private _nodeType?: string;
9
+ private _imagePath?: string;
10
+ private _source?: ApiDataInterface;
11
+
12
+ get content(): string {
13
+ return this._content ?? "";
14
+ }
15
+ get nodeId(): string | undefined {
16
+ return this._nodeId;
17
+ }
18
+ get nodeType(): string | undefined {
19
+ return this._nodeType;
20
+ }
21
+ get imagePath(): string | undefined {
22
+ return this._imagePath;
23
+ }
24
+ get source(): ApiDataInterface | undefined {
25
+ return this._source;
26
+ }
27
+
28
+ rehydrate(data: JsonApiHydratedDataInterface): this {
29
+ super.rehydrate(data);
30
+ const attrs = data.jsonApi.attributes ?? {};
31
+ const meta = data.jsonApi.meta ?? {};
32
+ this._content = attrs.content;
33
+ this._nodeId = meta.nodeId ?? attrs.nodeId;
34
+ this._nodeType = meta.nodeType ?? attrs.nodeType;
35
+ this._imagePath = attrs.imagePath;
36
+
37
+ const src = this._readIncludedPolymorphic<ApiDataInterface>(data, "source", resolveReferenceableModules());
38
+ this._source = Array.isArray(src) ? src[0] : src;
39
+ return this;
40
+ }
41
+
42
+ createJsonApi() {
43
+ // Chunks are read-only from the assistant context. Provide a minimal stub.
44
+ return {
45
+ data: { type: Modules.Chunk.name, id: this.id, attributes: {}, relationships: {} },
46
+ included: [],
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,3 @@
1
+ export type ChunkInput = {
2
+ id: string;
3
+ };
@@ -0,0 +1,18 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+
3
+ /**
4
+ * Edge properties on the (:AssistantMessage)-[:CITES]->(:Chunk) relationship.
5
+ * Surfaced via _readIncludedWithMeta on AssistantMessage.citations.
6
+ */
7
+ export interface ChunkRelationshipMeta {
8
+ relevance: number;
9
+ reason?: string;
10
+ }
11
+
12
+ export interface ChunkInterface extends ApiDataInterface {
13
+ get content(): string;
14
+ get nodeId(): string | undefined;
15
+ get nodeType(): string | undefined;
16
+ get imagePath(): string | undefined;
17
+ get source(): ApiDataInterface | undefined;
18
+ }
@@ -0,0 +1,83 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
2
+ import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
3
+ import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
4
+ import { JsonApiHydratedDataInterface } from "../../../../core/interfaces/JsonApiHydratedDataInterface";
5
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
6
+ import { Chunk } from "../Chunk";
7
+ import { resolveReferenceableModules } from "../../../assistant/utils/resolveReferenceableModules";
8
+
9
+ vi.mock("../../../assistant/utils/resolveReferenceableModules", () => ({
10
+ resolveReferenceableModules: vi.fn(() => []),
11
+ }));
12
+
13
+ // Minimal AbstractApiData-derived class so RehydrationFactory can `new` it
14
+ // and call `instance.rehydrate(data)`.
15
+ class TestDocument extends AbstractApiData {
16
+ static identifierFields: string[] = ["name"];
17
+
18
+ rehydrate(data: JsonApiHydratedDataInterface): this {
19
+ super.rehydrate(data);
20
+ return this;
21
+ }
22
+
23
+ createJsonApi(_data?: any): any {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ const testDocumentModule: ApiRequestDataTypeInterface = {
29
+ name: "documents",
30
+ model: TestDocument,
31
+ } as any;
32
+
33
+ beforeAll(() => {
34
+ DataClassRegistry.clear();
35
+ DataClassRegistry.registerObjectClass(testDocumentModule, TestDocument);
36
+ });
37
+
38
+ afterAll(() => {
39
+ DataClassRegistry.clear();
40
+ });
41
+
42
+ describe("Chunk model", () => {
43
+ it("rehydrates content, nodeId, nodeType from attributes", () => {
44
+ const chunk = new Chunk();
45
+ chunk.rehydrate({
46
+ jsonApi: {
47
+ id: "c1",
48
+ type: "chunks",
49
+ attributes: { content: "hello", nodeId: "doc-1", nodeType: "documents" },
50
+ } as any,
51
+ included: [],
52
+ });
53
+ expect(chunk.content).toBe("hello");
54
+ expect(chunk.nodeId).toBe("doc-1");
55
+ expect(chunk.nodeType).toBe("documents");
56
+ });
57
+
58
+ it("resolves the source relationship polymorphically when matching module is registered", () => {
59
+ (resolveReferenceableModules as any).mockReturnValue([testDocumentModule]);
60
+
61
+ const chunk = new Chunk();
62
+ chunk.rehydrate({
63
+ jsonApi: {
64
+ id: "c1",
65
+ type: "chunks",
66
+ attributes: { content: "hello" },
67
+ relationships: { source: { data: { type: "documents", id: "doc-1" } } },
68
+ } as any,
69
+ included: [{ id: "doc-1", type: "documents", attributes: { name: "Manual v1" } }],
70
+ });
71
+ expect(chunk.source).toBeDefined();
72
+ expect((chunk.source as any).type).toBe("documents");
73
+ });
74
+
75
+ it("returns undefined source when relationship is absent", () => {
76
+ const chunk = new Chunk();
77
+ chunk.rehydrate({
78
+ jsonApi: { id: "c1", type: "chunks", attributes: { content: "x" } } as any,
79
+ included: [],
80
+ });
81
+ expect(chunk.source).toBeUndefined();
82
+ });
83
+ });
@@ -0,0 +1,3 @@
1
+ export * from "./Chunk";
2
+ export * from "./ChunkInterface";
3
+ export * from "./ChunkInput";
@@ -0,0 +1,2 @@
1
+ export * from "./ChunkModule";
2
+ export * from "./data";
@@ -1,80 +1,349 @@
1
1
  "use client";
2
2
 
3
3
  import { RoundPageContainer } from "@/components";
4
+ import { cn } from "@/lib/utils";
4
5
  import { Loader2Icon } from "lucide-react";
5
6
  import { useTranslations } from "next-intl";
6
- import { useCallback, useEffect, useState } from "react";
7
- import { RbacService } from "../data/RbacService";
8
- import { useRbacState } from "../hooks/useRbacState";
9
- import { downloadMigrationFile, generateMigrationFile } from "../utils/RbacMigrationGenerator";
10
- import RbacFeatureSection from "./RbacFeatureSection";
11
- import RbacToolbar from "./RbacToolbar";
7
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { useRbacContext } from "../contexts/RbacContext";
9
+ import { ACTION_TYPES, ActionType, PermissionValue, type PermToken, type RbacModuleBlock } from "../data/RbacTypes";
10
+ import RbacPermissionCell from "./RbacPermissionCell";
11
+ import { RbacPermissionPicker } from "./RbacPermissionPicker";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Pure helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ function findToken(tokens: PermToken[] | undefined, action: ActionType): PermToken | undefined {
18
+ if (!tokens) return undefined;
19
+ return tokens.find((t) => t.action === action);
20
+ }
21
+
22
+ function cellValue(tokens: PermToken[] | undefined, action: ActionType): PermissionValue | undefined {
23
+ const tok = findToken(tokens, action);
24
+ if (!tok) return undefined;
25
+ return tok.scope;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Picker target state
30
+ // ---------------------------------------------------------------------------
31
+
32
+ interface ActivePicker {
33
+ moduleId: string;
34
+ rowKey: string; // "default" or a role UUID
35
+ action: ActionType;
36
+ isRoleColumn: boolean;
37
+ anchor: HTMLElement;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Cell trigger — pure display + a click handler that opens the global picker.
42
+ // Memoised so unchanged cells don't re-render.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ interface CellButtonProps {
46
+ moduleId: string;
47
+ rowKey: string;
48
+ action: ActionType;
49
+ tokens: PermToken[] | undefined;
50
+ isRoleColumn: boolean;
51
+ onOpen: (picker: ActivePicker) => void;
52
+ }
53
+
54
+ const CellButton = memo(function CellButton({
55
+ moduleId,
56
+ rowKey,
57
+ action,
58
+ tokens,
59
+ isRoleColumn,
60
+ onOpen,
61
+ }: CellButtonProps) {
62
+ const ref = useRef<HTMLDivElement>(null);
63
+ const value = cellValue(tokens, action);
64
+
65
+ const handleClick = useCallback(() => {
66
+ if (!ref.current) return;
67
+ onOpen({ moduleId, rowKey, action, isRoleColumn, anchor: ref.current });
68
+ }, [onOpen, moduleId, rowKey, action, isRoleColumn]);
69
+
70
+ return (
71
+ <div ref={ref}>
72
+ <RbacPermissionCell value={value} isRoleColumn={isRoleColumn} onClick={handleClick} />
73
+ </div>
74
+ );
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Single-module editor — renders exactly one module's table.
79
+ // Memoised on its inputs so sidebar re-renders (e.g. new selectedModuleId)
80
+ // don't re-render the editor when the editor's module-level data hasn't
81
+ // changed.
82
+ // ---------------------------------------------------------------------------
83
+
84
+ const ACTION_LABELS: Record<ActionType, string> = {
85
+ read: "Read",
86
+ create: "Create",
87
+ update: "Update",
88
+ delete: "Delete",
89
+ };
90
+
91
+ interface ModuleEditorProps {
92
+ moduleId: string;
93
+ block: RbacModuleBlock;
94
+ moduleLabel: string;
95
+ roleIds: string[];
96
+ roleNames?: Record<string, string>;
97
+ onOpenPicker: (picker: ActivePicker) => void;
98
+ }
99
+
100
+ const ModuleEditor = memo(function ModuleEditor({
101
+ moduleId,
102
+ block,
103
+ moduleLabel,
104
+ roleIds,
105
+ roleNames,
106
+ onOpenPicker,
107
+ }: ModuleEditorProps) {
108
+ const t = useTranslations();
109
+ const defaultTokens = block.default ?? [];
110
+
111
+ return (
112
+ <div className="rounded-lg border border-accent bg-card">
113
+ <div className="flex items-center justify-between border-b px-4 py-2">
114
+ <h4 className="text-sm font-medium">{moduleLabel}</h4>
115
+ <span className="font-mono text-[10px] text-muted-foreground">{moduleId}</span>
116
+ </div>
117
+ <div className="overflow-x-auto">
118
+ <table className="w-full text-sm">
119
+ <thead>
120
+ <tr className="border-b bg-muted/50">
121
+ <th className="w-40 px-4 py-2 text-left text-xs font-medium text-muted-foreground">{t("rbac.role")}</th>
122
+ {ACTION_TYPES.map((action) => (
123
+ <th key={action} className="min-w-28 px-2 py-2 text-center text-xs font-medium text-muted-foreground">
124
+ {ACTION_LABELS[action]}
125
+ </th>
126
+ ))}
127
+ </tr>
128
+ </thead>
129
+ <tbody>
130
+ {/* Defaults row */}
131
+ <tr className={cn("border-b bg-muted/30")}>
132
+ <td className="px-4 py-1 text-xs font-bold text-muted-foreground">{t("rbac.defaults")}</td>
133
+ {ACTION_TYPES.map((action) => (
134
+ <td key={action} className="px-2 py-1">
135
+ <CellButton
136
+ moduleId={moduleId}
137
+ rowKey="default"
138
+ action={action}
139
+ tokens={defaultTokens}
140
+ isRoleColumn={false}
141
+ onOpen={onOpenPicker}
142
+ />
143
+ </td>
144
+ ))}
145
+ </tr>
146
+
147
+ {/* Role rows */}
148
+ {roleIds.map((roleId) => {
149
+ const roleTokens = (block as Record<string, PermToken[]>)[roleId];
150
+ const roleLabel = roleNames?.[roleId] ?? roleId;
151
+ return (
152
+ <tr key={roleId} className="border-b last:border-b-0">
153
+ <td className="px-4 py-1 text-xs font-medium text-muted-foreground">{roleLabel}</td>
154
+ {ACTION_TYPES.map((action) => (
155
+ <td key={action} className="px-2 py-1">
156
+ <CellButton
157
+ moduleId={moduleId}
158
+ rowKey={roleId}
159
+ action={action}
160
+ tokens={roleTokens}
161
+ isRoleColumn={true}
162
+ onOpen={onOpenPicker}
163
+ />
164
+ </td>
165
+ ))}
166
+ </tr>
167
+ );
168
+ })}
169
+ </tbody>
170
+ </table>
171
+ </div>
172
+ </div>
173
+ );
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Container — sidebar (module list) + detail (single module editor) + one
178
+ // global picker.
179
+ // ---------------------------------------------------------------------------
12
180
 
13
181
  export default function RbacContainer() {
14
182
  const t = useTranslations();
15
- const stateApi = useRbacState();
16
- const [loading, setLoading] = useState(true);
17
- const [error, setError] = useState<string | null>(null);
183
+ const { matrix, modulePaths, loading, error, roleNames, moduleNames, updateCell, clearCell } = useRbacContext();
18
184
 
19
- useEffect(() => {
20
- async function fetchData() {
21
- try {
22
- setLoading(true);
23
- const [features, roles, permissionMappings, modulePaths] = await Promise.all([
24
- RbacService.getFeatures(),
25
- RbacService.getRoles(),
26
- RbacService.getPermissionMappings(),
27
- RbacService.getModuleRelationshipPaths(),
28
- ]);
29
- stateApi.init(features, roles, permissionMappings, modulePaths);
30
- } catch (err) {
31
- console.error("Failed to load RBAC configuration:", err);
32
- setError(t("rbac.loading_error"));
33
- } finally {
34
- setLoading(false);
185
+ // Which module is visible in the right pane.
186
+ const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
187
+
188
+ // Which cell the single global picker is currently anchored to.
189
+ const [activePicker, setActivePicker] = useState<ActivePicker | null>(null);
190
+
191
+ const openPicker = useCallback((picker: ActivePicker) => {
192
+ setActivePicker(picker);
193
+ }, []);
194
+
195
+ const closePicker = useCallback(() => {
196
+ setActivePicker(null);
197
+ }, []);
198
+
199
+ const handleSelectModule = useCallback((id: string) => {
200
+ setSelectedModuleId(id);
201
+ setActivePicker(null); // stale anchor once we switch modules
202
+ }, []);
203
+
204
+ /** Module IDs sorted by display name (fallback to UUID). */
205
+ const sortedModuleIds = useMemo(() => {
206
+ if (!matrix) return [];
207
+ return Object.keys(matrix).sort((a, b) => (moduleNames?.[a] ?? a).localeCompare(moduleNames?.[b] ?? b));
208
+ }, [matrix, moduleNames]);
209
+
210
+ /**
211
+ * All role IDs to render as rows. When `roleNames` is provided (normal app
212
+ * path), use its full key set so every role appears — not just roles that
213
+ * already have an entry in the matrix. Sorted by display name.
214
+ */
215
+ const roleIds = useMemo(() => {
216
+ if (roleNames) {
217
+ return Object.keys(roleNames).sort((a, b) => (roleNames[a] ?? a).localeCompare(roleNames[b] ?? b));
218
+ }
219
+ if (!matrix) return [];
220
+ const set = new Set<string>();
221
+ for (const moduleId of Object.keys(matrix)) {
222
+ for (const key of Object.keys(matrix[moduleId] ?? {})) {
223
+ if (key !== "default") set.add(key);
35
224
  }
36
225
  }
37
- fetchData();
38
- }, [t]);
226
+ return Array.from(set).sort();
227
+ }, [matrix, roleNames]);
39
228
 
40
- const handleGenerate = useCallback(() => {
41
- const effective = stateApi.getEffectiveConfiguration();
42
- if (!effective) return;
229
+ // Auto-select the first module once the matrix loads.
230
+ useEffect(() => {
231
+ if (!selectedModuleId && sortedModuleIds.length > 0) {
232
+ setSelectedModuleId(sortedModuleIds[0]);
233
+ }
234
+ }, [selectedModuleId, sortedModuleIds]);
235
+
236
+ // --- Picker-driven values ---------------------------------------------------
237
+
238
+ const activeValue = useMemo<PermissionValue | undefined>(() => {
239
+ if (!activePicker || !matrix) return undefined;
240
+ const block = matrix[activePicker.moduleId];
241
+ if (!block) return undefined;
242
+ const tokens =
243
+ activePicker.rowKey === "default" ? block.default : (block as Record<string, PermToken[]>)[activePicker.rowKey];
244
+ return cellValue(tokens, activePicker.action);
245
+ }, [activePicker, matrix]);
246
+
247
+ const activeSegments = useMemo<string[]>(() => {
248
+ if (!activePicker) return [];
249
+ return (modulePaths[activePicker.moduleId] as string[] | undefined) ?? [];
250
+ }, [activePicker, modulePaths]);
251
+
252
+ // Update-only: whether to ALSO close the picker is the picker's decision
253
+ // (quick true/false + "inherit" close via the picker's own onClose; checkbox
254
+ // toggles do NOT close so users can pick multiple relationship paths).
255
+ const handleSetValue = useCallback(
256
+ (value: PermissionValue) => {
257
+ if (!activePicker) return;
258
+ updateCell(activePicker.moduleId, activePicker.rowKey, activePicker.action, value);
259
+ },
260
+ [activePicker, updateCell],
261
+ );
43
262
 
44
- const content = generateMigrationFile({
45
- features: effective.features,
46
- roles: effective.roles,
47
- rolePermissionsMap: effective.rolePermissionsMap,
48
- });
263
+ const handleClear = useCallback(() => {
264
+ if (!activePicker || !activePicker.isRoleColumn) return;
265
+ clearCell(activePicker.moduleId, activePicker.rowKey, activePicker.action);
266
+ }, [activePicker, clearCell]);
49
267
 
50
- downloadMigrationFile(content);
51
- }, [stateApi]);
268
+ // --- Transient-state returns ------------------------------------------------
52
269
 
53
270
  if (loading) {
54
271
  return (
55
- <div className="flex h-full items-center justify-center">
56
- <Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
57
- </div>
272
+ <RoundPageContainer fullWidth>
273
+ <div className="flex h-full items-center justify-center">
274
+ <Loader2Icon className="h-8 w-8 animate-spin text-muted-foreground" />
275
+ </div>
276
+ </RoundPageContainer>
58
277
  );
59
278
  }
60
279
 
61
280
  if (error) {
62
281
  return (
63
- <div className="flex h-full items-center justify-center">
64
- <p className="text-destructive">{error}</p>
65
- </div>
282
+ <RoundPageContainer fullWidth>
283
+ <div className="flex h-full items-center justify-center">
284
+ <p className="text-destructive">{error}</p>
285
+ </div>
286
+ </RoundPageContainer>
66
287
  );
67
288
  }
68
289
 
69
- if (!stateApi.original) return null;
290
+ if (!matrix) return null;
291
+
292
+ const selectedBlock: RbacModuleBlock | undefined = selectedModuleId ? matrix[selectedModuleId] : undefined;
70
293
 
71
294
  return (
72
- <RoundPageContainer>
73
- <RbacToolbar isDirty={stateApi.isDirty} onGenerate={handleGenerate} onReset={stateApi.reset} />
295
+ <RoundPageContainer fullWidth forceHeader>
296
+ <div className="flex h-full w-full">
297
+ {/* Sidebar: module list */}
298
+ <aside className="w-60 shrink-0 overflow-y-auto border-r bg-muted/20">
299
+ <ul className="py-1">
300
+ {sortedModuleIds.map((id) => (
301
+ <li key={id}>
302
+ <button
303
+ type="button"
304
+ onClick={() => handleSelectModule(id)}
305
+ aria-current={id === selectedModuleId ? "true" : undefined}
306
+ className={cn(
307
+ "block w-full px-4 py-1.5 text-left text-sm hover:bg-muted",
308
+ id === selectedModuleId && "bg-muted font-medium text-foreground",
309
+ id !== selectedModuleId && "text-muted-foreground",
310
+ )}
311
+ >
312
+ {moduleNames?.[id] ?? id}
313
+ </button>
314
+ </li>
315
+ ))}
316
+ </ul>
317
+ </aside>
318
+
319
+ {/* Detail: one module's editor */}
320
+ <section className="flex-1 overflow-y-auto p-4">
321
+ {selectedModuleId && selectedBlock ? (
322
+ <ModuleEditor
323
+ moduleId={selectedModuleId}
324
+ block={selectedBlock}
325
+ moduleLabel={moduleNames?.[selectedModuleId] ?? selectedModuleId}
326
+ roleIds={roleIds}
327
+ roleNames={roleNames}
328
+ onOpenPicker={openPicker}
329
+ />
330
+ ) : (
331
+ <p className="text-muted-foreground text-sm">{t("rbac.select_module_prompt")}</p>
332
+ )}
333
+ </section>
334
+ </div>
74
335
 
75
- {stateApi.original.features.map((feature) => (
76
- <RbacFeatureSection key={feature.id} feature={feature} roles={stateApi.original!.roles} stateApi={stateApi} />
77
- ))}
336
+ {/* One global picker for the whole container. */}
337
+ <RbacPermissionPicker
338
+ open={!!activePicker}
339
+ anchor={activePicker?.anchor ?? null}
340
+ value={activeValue}
341
+ isRoleColumn={activePicker?.isRoleColumn ?? false}
342
+ knownSegments={activeSegments}
343
+ onSetValue={handleSetValue}
344
+ onClear={activePicker?.isRoleColumn ? handleClear : undefined}
345
+ onClose={closePicker}
346
+ />
78
347
  </RoundPageContainer>
79
348
  );
80
349
  }