@nocobase/portal-template-default 1.0.0 → 1.0.2
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/README.MD +90 -1
- package/components.json +3 -1
- package/package.json +21 -5
- package/registry/nocobase-acl/README.md +19 -0
- package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
- package/registry/nocobase-acl/components/index.ts +3 -0
- package/registry/nocobase-acl/components/role-options.ts +36 -0
- package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
- package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
- package/registry/nocobase-acl/demo/components.tsx +231 -0
- package/registry/nocobase-acl/demo/index.tsx +505 -0
- package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
- package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
- package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
- package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
- package/registry/nocobase-acl/extension.tsx +71 -0
- package/registry/nocobase-acl/index.ts +1 -0
- package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
- package/registry/nocobase-ai/README.md +70 -0
- package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
- package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
- package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
- package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
- package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
- package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
- package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
- package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
- package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
- package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
- package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
- package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
- package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
- package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
- package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
- package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
- package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
- package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
- package/registry/nocobase-ai/components/index.ts +59 -0
- package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
- package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
- package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
- package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
- package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
- package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
- package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
- package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
- package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
- package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
- package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
- package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
- package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
- package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
- package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
- package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
- package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
- package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
- package/registry/nocobase-ai/demo/floating.tsx +134 -0
- package/registry/nocobase-ai/demo/index.tsx +346 -0
- package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
- package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
- package/registry/nocobase-ai/demo/page-context.tsx +884 -0
- package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
- package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
- package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
- package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
- package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
- package/registry/nocobase-ai/extension.tsx +158 -0
- package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
- package/registry/nocobase-ai/index.ts +3 -0
- package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
- package/registry/nocobase-ai/providers/avatars.ts +817 -0
- package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
- package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
- package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
- package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
- package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
- package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
- package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
- package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
- package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
- package/registry/nocobase-ai/providers/index.ts +61 -0
- package/registry/nocobase-ai/providers/model.ts +32 -0
- package/registry/nocobase-ai/providers/page-context.tsx +136 -0
- package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
- package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
- package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
- package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
- package/registry/nocobase-ai/providers/types.ts +183 -0
- package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
- package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
- package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
- package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
- package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
- package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
- package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
- package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
- package/registry/nocobase-ai/services/index.ts +7 -0
- package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
- package/registry/nocobase-ai/services/types.ts +82 -0
- package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
- package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
- package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
- package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
- package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
- package/registry/nocobase-i18n/README.md +53 -0
- package/registry/nocobase-i18n/components/index.ts +1 -0
- package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
- package/registry/nocobase-i18n/demo/index.tsx +209 -0
- package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
- package/registry/nocobase-i18n/extension.tsx +46 -0
- package/registry/nocobase-i18n/i18n-provider.ts +9 -0
- package/registry/nocobase-i18n/index.ts +6 -0
- package/registry/nocobase-i18n/locale-store.ts +134 -0
- package/registry/nocobase-i18n/locales/en-US.ts +89 -0
- package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
- package/registry/nocobase-i18n/provider.tsx +32 -0
- package/registry/nocobase-i18n/runtime.ts +118 -0
- package/registry/nocobase-i18n/server-resources.ts +184 -0
- package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
- package/registry.config.json +163 -0
- package/scripts/registry.mjs +164 -0
- package/src/App.css +95 -0
- package/src/App.tsx +87 -69
- package/src/app/extension.ts +11 -0
- package/src/app/extensions.tsx +49 -0
- package/src/components/access-control/access-denied.tsx +30 -0
- package/src/components/access-control/acl-bootstrap.tsx +9 -0
- package/src/components/access-control/acl-gate.tsx +36 -0
- package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
- package/src/components/access-control/resource-access-guard.tsx +21 -0
- package/src/components/access-control/use-acl-runtime.ts +28 -0
- package/src/components/app-shell/breadcrumb.tsx +39 -3
- package/src/components/app-shell/document-title-handler.tsx +11 -20
- package/src/components/app-shell/header.tsx +22 -4
- package/src/components/app-shell/loading-overlay.tsx +2 -4
- package/src/components/app-shell/loading-state.tsx +15 -0
- package/src/components/app-shell/sidebar.tsx +103 -45
- package/src/components/data-table/data-table-pagination.tsx +27 -7
- package/src/components/data-table/data-table-styles.ts +57 -0
- package/src/components/data-table/data-table.tsx +14 -59
- package/src/components/resources/resource-label.ts +101 -0
- package/src/components/resources/views/create-view.tsx +17 -14
- package/src/components/resources/views/edit-view.tsx +17 -15
- package/src/components/resources/views/list-view.tsx +20 -10
- package/src/components/resources/views/show-view.tsx +20 -18
- package/src/index.tsx +2 -3
- package/src/lib/i18n.ts +161 -0
- package/src/lib/nocobase/acl/action.ts +147 -0
- package/src/lib/nocobase/acl/data-source.ts +24 -0
- package/src/lib/nocobase/acl/index.ts +6 -0
- package/src/lib/nocobase/acl/menu.ts +41 -0
- package/src/lib/nocobase/acl/record-permissions.ts +111 -0
- package/src/lib/nocobase/acl/store.ts +182 -0
- package/src/lib/nocobase/acl/types.ts +96 -0
- package/src/lib/nocobase/client.ts +279 -0
- package/src/lib/nocobase/error.ts +32 -0
- package/src/locales/en-US.ts +82 -0
- package/src/locales/index.ts +13 -0
- package/src/locales/zh-CN.ts +79 -0
- package/src/pages/users/create.tsx +98 -0
- package/src/pages/users/edit.tsx +92 -0
- package/src/pages/users/form-context.ts +57 -0
- package/src/pages/users/form-fields.tsx +211 -0
- package/src/pages/users/list.tsx +205 -0
- package/src/pages/users/show.tsx +187 -0
- package/src/pages/users/types.ts +20 -0
- package/src/providers/access-control.ts +34 -0
- package/src/providers/auth.ts +74 -60
- package/src/providers/constants.ts +3 -37
- package/src/providers/data.ts +242 -85
- package/vite.config.ts +17 -0
- package/src/pages/blog-posts/create.tsx +0 -177
- package/src/pages/blog-posts/edit.tsx +0 -197
- package/src/pages/blog-posts/list.tsx +0 -131
- package/src/pages/blog-posts/show.tsx +0 -81
- package/src/pages/categories/create.tsx +0 -73
- package/src/pages/categories/edit.tsx +0 -73
- package/src/pages/categories/index.ts +0 -4
- package/src/pages/categories/list.tsx +0 -86
- package/src/pages/categories/show.tsx +0 -37
- /package/src/pages/{blog-posts → users}/index.ts +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AclActionParams,
|
|
3
|
+
AclCanParams,
|
|
4
|
+
AclRoleData,
|
|
5
|
+
AclSnapshot,
|
|
6
|
+
ResourceAcl,
|
|
7
|
+
} from "./types";
|
|
8
|
+
import ignore from "ignore";
|
|
9
|
+
import { getRecordActionPermission } from "./record-permissions";
|
|
10
|
+
import { resolveAclDataSourceKey } from "./data-source";
|
|
11
|
+
|
|
12
|
+
const REFINE_ACTION_MAP: Record<string, string> = {
|
|
13
|
+
list: "list",
|
|
14
|
+
show: "get",
|
|
15
|
+
create: "create",
|
|
16
|
+
edit: "update",
|
|
17
|
+
delete: "destroy",
|
|
18
|
+
clone: "create",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const getResourceAcl = (params?: AclCanParams["params"]) =>
|
|
22
|
+
params?.resource?.meta?.acl as ResourceAcl | undefined;
|
|
23
|
+
|
|
24
|
+
export const mapRefineAction = (action: string) =>
|
|
25
|
+
REFINE_ACTION_MAP[action] ?? action;
|
|
26
|
+
|
|
27
|
+
export const getAclDataForDataSource = (
|
|
28
|
+
snapshot: AclSnapshot,
|
|
29
|
+
dataSourceKey = "main"
|
|
30
|
+
): AclRoleData => {
|
|
31
|
+
const dataSourceAcl = snapshot.meta.dataSources?.[dataSourceKey];
|
|
32
|
+
return dataSourceAcl
|
|
33
|
+
? {
|
|
34
|
+
...snapshot.data,
|
|
35
|
+
...dataSourceAcl,
|
|
36
|
+
snippets: snapshot.data.snippets,
|
|
37
|
+
}
|
|
38
|
+
: snapshot.data;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const resolveActionPermission = ({
|
|
42
|
+
snapshot,
|
|
43
|
+
resource,
|
|
44
|
+
action,
|
|
45
|
+
dataSourceKey = "main",
|
|
46
|
+
}: {
|
|
47
|
+
snapshot: AclSnapshot;
|
|
48
|
+
resource: string;
|
|
49
|
+
action: string;
|
|
50
|
+
dataSourceKey?: string;
|
|
51
|
+
}): AclActionParams | null => {
|
|
52
|
+
const data = getAclDataForDataSource(snapshot, dataSourceKey);
|
|
53
|
+
if (data.allowAll) return {};
|
|
54
|
+
|
|
55
|
+
const canonicalAction = data.actionAlias?.[action] ?? action;
|
|
56
|
+
if (data.resources?.includes(resource)) {
|
|
57
|
+
return (
|
|
58
|
+
data.actions?.[`${resource}:${canonicalAction}`] ??
|
|
59
|
+
data.actions?.[`${resource}:${action}`] ??
|
|
60
|
+
null
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const strategyAllowed = data.strategy?.actions?.some(
|
|
65
|
+
(item) => item.split(":")[0] === canonicalAction
|
|
66
|
+
);
|
|
67
|
+
return strategyAllowed ? {} : null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const matchesSnippet = (snippets: string[], target: string) =>
|
|
71
|
+
!target || target === "*" || ignore().add(snippets).ignores(target);
|
|
72
|
+
|
|
73
|
+
export const canAccessWithSnapshot = (
|
|
74
|
+
snapshot: AclSnapshot,
|
|
75
|
+
{ resource, action, params }: AclCanParams
|
|
76
|
+
) => {
|
|
77
|
+
if (snapshot.status !== "ready") return false;
|
|
78
|
+
|
|
79
|
+
const resourceAcl = getResourceAcl(params);
|
|
80
|
+
if (resourceAcl === false || resourceAcl?.type === "authenticated") {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (resourceAcl?.type === "snippet") {
|
|
85
|
+
return (
|
|
86
|
+
snapshot.data.allowAll ||
|
|
87
|
+
matchesSnippet(snapshot.data.snippets ?? [], resourceAcl.name)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (resourceAcl?.type === "route") {
|
|
92
|
+
return (
|
|
93
|
+
snapshot.data.allowAll ||
|
|
94
|
+
(snapshot.data.allowMenuItemIds ?? [])
|
|
95
|
+
.map(String)
|
|
96
|
+
.includes(String(resourceAcl.routeId))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const targetResource =
|
|
101
|
+
resourceAcl?.type === "collection" && resourceAcl.resource
|
|
102
|
+
? resourceAcl.resource
|
|
103
|
+
: resource;
|
|
104
|
+
if (!targetResource) return true;
|
|
105
|
+
|
|
106
|
+
const dataSourceKey =
|
|
107
|
+
resolveAclDataSourceKey(
|
|
108
|
+
params,
|
|
109
|
+
params?.meta as { dataSourceKey?: unknown; acl?: ResourceAcl } | undefined,
|
|
110
|
+
params?.resource?.meta as
|
|
111
|
+
| { dataSourceKey?: unknown; acl?: ResourceAcl }
|
|
112
|
+
| undefined
|
|
113
|
+
) ?? "main";
|
|
114
|
+
const mappedAction =
|
|
115
|
+
(resourceAcl?.type === "collection" && resourceAcl.actionMap?.[action]) ||
|
|
116
|
+
mapRefineAction(action);
|
|
117
|
+
const permission = resolveActionPermission({
|
|
118
|
+
snapshot,
|
|
119
|
+
resource: targetResource,
|
|
120
|
+
action: mappedAction,
|
|
121
|
+
dataSourceKey,
|
|
122
|
+
});
|
|
123
|
+
if (!permission) return false;
|
|
124
|
+
|
|
125
|
+
if (params?.id !== undefined) {
|
|
126
|
+
const recordPermission = getRecordActionPermission({
|
|
127
|
+
dataSourceKey,
|
|
128
|
+
resource: targetResource,
|
|
129
|
+
action: mappedAction,
|
|
130
|
+
id: params.id,
|
|
131
|
+
actionAlias: getAclDataForDataSource(snapshot, dataSourceKey).actionAlias,
|
|
132
|
+
});
|
|
133
|
+
if (recordPermission !== undefined) return recordPermission;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof params?.field === "string") {
|
|
137
|
+
const fields = [
|
|
138
|
+
...(permission.fields ?? []),
|
|
139
|
+
...(permission.whitelist ?? []),
|
|
140
|
+
...(permission.appends ?? []),
|
|
141
|
+
];
|
|
142
|
+
if (!fields.length) return true;
|
|
143
|
+
return fields.includes(params.field);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return true;
|
|
147
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ResourceAcl } from "./types";
|
|
2
|
+
|
|
3
|
+
type DataSourceMeta = {
|
|
4
|
+
dataSourceKey?: unknown;
|
|
5
|
+
acl?: ResourceAcl;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const resolveAclDataSourceKey = (
|
|
9
|
+
...candidates: Array<DataSourceMeta | null | undefined>
|
|
10
|
+
) => {
|
|
11
|
+
for (const candidate of candidates) {
|
|
12
|
+
if (typeof candidate?.dataSourceKey === "string") {
|
|
13
|
+
return candidate.dataSourceKey;
|
|
14
|
+
}
|
|
15
|
+
if (
|
|
16
|
+
candidate?.acl &&
|
|
17
|
+
candidate.acl.type === "collection" &&
|
|
18
|
+
candidate.acl.dataSourceKey
|
|
19
|
+
) {
|
|
20
|
+
return candidate.acl.dataSourceKey;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { TreeMenuItem } from "@refinedev/core";
|
|
2
|
+
|
|
3
|
+
import { canAccessWithSnapshot } from "./action";
|
|
4
|
+
import type { AclSnapshot } from "./types";
|
|
5
|
+
|
|
6
|
+
export const filterMenuItemsByAcl = (
|
|
7
|
+
items: TreeMenuItem[],
|
|
8
|
+
acl: AclSnapshot
|
|
9
|
+
): TreeMenuItem[] =>
|
|
10
|
+
items.flatMap((item) => {
|
|
11
|
+
const children = filterMenuItemsByAcl(item.children ?? [], acl);
|
|
12
|
+
const isContainer = Boolean(item.meta?.group || item.children?.length);
|
|
13
|
+
const canAccessItem = item.route
|
|
14
|
+
? canAccessWithSnapshot(acl, {
|
|
15
|
+
resource: item.name,
|
|
16
|
+
action: "list",
|
|
17
|
+
params: { resource: item },
|
|
18
|
+
})
|
|
19
|
+
: true;
|
|
20
|
+
|
|
21
|
+
if (isContainer && !children.length && !item.route) return [];
|
|
22
|
+
if (!canAccessItem && !children.length) return [];
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
...item,
|
|
26
|
+
route: canAccessItem ? item.route : undefined,
|
|
27
|
+
children,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const findFirstAccessibleRoute = (
|
|
33
|
+
items: TreeMenuItem[]
|
|
34
|
+
): string | undefined => {
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
if (item.route) return item.route;
|
|
37
|
+
const childRoute = findFirstAccessibleRoute(item.children ?? []);
|
|
38
|
+
if (childRoute) return childRoute;
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { BaseKey } from "@refinedev/core";
|
|
2
|
+
|
|
3
|
+
type RecordPermissionEntry = {
|
|
4
|
+
allowedIds: Map<string, Set<string>>;
|
|
5
|
+
knownIds: Map<string, Set<string>>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const permissions = new Map<string, RecordPermissionEntry>();
|
|
9
|
+
|
|
10
|
+
const getKey = (dataSourceKey: string, resource: string) =>
|
|
11
|
+
`${dataSourceKey}:${resource}`;
|
|
12
|
+
|
|
13
|
+
export const clearRecordPermissions = () => {
|
|
14
|
+
permissions.clear();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const updateRecordPermissions = ({
|
|
18
|
+
dataSourceKey = "main",
|
|
19
|
+
resource,
|
|
20
|
+
recordIds,
|
|
21
|
+
allowedActions,
|
|
22
|
+
}: {
|
|
23
|
+
dataSourceKey?: string;
|
|
24
|
+
resource: string;
|
|
25
|
+
recordIds: BaseKey[];
|
|
26
|
+
allowedActions?: Record<string, BaseKey[]>;
|
|
27
|
+
}) => {
|
|
28
|
+
if (!allowedActions) return false;
|
|
29
|
+
|
|
30
|
+
const key = getKey(dataSourceKey, resource);
|
|
31
|
+
const entry = permissions.get(key) ?? {
|
|
32
|
+
allowedIds: new Map<string, Set<string>>(),
|
|
33
|
+
knownIds: new Map<string, Set<string>>(),
|
|
34
|
+
};
|
|
35
|
+
const normalizedIds = new Set(recordIds.map(String));
|
|
36
|
+
const normalizedAllowedActions = new Map(
|
|
37
|
+
Object.entries(allowedActions).map(([action, ids]) => [
|
|
38
|
+
action,
|
|
39
|
+
new Set(ids.map(String)),
|
|
40
|
+
])
|
|
41
|
+
);
|
|
42
|
+
const actionNames = new Set([
|
|
43
|
+
...entry.knownIds.keys(),
|
|
44
|
+
...normalizedAllowedActions.keys(),
|
|
45
|
+
]);
|
|
46
|
+
let changed = false;
|
|
47
|
+
|
|
48
|
+
for (const action of actionNames) {
|
|
49
|
+
const previousAllowedIds = entry.allowedIds.get(action);
|
|
50
|
+
const previousKnownIds = entry.knownIds.get(action);
|
|
51
|
+
const nextAllowedIds = normalizedAllowedActions.get(action);
|
|
52
|
+
const nextKnown = Boolean(nextAllowedIds);
|
|
53
|
+
for (const id of normalizedIds) {
|
|
54
|
+
const previousKnown = Boolean(previousKnownIds?.has(id));
|
|
55
|
+
if (
|
|
56
|
+
previousKnown !== nextKnown ||
|
|
57
|
+
(nextKnown &&
|
|
58
|
+
Boolean(previousAllowedIds?.has(id)) !==
|
|
59
|
+
Boolean(nextAllowedIds?.has(id)))
|
|
60
|
+
) {
|
|
61
|
+
changed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const action of actionNames) {
|
|
67
|
+
const allowedIds = entry.allowedIds.get(action);
|
|
68
|
+
const knownIds = entry.knownIds.get(action);
|
|
69
|
+
for (const id of normalizedIds) {
|
|
70
|
+
allowedIds?.delete(id);
|
|
71
|
+
knownIds?.delete(id);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const [action, ids] of normalizedAllowedActions) {
|
|
76
|
+
const allowedIds = entry.allowedIds.get(action) ?? new Set<string>();
|
|
77
|
+
const knownIds = entry.knownIds.get(action) ?? new Set<string>();
|
|
78
|
+
ids.forEach((id) => allowedIds.add(id));
|
|
79
|
+
normalizedIds.forEach((id) => knownIds.add(id));
|
|
80
|
+
entry.allowedIds.set(action, allowedIds);
|
|
81
|
+
entry.knownIds.set(action, knownIds);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
permissions.set(key, entry);
|
|
85
|
+
return changed;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const getRecordActionPermission = ({
|
|
89
|
+
dataSourceKey = "main",
|
|
90
|
+
resource,
|
|
91
|
+
action,
|
|
92
|
+
id,
|
|
93
|
+
actionAlias,
|
|
94
|
+
}: {
|
|
95
|
+
dataSourceKey?: string;
|
|
96
|
+
resource: string;
|
|
97
|
+
action: string;
|
|
98
|
+
id: BaseKey;
|
|
99
|
+
actionAlias?: Record<string, string>;
|
|
100
|
+
}): boolean | undefined => {
|
|
101
|
+
const entry = permissions.get(getKey(dataSourceKey, resource));
|
|
102
|
+
if (!entry) return undefined;
|
|
103
|
+
|
|
104
|
+
const canonicalAction = actionAlias?.[action] ?? action;
|
|
105
|
+
const targetAction = entry.knownIds.has(canonicalAction)
|
|
106
|
+
? canonicalAction
|
|
107
|
+
: action;
|
|
108
|
+
const recordId = String(id);
|
|
109
|
+
if (!entry.knownIds.get(targetAction)?.has(recordId)) return undefined;
|
|
110
|
+
return Boolean(entry.allowedIds.get(targetAction)?.has(recordId));
|
|
111
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
import { nocobaseClient } from "@/lib/nocobase/client";
|
|
4
|
+
import { NocoBaseHttpError } from "@/lib/nocobase/error";
|
|
5
|
+
import { clearRecordPermissions } from "./record-permissions";
|
|
6
|
+
import type {
|
|
7
|
+
AclResponse,
|
|
8
|
+
AclSnapshot,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
const listeners = new Set<() => void>();
|
|
12
|
+
let request:
|
|
13
|
+
| {
|
|
14
|
+
id: number;
|
|
15
|
+
identity: string;
|
|
16
|
+
promise: Promise<AclSnapshot>;
|
|
17
|
+
}
|
|
18
|
+
| undefined;
|
|
19
|
+
let requestId = 0;
|
|
20
|
+
let snapshot: AclSnapshot = {
|
|
21
|
+
status: "idle",
|
|
22
|
+
data: {},
|
|
23
|
+
meta: {},
|
|
24
|
+
version: 0,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const emit = () => listeners.forEach((listener) => listener());
|
|
28
|
+
|
|
29
|
+
const setSnapshot = (next: Partial<AclSnapshot>) => {
|
|
30
|
+
snapshot = {
|
|
31
|
+
...snapshot,
|
|
32
|
+
...next,
|
|
33
|
+
version: snapshot.version + 1,
|
|
34
|
+
};
|
|
35
|
+
emit();
|
|
36
|
+
return snapshot;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getIdentity = () => {
|
|
40
|
+
const token = nocobaseClient.getToken();
|
|
41
|
+
return token ? `${token}:${nocobaseClient.getRole() ?? ""}` : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const requestAcl = async (role?: string) =>
|
|
45
|
+
nocobaseClient.request<AclResponse>("roles:check", {
|
|
46
|
+
unwrap: "none",
|
|
47
|
+
withAclMeta: false,
|
|
48
|
+
role,
|
|
49
|
+
includeRole: Boolean(role),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const isStaleRoleError = (error: unknown) => {
|
|
53
|
+
if (!(error instanceof NocoBaseHttpError)) return false;
|
|
54
|
+
const payload = error.payload as
|
|
55
|
+
| {
|
|
56
|
+
code?: string;
|
|
57
|
+
error?: { code?: string };
|
|
58
|
+
errors?: Array<{ code?: string }>;
|
|
59
|
+
}
|
|
60
|
+
| undefined;
|
|
61
|
+
const codes = [
|
|
62
|
+
payload?.code,
|
|
63
|
+
payload?.error?.code,
|
|
64
|
+
...(payload?.errors?.map((item) => item.code) ?? []),
|
|
65
|
+
];
|
|
66
|
+
return codes.some((code) =>
|
|
67
|
+
["ROLE_NOT_FOUND_ERR", "ROLE_NOT_FOUND_FOR_USER"].includes(code ?? "")
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const getAclSnapshot = () => snapshot;
|
|
72
|
+
|
|
73
|
+
export const subscribeAcl = (listener: () => void) => {
|
|
74
|
+
listeners.add(listener);
|
|
75
|
+
return () => listeners.delete(listener);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const useAclSnapshot = () =>
|
|
79
|
+
useSyncExternalStore(
|
|
80
|
+
subscribeAcl,
|
|
81
|
+
getAclSnapshot,
|
|
82
|
+
getAclSnapshot
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
export const clearAcl = ({ keepRole = false } = {}) => {
|
|
86
|
+
requestId += 1;
|
|
87
|
+
request = undefined;
|
|
88
|
+
clearRecordPermissions();
|
|
89
|
+
if (!keepRole) nocobaseClient.setRole(null);
|
|
90
|
+
setSnapshot({
|
|
91
|
+
status: "idle",
|
|
92
|
+
identity: undefined,
|
|
93
|
+
data: {},
|
|
94
|
+
meta: {},
|
|
95
|
+
error: undefined,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const notifyRecordPermissionsChanged = () => {
|
|
100
|
+
setSnapshot({});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const loadAcl = async ({ force = false } = {}) => {
|
|
104
|
+
const identity = getIdentity();
|
|
105
|
+
if (!identity) {
|
|
106
|
+
clearAcl();
|
|
107
|
+
return snapshot;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!force && snapshot.status === "ready" && snapshot.identity === identity) {
|
|
111
|
+
return snapshot;
|
|
112
|
+
}
|
|
113
|
+
if (!force && request?.identity === identity) return request.promise;
|
|
114
|
+
|
|
115
|
+
setSnapshot({ status: "loading", identity, error: undefined });
|
|
116
|
+
const currentRequestId = ++requestId;
|
|
117
|
+
const requestedRole = nocobaseClient.getRole();
|
|
118
|
+
const promise = (async () => {
|
|
119
|
+
try {
|
|
120
|
+
let response: AclResponse;
|
|
121
|
+
let effectiveRole = requestedRole;
|
|
122
|
+
try {
|
|
123
|
+
response = await requestAcl(requestedRole);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!requestedRole || !isStaleRoleError(error)) throw error;
|
|
126
|
+
effectiveRole = undefined;
|
|
127
|
+
response = await requestAcl();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (currentRequestId !== requestId) return snapshot;
|
|
131
|
+
|
|
132
|
+
const data = response.data ?? {};
|
|
133
|
+
nocobaseClient.setRole(data.role ?? effectiveRole ?? null);
|
|
134
|
+
clearRecordPermissions();
|
|
135
|
+
return setSnapshot({
|
|
136
|
+
status: "ready",
|
|
137
|
+
identity: getIdentity(),
|
|
138
|
+
data,
|
|
139
|
+
meta: response.meta ?? {},
|
|
140
|
+
error: undefined,
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
const normalizedError =
|
|
144
|
+
error instanceof Error ? error : new Error("Unable to load permissions");
|
|
145
|
+
if (currentRequestId !== requestId) return snapshot;
|
|
146
|
+
setSnapshot({ status: "error", error: normalizedError });
|
|
147
|
+
throw normalizedError;
|
|
148
|
+
} finally {
|
|
149
|
+
if (request?.id === currentRequestId) request = undefined;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
request = { id: currentRequestId, identity, promise };
|
|
153
|
+
|
|
154
|
+
return promise;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export const switchRole = async (
|
|
158
|
+
roleName: string,
|
|
159
|
+
{ reloadAcl = true } = {}
|
|
160
|
+
) => {
|
|
161
|
+
const previousRole = nocobaseClient.getRole();
|
|
162
|
+
nocobaseClient.setRole(roleName);
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await nocobaseClient.action("users", "setDefaultRole", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: { roleName },
|
|
168
|
+
withAclMeta: false,
|
|
169
|
+
});
|
|
170
|
+
if (!reloadAcl) {
|
|
171
|
+
clearRecordPermissions();
|
|
172
|
+
return snapshot;
|
|
173
|
+
}
|
|
174
|
+
return await loadAcl({ force: true });
|
|
175
|
+
} catch (error) {
|
|
176
|
+
nocobaseClient.setRole(previousRole);
|
|
177
|
+
if (reloadAcl) {
|
|
178
|
+
await loadAcl({ force: true }).catch(() => undefined);
|
|
179
|
+
}
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { BaseKey, IResourceItem } from "@refinedev/core";
|
|
2
|
+
|
|
3
|
+
export type RoleMode =
|
|
4
|
+
| "default"
|
|
5
|
+
| "allow-use-union"
|
|
6
|
+
| "only-use-union";
|
|
7
|
+
|
|
8
|
+
export type Role = {
|
|
9
|
+
name: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type AclActionParams = Record<string, unknown> & {
|
|
14
|
+
fields?: string[];
|
|
15
|
+
whitelist?: string[];
|
|
16
|
+
appends?: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AclRoleData = {
|
|
20
|
+
snippets?: string[];
|
|
21
|
+
role?: string;
|
|
22
|
+
roleMode?: RoleMode;
|
|
23
|
+
resources?: string[];
|
|
24
|
+
actions?: Record<string, AclActionParams>;
|
|
25
|
+
actionAlias?: Record<string, string>;
|
|
26
|
+
strategy?: {
|
|
27
|
+
actions?: string[];
|
|
28
|
+
};
|
|
29
|
+
allowAll?: boolean;
|
|
30
|
+
allowConfigure?: boolean;
|
|
31
|
+
availableActions?: string[];
|
|
32
|
+
allowMenuItemIds?: Array<string | number>;
|
|
33
|
+
allowAnonymous?: boolean;
|
|
34
|
+
uiButtonSchemasBlacklist?: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AclMeta = {
|
|
38
|
+
dataSources?: Record<string, Partial<AclRoleData>>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type AclResponse = {
|
|
42
|
+
data?: AclRoleData;
|
|
43
|
+
meta?: AclMeta;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AclSnapshot = {
|
|
47
|
+
status: "idle" | "loading" | "ready" | "error";
|
|
48
|
+
identity?: string;
|
|
49
|
+
data: AclRoleData;
|
|
50
|
+
meta: AclMeta;
|
|
51
|
+
error?: Error;
|
|
52
|
+
version: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ResourceAcl =
|
|
56
|
+
| false
|
|
57
|
+
| {
|
|
58
|
+
type: "authenticated";
|
|
59
|
+
}
|
|
60
|
+
| {
|
|
61
|
+
type: "collection";
|
|
62
|
+
resource?: string;
|
|
63
|
+
dataSourceKey?: string;
|
|
64
|
+
actionMap?: Record<string, string>;
|
|
65
|
+
}
|
|
66
|
+
| {
|
|
67
|
+
type: "snippet";
|
|
68
|
+
name: string;
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
type: "route";
|
|
72
|
+
routeId: string | number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type AclCanParams = {
|
|
76
|
+
resource?: string;
|
|
77
|
+
action: string;
|
|
78
|
+
params?: {
|
|
79
|
+
id?: BaseKey;
|
|
80
|
+
field?: string;
|
|
81
|
+
dataSourceKey?: string;
|
|
82
|
+
meta?: Record<string, unknown>;
|
|
83
|
+
resource?: IResourceItem;
|
|
84
|
+
[key: string]: unknown;
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type AclIdentity = {
|
|
89
|
+
id: number | string;
|
|
90
|
+
firstName: string;
|
|
91
|
+
lastName: string;
|
|
92
|
+
fullName: string;
|
|
93
|
+
email: string;
|
|
94
|
+
avatar?: string;
|
|
95
|
+
roles: Role[];
|
|
96
|
+
};
|