@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.
Files changed (194) hide show
  1. package/README.MD +90 -1
  2. package/components.json +3 -1
  3. package/package.json +21 -5
  4. package/registry/nocobase-acl/README.md +19 -0
  5. package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
  6. package/registry/nocobase-acl/components/index.ts +3 -0
  7. package/registry/nocobase-acl/components/role-options.ts +36 -0
  8. package/registry/nocobase-acl/components/role-switcher.tsx +135 -0
  9. package/registry/nocobase-acl/demo/boundary-api.tsx +186 -0
  10. package/registry/nocobase-acl/demo/components.tsx +231 -0
  11. package/registry/nocobase-acl/demo/index.tsx +505 -0
  12. package/registry/nocobase-acl/demo/lazy-route.tsx +10 -0
  13. package/registry/nocobase-acl/demo/prompt-generator.tsx +134 -0
  14. package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +198 -0
  15. package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
  16. package/registry/nocobase-acl/extension.tsx +71 -0
  17. package/registry/nocobase-acl/index.ts +1 -0
  18. package/registry/nocobase-acl/tests/acl-regression.mjs +314 -0
  19. package/registry/nocobase-ai/README.md +70 -0
  20. package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
  21. package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
  22. package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
  23. package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
  24. package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
  25. package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
  26. package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
  27. package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
  28. package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
  29. package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
  30. package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
  31. package/registry/nocobase-ai/components/chat/chat-messages.tsx +173 -0
  32. package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
  33. package/registry/nocobase-ai/components/chat/conversation-list.tsx +343 -0
  34. package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
  35. package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
  36. package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
  37. package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
  38. package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
  39. package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
  40. package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
  41. package/registry/nocobase-ai/components/index.ts +59 -0
  42. package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
  43. package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
  44. package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
  45. package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
  46. package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
  47. package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
  48. package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
  49. package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
  50. package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
  51. package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +364 -0
  52. package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
  53. package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
  54. package/registry/nocobase-ai/components/tools/chart-renderer.tsx +99 -0
  55. package/registry/nocobase-ai/components/tools/echarts-preview.tsx +59 -0
  56. package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
  57. package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
  58. package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
  59. package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
  60. package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
  61. package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
  62. package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
  63. package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
  64. package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
  65. package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
  66. package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
  67. package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
  68. package/registry/nocobase-ai/demo/configuration-gate.tsx +49 -0
  69. package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
  70. package/registry/nocobase-ai/demo/floating.tsx +134 -0
  71. package/registry/nocobase-ai/demo/index.tsx +346 -0
  72. package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
  73. package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
  74. package/registry/nocobase-ai/demo/page-context.tsx +884 -0
  75. package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
  76. package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
  77. package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
  78. package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
  79. package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
  80. package/registry/nocobase-ai/extension.tsx +158 -0
  81. package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
  82. package/registry/nocobase-ai/index.ts +3 -0
  83. package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
  84. package/registry/nocobase-ai/providers/avatars.ts +817 -0
  85. package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
  86. package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
  87. package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
  88. package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
  89. package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
  90. package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
  91. package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
  92. package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
  93. package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
  94. package/registry/nocobase-ai/providers/index.ts +61 -0
  95. package/registry/nocobase-ai/providers/model.ts +32 -0
  96. package/registry/nocobase-ai/providers/page-context.tsx +136 -0
  97. package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
  98. package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
  99. package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
  100. package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
  101. package/registry/nocobase-ai/providers/types.ts +183 -0
  102. package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
  103. package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
  104. package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
  105. package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
  106. package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
  107. package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
  108. package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
  109. package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
  110. package/registry/nocobase-ai/services/index.ts +7 -0
  111. package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
  112. package/registry/nocobase-ai/services/types.ts +82 -0
  113. package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
  114. package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
  115. package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
  116. package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
  117. package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
  118. package/registry/nocobase-i18n/README.md +53 -0
  119. package/registry/nocobase-i18n/components/index.ts +1 -0
  120. package/registry/nocobase-i18n/components/language-switcher.tsx +138 -0
  121. package/registry/nocobase-i18n/demo/index.tsx +209 -0
  122. package/registry/nocobase-i18n/demo/prompt-generator.tsx +155 -0
  123. package/registry/nocobase-i18n/extension.tsx +46 -0
  124. package/registry/nocobase-i18n/i18n-provider.ts +9 -0
  125. package/registry/nocobase-i18n/index.ts +6 -0
  126. package/registry/nocobase-i18n/locale-store.ts +134 -0
  127. package/registry/nocobase-i18n/locales/en-US.ts +89 -0
  128. package/registry/nocobase-i18n/locales/zh-CN.ts +85 -0
  129. package/registry/nocobase-i18n/provider.tsx +32 -0
  130. package/registry/nocobase-i18n/runtime.ts +118 -0
  131. package/registry/nocobase-i18n/server-resources.ts +184 -0
  132. package/registry/nocobase-i18n/tests/i18n-regression.mjs +42 -0
  133. package/registry.config.json +163 -0
  134. package/scripts/registry.mjs +164 -0
  135. package/src/App.css +95 -0
  136. package/src/App.tsx +87 -69
  137. package/src/app/extension.ts +11 -0
  138. package/src/app/extensions.tsx +49 -0
  139. package/src/components/access-control/access-denied.tsx +30 -0
  140. package/src/components/access-control/acl-bootstrap.tsx +9 -0
  141. package/src/components/access-control/acl-gate.tsx +36 -0
  142. package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
  143. package/src/components/access-control/resource-access-guard.tsx +21 -0
  144. package/src/components/access-control/use-acl-runtime.ts +28 -0
  145. package/src/components/app-shell/breadcrumb.tsx +39 -3
  146. package/src/components/app-shell/document-title-handler.tsx +11 -20
  147. package/src/components/app-shell/header.tsx +22 -4
  148. package/src/components/app-shell/loading-overlay.tsx +2 -4
  149. package/src/components/app-shell/loading-state.tsx +15 -0
  150. package/src/components/app-shell/sidebar.tsx +103 -45
  151. package/src/components/data-table/data-table-pagination.tsx +27 -7
  152. package/src/components/data-table/data-table-styles.ts +57 -0
  153. package/src/components/data-table/data-table.tsx +14 -59
  154. package/src/components/resources/resource-label.ts +101 -0
  155. package/src/components/resources/views/create-view.tsx +17 -14
  156. package/src/components/resources/views/edit-view.tsx +17 -15
  157. package/src/components/resources/views/list-view.tsx +20 -10
  158. package/src/components/resources/views/show-view.tsx +20 -18
  159. package/src/index.tsx +2 -3
  160. package/src/lib/i18n.ts +161 -0
  161. package/src/lib/nocobase/acl/action.ts +147 -0
  162. package/src/lib/nocobase/acl/data-source.ts +24 -0
  163. package/src/lib/nocobase/acl/index.ts +6 -0
  164. package/src/lib/nocobase/acl/menu.ts +41 -0
  165. package/src/lib/nocobase/acl/record-permissions.ts +111 -0
  166. package/src/lib/nocobase/acl/store.ts +182 -0
  167. package/src/lib/nocobase/acl/types.ts +96 -0
  168. package/src/lib/nocobase/client.ts +279 -0
  169. package/src/lib/nocobase/error.ts +32 -0
  170. package/src/locales/en-US.ts +82 -0
  171. package/src/locales/index.ts +13 -0
  172. package/src/locales/zh-CN.ts +79 -0
  173. package/src/pages/users/create.tsx +98 -0
  174. package/src/pages/users/edit.tsx +92 -0
  175. package/src/pages/users/form-context.ts +57 -0
  176. package/src/pages/users/form-fields.tsx +211 -0
  177. package/src/pages/users/list.tsx +205 -0
  178. package/src/pages/users/show.tsx +187 -0
  179. package/src/pages/users/types.ts +20 -0
  180. package/src/providers/access-control.ts +34 -0
  181. package/src/providers/auth.ts +74 -60
  182. package/src/providers/constants.ts +3 -37
  183. package/src/providers/data.ts +242 -85
  184. package/vite.config.ts +17 -0
  185. package/src/pages/blog-posts/create.tsx +0 -177
  186. package/src/pages/blog-posts/edit.tsx +0 -197
  187. package/src/pages/blog-posts/list.tsx +0 -131
  188. package/src/pages/blog-posts/show.tsx +0 -81
  189. package/src/pages/categories/create.tsx +0 -73
  190. package/src/pages/categories/edit.tsx +0 -73
  191. package/src/pages/categories/index.ts +0 -4
  192. package/src/pages/categories/list.tsx +0 -86
  193. package/src/pages/categories/show.tsx +0 -37
  194. /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,6 @@
1
+ export * from "./action";
2
+ export * from "./data-source";
3
+ export * from "./menu";
4
+ export * from "./record-permissions";
5
+ export * from "./store";
6
+ export * from "./types";
@@ -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
+ };