@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.
- package/dist/AssistantInterface-BYgI5z1-.d.mts +12 -0
- package/dist/AssistantInterface-DfDcz0gJ.d.ts +12 -0
- package/dist/AssistantMessageInterface-DWnbd6J7.d.ts +36 -0
- package/dist/AssistantMessageInterface-Mla6kgPe.d.mts +36 -0
- package/dist/{AuthComponent-Blbs06ud.d.ts → AuthComponent-B6DIk8Vf.d.ts} +1 -1
- package/dist/{AuthComponent-huIaK5rm.d.mts → AuthComponent-BKI0ZbtD.d.mts} +1 -1
- package/dist/{BlockNoteEditor-7HAAXN3H.mjs → BlockNoteEditor-6CBDTVKV.mjs} +4 -4
- package/dist/{BlockNoteEditor-UB7T7V67.js → BlockNoteEditor-EH4HWI7H.js} +14 -14
- package/dist/{BlockNoteEditor-UB7T7V67.js.map → BlockNoteEditor-EH4HWI7H.js.map} +1 -1
- package/dist/RbacTypes-BTbr27Ew.d.mts +43 -0
- package/dist/RbacTypes-BTbr27Ew.d.ts +43 -0
- package/dist/{auth.interface-CQJ6A2Cj.d.ts → auth.interface-BBUgMZzs.d.ts} +1 -1
- package/dist/{auth.interface-Bdq7-8iV.d.mts → auth.interface-XYEREOD6.d.mts} +1 -1
- package/dist/billing/index.js +346 -346
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-FKLP4NED.js → chunk-5IEWLLLD.js} +379 -18
- package/dist/chunk-5IEWLLLD.js.map +1 -0
- package/dist/{chunk-XI35ALWY.mjs → chunk-BKM5U3DE.mjs} +362 -1
- package/dist/chunk-BKM5U3DE.mjs.map +1 -0
- package/dist/{chunk-F44ET4AC.mjs → chunk-ENRSFVOS.mjs} +2657 -2264
- package/dist/chunk-ENRSFVOS.mjs.map +1 -0
- package/dist/{chunk-JOJZRGZL.mjs → chunk-MEWXQEVE.mjs} +38 -29
- package/dist/{chunk-JOJZRGZL.mjs.map → chunk-MEWXQEVE.mjs.map} +1 -1
- package/dist/{chunk-OTZEXASK.js → chunk-TWDSDTHU.js} +39 -30
- package/dist/chunk-TWDSDTHU.js.map +1 -0
- package/dist/{chunk-CV7UOUKQ.js → chunk-ZDP3MBUI.js} +1813 -1420
- package/dist/chunk-ZDP3MBUI.js.map +1 -0
- package/dist/client/index.d.mts +6 -24
- package/dist/client/index.d.ts +6 -24
- package/dist/client/index.js +4 -10
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +3 -9
- package/dist/components/index.d.mts +51 -34
- package/dist/components/index.d.ts +51 -34
- package/dist/components/index.js +4 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +9 -9
- package/dist/{config-B3jKt9P7.d.ts → config-B5oBQVEA.d.ts} +1 -1
- package/dist/{config-DkHF61xA.d.mts → config-Bx_uh22h.d.mts} +1 -1
- package/dist/contexts/index.d.mts +65 -4
- package/dist/contexts/index.d.ts +65 -4
- package/dist/contexts/index.js +12 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +11 -3
- package/dist/core/index.d.mts +126 -11
- package/dist/core/index.d.ts +126 -11
- package/dist/core/index.js +16 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +15 -1
- package/dist/index.d.mts +118 -20
- package/dist/index.d.ts +118 -20
- package/dist/index.js +19 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +18 -2
- package/dist/{notification.interface-DG6obXUH.d.mts → notification.interface-DLZGtV7Z.d.mts} +1 -1
- package/dist/{notification.interface-DcSuc9CL.d.ts → notification.interface-aLEJbA_g.d.ts} +1 -1
- package/dist/{s3.service-DGilbikH.d.mts → s3.service-CVgLWaDc.d.mts} +2 -2
- package/dist/{s3.service-DjwEQJPe.d.ts → s3.service-SLlX0Zbz.d.ts} +2 -2
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/useDataListRetriever-BqJSFBck.d.mts +33 -0
- package/dist/useDataListRetriever-BqJSFBck.d.ts +33 -0
- package/dist/{useSocket-CmzVtg32.d.mts → useSocket-BkxHHujj.d.mts} +1 -1
- package/dist/{useSocket-8eUtnL7J.d.ts → useSocket-CMDjWFYm.d.ts} +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +0 -4
- package/src/components/index.ts +2 -3
- package/src/contexts/index.ts +2 -0
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +10 -0
- package/src/features/assistant/AssistantModule.ts +19 -0
- package/src/features/assistant/components/containers/AssistantContainer.tsx +56 -0
- package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +101 -0
- package/src/features/assistant/components/index.ts +1 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +56 -0
- package/src/features/assistant/components/parts/AssistantEmptyState.tsx +47 -0
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +64 -0
- package/src/features/assistant/components/parts/AssistantStatusLine.tsx +19 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +36 -0
- package/src/features/assistant/components/parts/AssistantThreadHeader.tsx +91 -0
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +32 -0
- package/src/features/assistant/components/parts/__tests__/AssistantEmptyState.spec.tsx +27 -0
- package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +58 -0
- package/src/features/assistant/components/parts/__tests__/AssistantStatusLine.spec.tsx +19 -0
- package/src/features/assistant/components/parts/__tests__/AssistantThread.spec.tsx +39 -0
- package/src/features/assistant/components/parts/__tests__/AssistantThreadHeader.spec.tsx +67 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +255 -0
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +375 -0
- package/src/features/assistant/data/Assistant.ts +37 -0
- package/src/features/assistant/data/AssistantInterface.ts +11 -0
- package/src/features/assistant/data/AssistantService.ts +79 -0
- package/src/features/assistant/data/index.ts +3 -0
- package/src/features/assistant/index.ts +2 -0
- package/src/features/assistant/utils/__tests__/groupThreadsByBucket.spec.ts +24 -0
- package/src/features/assistant/utils/__tests__/resolveReferenceableModules.spec.ts +92 -0
- package/src/features/assistant/utils/groupThreadsByBucket.ts +26 -0
- package/src/features/assistant/utils/resolveReferenceableModules.ts +14 -0
- package/src/features/assistant-message/AssistantMessageModule.ts +28 -0
- package/src/features/assistant-message/components/MessageItem.tsx +60 -0
- package/src/features/assistant-message/components/MessageList.tsx +38 -0
- package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +108 -0
- package/src/features/assistant-message/components/index.ts +2 -0
- package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +46 -0
- package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +52 -0
- package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +59 -0
- package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +29 -0
- package/src/features/assistant-message/data/AssistantMessage.ts +95 -0
- package/src/features/assistant-message/data/AssistantMessageInterface.ts +21 -0
- package/src/features/assistant-message/data/AssistantMessageService.ts +40 -0
- package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +158 -0
- package/src/features/assistant-message/data/index.ts +3 -0
- package/src/features/assistant-message/index.ts +2 -0
- package/src/features/rbac/components/RbacContainer.tsx +318 -49
- package/src/features/rbac/components/RbacPermissionPicker.tsx +144 -121
- package/src/features/rbac/contexts/RbacContext.tsx +209 -0
- package/src/features/rbac/contexts/index.ts +1 -0
- package/src/features/rbac/data/RbacMatrixModel.ts +84 -0
- package/src/features/rbac/data/RbacService.ts +61 -33
- package/src/features/rbac/data/RbacTypes.ts +28 -0
- package/src/features/rbac/data/index.ts +1 -0
- package/src/features/rbac/index.ts +1 -10
- package/src/features/rbac/rbac.module.ts +13 -0
- package/src/features/user/contexts/CurrentUserContext.tsx +5 -13
- package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +141 -0
- package/src/index.ts +4 -0
- package/dist/HowToInterface-BKhnkzBp.d.ts +0 -17
- package/dist/HowToInterface-Cj8OuQFf.d.mts +0 -17
- package/dist/ModulePathsInterface-BrdqgteS.d.mts +0 -31
- package/dist/ModulePathsInterface-DJKs7s_s.d.ts +0 -31
- package/dist/chunk-CV7UOUKQ.js.map +0 -1
- package/dist/chunk-F44ET4AC.mjs.map +0 -1
- package/dist/chunk-FKLP4NED.js.map +0 -1
- package/dist/chunk-OTZEXASK.js.map +0 -1
- package/dist/chunk-XI35ALWY.mjs.map +0 -1
- package/dist/useRbacState-C88O-5L8.d.ts +0 -77
- package/dist/useRbacState-mqYiRp3J.d.mts +0 -77
- package/src/features/rbac/components/RbacFeatureSection.tsx +0 -66
- package/src/features/rbac/components/RbacModuleTable.tsx +0 -121
- package/src/features/rbac/components/RbacToolbar.tsx +0 -40
- package/src/features/rbac/hooks/useRbacState.test.ts +0 -180
- package/src/features/rbac/hooks/useRbacState.ts +0 -319
- package/src/features/rbac/utils/RbacMigrationGenerator.test.ts +0 -124
- package/src/features/rbac/utils/RbacMigrationGenerator.ts +0 -184
- /package/dist/{BlockNoteEditor-7HAAXN3H.mjs.map → BlockNoteEditor-6CBDTVKV.mjs.map} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { AbstractApiData } from "../../../../core/abstracts/AbstractApiData";
|
|
3
|
+
import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
|
|
4
|
+
import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
|
|
5
|
+
import { JsonApiHydratedDataInterface } from "../../../../core/interfaces/JsonApiHydratedDataInterface";
|
|
6
|
+
import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
|
|
7
|
+
import { AssistantMessage } from "../AssistantMessage";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Minimal test-only model — avoids collision with any real app module.
|
|
11
|
+
// identifierFields = ["name"] resolves "Acme" from attributes.name.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
class TestAccount extends AbstractApiData {
|
|
14
|
+
static identifierFields: string[] = ["name"];
|
|
15
|
+
|
|
16
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
17
|
+
super.rehydrate(data);
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
createJsonApi(_data?: any): any {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const testAccountModule: ApiRequestDataTypeInterface = {
|
|
27
|
+
name: "test-accounts",
|
|
28
|
+
model: TestAccount,
|
|
29
|
+
} as any;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Setup / teardown
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
const assistantMessageModule: ApiRequestDataTypeInterface = {
|
|
35
|
+
name: "assistant-messages",
|
|
36
|
+
model: AssistantMessage,
|
|
37
|
+
} as any;
|
|
38
|
+
|
|
39
|
+
const assistantModule: ApiRequestDataTypeInterface = {
|
|
40
|
+
name: "assistants",
|
|
41
|
+
model: class {},
|
|
42
|
+
} as any;
|
|
43
|
+
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
DataClassRegistry.clear();
|
|
46
|
+
DataClassRegistry.registerObjectClass(testAccountModule, TestAccount);
|
|
47
|
+
ModuleRegistry.register("TestAccount", testAccountModule);
|
|
48
|
+
ModuleRegistry.register("AssistantMessage", assistantMessageModule);
|
|
49
|
+
ModuleRegistry.register("Assistant", assistantModule);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(() => {
|
|
53
|
+
DataClassRegistry.clear();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function makeRehydratedMessage(jsonApiData: any, included: any[] = []): AssistantMessage {
|
|
60
|
+
const data: JsonApiHydratedDataInterface = {
|
|
61
|
+
jsonApi: jsonApiData,
|
|
62
|
+
included,
|
|
63
|
+
};
|
|
64
|
+
return new AssistantMessage().rehydrate(data);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Tests
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
describe("AssistantMessage.rehydrate", () => {
|
|
71
|
+
it("rehydrates `references` as an array of the correct Module's model", () => {
|
|
72
|
+
// GIVEN: JSON:API assistant-message with a `references` relationship pointing to
|
|
73
|
+
// a test-accounts entity, plus that entity in the `included` array.
|
|
74
|
+
const jsonApiData = {
|
|
75
|
+
id: "msg-1",
|
|
76
|
+
type: "assistant-messages",
|
|
77
|
+
attributes: {
|
|
78
|
+
role: "assistant",
|
|
79
|
+
content: "Here is the account.",
|
|
80
|
+
position: 1,
|
|
81
|
+
},
|
|
82
|
+
relationships: {
|
|
83
|
+
references: {
|
|
84
|
+
data: [{ type: "test-accounts", id: "acc-1" }],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
const included = [
|
|
89
|
+
{
|
|
90
|
+
type: "test-accounts",
|
|
91
|
+
id: "acc-1",
|
|
92
|
+
attributes: { name: "Acme" },
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// WHEN
|
|
97
|
+
const message = makeRehydratedMessage(jsonApiData, included);
|
|
98
|
+
|
|
99
|
+
// THEN: references should contain one hydrated TestAccount instance
|
|
100
|
+
expect(message.references).toHaveLength(1);
|
|
101
|
+
|
|
102
|
+
const ref = message.references[0] as any;
|
|
103
|
+
// Type and id should be populated from the included entry
|
|
104
|
+
expect(ref.type).toBe("test-accounts");
|
|
105
|
+
expect(ref.id).toBe("acc-1");
|
|
106
|
+
// identifier reads from identifierFields = ["name"] → attributes.name = "Acme"
|
|
107
|
+
expect(ref.identifier).toBe("Acme");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns an empty array when the relationship is absent", () => {
|
|
111
|
+
// GIVEN: JSON:API assistant-message with no `references` attribute or relationship
|
|
112
|
+
const jsonApiData = {
|
|
113
|
+
id: "msg-2",
|
|
114
|
+
type: "assistant-messages",
|
|
115
|
+
attributes: {
|
|
116
|
+
role: "user",
|
|
117
|
+
content: "Hello.",
|
|
118
|
+
position: 0,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// WHEN
|
|
123
|
+
const message = makeRehydratedMessage(jsonApiData, []);
|
|
124
|
+
|
|
125
|
+
// THEN
|
|
126
|
+
expect(message.references).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("AssistantMessage.buildOptimistic", () => {
|
|
131
|
+
it("creates a user message with a tmp-prefixed id and the given content + position", () => {
|
|
132
|
+
const msg = AssistantMessage.buildOptimistic({
|
|
133
|
+
content: "hello",
|
|
134
|
+
assistantId: "a-1",
|
|
135
|
+
position: 3,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(msg.id.startsWith("tmp-")).toBe(true);
|
|
139
|
+
expect(msg.role).toBe("user");
|
|
140
|
+
expect(msg.content).toBe("hello");
|
|
141
|
+
expect(msg.position).toBe(3);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("allows an omitted assistantId (first-message case)", () => {
|
|
145
|
+
const msg = AssistantMessage.buildOptimistic({ content: "first", position: 1 });
|
|
146
|
+
|
|
147
|
+
expect(msg.id.startsWith("tmp-")).toBe(true);
|
|
148
|
+
expect(msg.role).toBe("user");
|
|
149
|
+
expect(msg.content).toBe("first");
|
|
150
|
+
expect(msg.position).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("produces distinct ids across successive calls", () => {
|
|
154
|
+
const a = AssistantMessage.buildOptimistic({ content: "x", position: 1 });
|
|
155
|
+
const b = AssistantMessage.buildOptimistic({ content: "x", position: 2 });
|
|
156
|
+
expect(a.id).not.toEqual(b.id);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -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 {
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
10
|
-
import
|
|
11
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
}, [
|
|
226
|
+
return Array.from(set).sort();
|
|
227
|
+
}, [matrix, roleNames]);
|
|
39
228
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (!
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
}, [stateApi]);
|
|
268
|
+
// --- Transient-state returns ------------------------------------------------
|
|
52
269
|
|
|
53
270
|
if (loading) {
|
|
54
271
|
return (
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
|
|
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
|
-
<
|
|
64
|
-
<
|
|
65
|
-
|
|
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 (!
|
|
290
|
+
if (!matrix) return null;
|
|
291
|
+
|
|
292
|
+
const selectedBlock: RbacModuleBlock | undefined = selectedModuleId ? matrix[selectedModuleId] : undefined;
|
|
70
293
|
|
|
71
294
|
return (
|
|
72
|
-
<RoundPageContainer>
|
|
73
|
-
<
|
|
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
|
-
{
|
|
76
|
-
|
|
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
|
}
|