@nocobase/portal-template-default 1.0.0 → 1.0.1

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 (145) hide show
  1. package/README.MD +70 -0
  2. package/components.json +3 -1
  3. package/package.json +19 -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 +29 -0
  8. package/registry/nocobase-acl/components/role-switcher.tsx +130 -0
  9. package/registry/nocobase-acl/demo/boundary-api.tsx +115 -0
  10. package/registry/nocobase-acl/demo/components.tsx +210 -0
  11. package/registry/nocobase-acl/demo/index.tsx +474 -0
  12. package/registry/nocobase-acl/demo/lazy-route.tsx +15 -0
  13. package/registry/nocobase-acl/demo/prompt-generator.tsx +115 -0
  14. package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +179 -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 +313 -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 +174 -0
  32. package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
  33. package/registry/nocobase-ai/components/chat/conversation-list.tsx +345 -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 +367 -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 +110 -0
  55. package/registry/nocobase-ai/components/tools/echarts-preview.tsx +62 -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 +52 -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 +163 -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.config.json +143 -0
  119. package/scripts/registry.mjs +161 -0
  120. package/src/App.css +95 -0
  121. package/src/App.tsx +127 -50
  122. package/src/app/extension.ts +9 -0
  123. package/src/app/extensions.tsx +28 -0
  124. package/src/components/access-control/access-denied.tsx +30 -0
  125. package/src/components/access-control/acl-bootstrap.tsx +9 -0
  126. package/src/components/access-control/acl-gate.tsx +42 -0
  127. package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
  128. package/src/components/access-control/resource-access-guard.tsx +21 -0
  129. package/src/components/access-control/use-acl-runtime.ts +28 -0
  130. package/src/components/app-shell/header.tsx +4 -1
  131. package/src/components/app-shell/sidebar.tsx +34 -4
  132. package/src/lib/nocobase/acl/action.ts +147 -0
  133. package/src/lib/nocobase/acl/data-source.ts +24 -0
  134. package/src/lib/nocobase/acl/index.ts +6 -0
  135. package/src/lib/nocobase/acl/menu.ts +41 -0
  136. package/src/lib/nocobase/acl/record-permissions.ts +111 -0
  137. package/src/lib/nocobase/acl/store.ts +182 -0
  138. package/src/lib/nocobase/acl/types.ts +96 -0
  139. package/src/lib/nocobase/client.ts +262 -0
  140. package/src/lib/nocobase/error.ts +32 -0
  141. package/src/providers/access-control.ts +34 -0
  142. package/src/providers/auth.ts +74 -60
  143. package/src/providers/constants.ts +2 -37
  144. package/src/providers/data.ts +242 -85
  145. package/vite.config.ts +17 -0
@@ -29,10 +29,19 @@ import { Button } from "@/components/ui/button";
29
29
  import { ChevronRight, ListIcon, ShieldCheck } from "lucide-react";
30
30
  import { cn } from "@/lib/utils";
31
31
  import { Brand } from "@/components/app-shell/brand";
32
+ import {
33
+ filterMenuItemsByAcl,
34
+ useAclSnapshot,
35
+ } from "@/lib/nocobase/acl";
32
36
 
33
37
  export function Sidebar() {
34
38
  const { open } = useShadcnSidebar();
35
39
  const { menuItems, selectedKey } = useMenu();
40
+ const acl = useAclSnapshot();
41
+ const allowedMenuItems = React.useMemo(
42
+ () => filterMenuItemsByAcl(menuItems, acl),
43
+ [acl, menuItems]
44
+ );
36
45
 
37
46
  return (
38
47
  <ShadcnSidebar
@@ -55,7 +64,7 @@ export function Sidebar() {
55
64
  }
56
65
  )}
57
66
  >
58
- {menuItems.map((item: TreeMenuItem) => (
67
+ {allowedMenuItems.map((item: TreeMenuItem) => (
59
68
  <SidebarItem
60
69
  key={item.key || item.name}
61
70
  item={item}
@@ -135,6 +144,7 @@ function SidebarItemGroup({ item, selectedKey }: MenuItemProps) {
135
144
 
136
145
  function SidebarItemCollapsible({ item, selectedKey }: MenuItemProps) {
137
146
  const { name, children } = item;
147
+ const isSelected = isTreeItemSelected(item, selectedKey);
138
148
 
139
149
  const chevronIcon = (
140
150
  <ChevronRight
@@ -151,9 +161,19 @@ function SidebarItemCollapsible({ item, selectedKey }: MenuItemProps) {
151
161
  );
152
162
 
153
163
  return (
154
- <Collapsible key={`collapsible-${name}`} className={cn("w-full", "group")}>
164
+ <Collapsible
165
+ key={`collapsible-${name}`}
166
+ defaultOpen={isSelected}
167
+ className={cn("w-full", "group")}
168
+ >
155
169
  <CollapsibleTrigger
156
- render={<SidebarButton item={item} rightIcon={chevronIcon} />}
170
+ render={
171
+ <SidebarButton
172
+ item={item}
173
+ rightIcon={chevronIcon}
174
+ isSelected={isSelected}
175
+ />
176
+ }
157
177
  />
158
178
  <CollapsibleContent className={cn("ml-6", "flex", "flex-col", "gap-2")}>
159
179
  {children?.map((child: TreeMenuItem) => (
@@ -171,10 +191,13 @@ function SidebarItemCollapsible({ item, selectedKey }: MenuItemProps) {
171
191
  function SidebarItemDropdown({ item, selectedKey }: MenuItemProps) {
172
192
  const { children } = item;
173
193
  const Link = useLink();
194
+ const isSelected = isTreeItemSelected(item, selectedKey);
174
195
 
175
196
  return (
176
197
  <DropdownMenu>
177
- <DropdownMenuTrigger render={<SidebarButton item={item} />} />
198
+ <DropdownMenuTrigger
199
+ render={<SidebarButton item={item} isSelected={isSelected} />}
200
+ />
178
201
  <DropdownMenuContent side="right" align="start">
179
202
  {children?.map((child: TreeMenuItem) => {
180
203
  const { key: childKey } = child;
@@ -264,6 +287,13 @@ function getDisplayName(item: TreeMenuItem) {
264
287
  return item.meta?.label ?? item.label ?? item.name;
265
288
  }
266
289
 
290
+ function isTreeItemSelected(item: TreeMenuItem, selectedKey?: string) {
291
+ return (
292
+ item.key === selectedKey ||
293
+ Boolean(selectedKey?.startsWith(`${item.key}/`))
294
+ );
295
+ }
296
+
267
297
  type IconProps = {
268
298
  icon: React.ReactNode;
269
299
  isSelected?: boolean;
@@ -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
+ };