@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.
- package/dist/{AssistantMessageInterface-DS_tyJTV.d.ts → AssistantMessageInterface-BpEhx2pC.d.ts} +19 -2
- package/dist/{AssistantMessageInterface-D0Kwf8CR.d.mts → AssistantMessageInterface-DJ3Me16Y.d.mts} +19 -2
- 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-JXK3JGKJ.mjs → BlockNoteEditor-3M5PD3BZ.mjs} +4 -4
- package/dist/{BlockNoteEditor-2G5UYALC.js → BlockNoteEditor-YLTPJPTV.js} +14 -14
- package/dist/{BlockNoteEditor-2G5UYALC.js.map → BlockNoteEditor-YLTPJPTV.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-ZEDB6JVB.js → chunk-4NOQNTFI.js} +1585 -1405
- package/dist/chunk-4NOQNTFI.js.map +1 -0
- package/dist/{chunk-I65SSQ5Z.mjs → chunk-6UMB5LTQ.mjs} +157 -7
- package/dist/chunk-6UMB5LTQ.mjs.map +1 -0
- package/dist/{chunk-FDJQRIMY.js → chunk-N4YZ45SK.js} +174 -24
- package/dist/chunk-N4YZ45SK.js.map +1 -0
- package/dist/{chunk-NB6TIKHK.mjs → chunk-NQV5RDCK.mjs} +2524 -2344
- package/dist/chunk-NQV5RDCK.mjs.map +1 -0
- package/dist/{chunk-NZOUEN67.mjs → chunk-PV5V6CVW.mjs} +38 -29
- package/dist/{chunk-NZOUEN67.mjs.map → chunk-PV5V6CVW.mjs.map} +1 -1
- package/dist/{chunk-X4YDETTD.js → chunk-ZEJSPTHS.js} +39 -30
- package/dist/chunk-ZEJSPTHS.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 +55 -39
- package/dist/components/index.d.ts +55 -39
- package/dist/components/index.js +4 -8
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +5 -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 +41 -4
- package/dist/contexts/index.d.ts +41 -4
- package/dist/contexts/index.js +8 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +7 -3
- package/dist/core/index.d.mts +51 -11
- package/dist/core/index.d.ts +51 -11
- package/dist/core/index.js +8 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +7 -1
- package/dist/index.d.mts +117 -20
- package/dist/index.d.ts +117 -20
- package/dist/index.js +11 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10 -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 +0 -3
- package/src/contexts/index.ts +1 -0
- package/src/core/index.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +2 -0
- package/src/features/assistant/components/parts/AssistantThread.tsx +1 -1
- package/src/features/assistant-message/AssistantMessageModule.ts +4 -0
- package/src/features/assistant-message/components/MessageItem.tsx +7 -7
- package/src/features/assistant-message/components/MessageList.tsx +1 -1
- package/src/features/assistant-message/components/__tests__/MessageItem.spec.tsx +11 -7
- package/src/features/assistant-message/components/index.ts +1 -0
- package/src/features/assistant-message/components/parts/MessageSourcesContainer.tsx +135 -0
- package/src/features/assistant-message/components/parts/MessageSourcesPanel.tsx +151 -0
- package/src/features/assistant-message/components/parts/RelevanceMeter.tsx +29 -0
- package/src/features/assistant-message/components/parts/__tests__/MessageSourcesPanel.spec.tsx +70 -0
- package/src/features/assistant-message/components/parts/tabs/CitationsTab.tsx +105 -0
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +88 -0
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +51 -0
- package/src/features/assistant-message/components/parts/tabs/SuggestedQuestionsTab.tsx +24 -0
- package/src/features/assistant-message/components/parts/tabs/UsersTab.tsx +142 -0
- package/src/features/assistant-message/data/AssistantMessage.ts +20 -0
- package/src/features/assistant-message/data/AssistantMessageInterface.ts +2 -0
- package/src/features/assistant-message/data/AssistantMessageService.ts +13 -4
- package/src/features/assistant-message/data/__tests__/AssistantMessage.citations.spec.ts +65 -0
- package/src/features/assistant-message/data/__tests__/AssistantMessage.spec.ts +8 -0
- package/src/features/chunk/ChunkModule.ts +18 -0
- package/src/features/chunk/data/Chunk.ts +49 -0
- package/src/features/chunk/data/ChunkInput.ts +3 -0
- package/src/features/chunk/data/ChunkInterface.ts +18 -0
- package/src/features/chunk/data/__tests__/Chunk.spec.ts +83 -0
- package/src/features/chunk/data/index.ts +3 -0
- package/src/features/chunk/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/dist/ModulePathsInterface-BrdqgteS.d.mts +0 -31
- package/dist/ModulePathsInterface-DJKs7s_s.d.ts +0 -31
- package/dist/chunk-FDJQRIMY.js.map +0 -1
- package/dist/chunk-I65SSQ5Z.mjs.map +0 -1
- package/dist/chunk-NB6TIKHK.mjs.map +0 -1
- package/dist/chunk-X4YDETTD.js.map +0 -1
- package/dist/chunk-ZEDB6JVB.js.map +0 -1
- package/dist/useRbacState-C88O-5L8.d.ts +0 -77
- package/dist/useRbacState-mqYiRp3J.d.mts +0 -77
- package/src/features/assistant-message/components/parts/ReferenceBadges.tsx +0 -46
- package/src/features/assistant-message/components/parts/SuggestedFollowUps.tsx +0 -52
- package/src/features/assistant-message/components/parts/__tests__/ReferenceBadges.spec.tsx +0 -59
- package/src/features/assistant-message/components/parts/__tests__/SuggestedFollowUps.spec.tsx +0 -29
- 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-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,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
|
+
});
|
|
@@ -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
|
}
|