@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,164 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const projectRoot = path.resolve(
6
+ path.dirname(fileURLToPath(import.meta.url)),
7
+ ".."
8
+ );
9
+ const configPath = path.join(projectRoot, "registry.config.json");
10
+ const outputPath = path.join(projectRoot, "registry.json");
11
+ const action = process.argv[2];
12
+
13
+ if (!new Set(["build", "install-missing", "preview"]).has(action)) {
14
+ throw new Error(
15
+ "Usage: node scripts/registry.mjs <build|install-missing|preview>"
16
+ );
17
+ }
18
+
19
+ function toPosix(value) {
20
+ return value.split(path.sep).join("/");
21
+ }
22
+
23
+ function walkFiles(directory, root = directory) {
24
+ return fs
25
+ .readdirSync(directory, { withFileTypes: true })
26
+ .flatMap((entry) => {
27
+ const entryPath = path.join(directory, entry.name);
28
+ return entry.isDirectory()
29
+ ? walkFiles(entryPath, root)
30
+ : [toPosix(path.relative(root, entryPath))];
31
+ })
32
+ .sort((left, right) => left.localeCompare(right));
33
+ }
34
+
35
+ function isIncluded(file, include) {
36
+ return include.some((entry) => {
37
+ const normalized = entry.replace(/^\.\//, "").replace(/\/$/, "");
38
+ return (
39
+ normalized === "." ||
40
+ file === normalized ||
41
+ file.startsWith(`${normalized}/`)
42
+ );
43
+ });
44
+ }
45
+
46
+ function assertSafePath(value, prefix, label) {
47
+ if (!value.startsWith(prefix) || value.includes("..")) {
48
+ throw new Error(`Unsafe ${label} path: ${value}`);
49
+ }
50
+ }
51
+
52
+ const sourceConfig = JSON.parse(fs.readFileSync(configPath, "utf8"));
53
+ const sourceFiles = new Map();
54
+ const sourceMappings = new Map();
55
+ const itemNames = new Set();
56
+
57
+ const items = sourceConfig.items.map((item) => {
58
+ if (!item.name || itemNames.has(item.name)) {
59
+ throw new Error(`Registry item name must be unique: ${item.name}`);
60
+ }
61
+ itemNames.add(item.name);
62
+ const source = item.source;
63
+ if (!source) {
64
+ throw new Error(`Registry item ${item.name} is missing its source mapping`);
65
+ }
66
+
67
+ assertSafePath(source.root, "registry/", "source");
68
+ assertSafePath(source.target, "src/extensions/", "target");
69
+
70
+ const sourceRoot = path.join(projectRoot, source.root);
71
+ if (!fs.existsSync(sourceRoot)) {
72
+ throw new Error(`Registry source does not exist: ${source.root}`);
73
+ }
74
+
75
+ if (!Array.isArray(source.include) || !source.include.length) {
76
+ throw new Error(`Registry item ${item.name} must include at least one path`);
77
+ }
78
+
79
+ const mappingKey = `${source.root}\0${source.target}`;
80
+ const mapping = sourceMappings.get(mappingKey) ?? {
81
+ root: source.root,
82
+ target: source.target,
83
+ include: new Set(),
84
+ install: source.install !== false,
85
+ };
86
+ source.include.forEach((entry) => mapping.include.add(entry));
87
+ mapping.install = mapping.install || source.install !== false;
88
+ sourceMappings.set(mappingKey, mapping);
89
+
90
+ const allFiles = sourceFiles.get(source.root) ?? walkFiles(sourceRoot);
91
+ sourceFiles.set(source.root, allFiles);
92
+ const includedFiles = allFiles.filter((file) =>
93
+ isIncluded(file, source.include)
94
+ );
95
+ if (!includedFiles.length) {
96
+ throw new Error(
97
+ `Registry item ${item.name} include paths did not match any files`
98
+ );
99
+ }
100
+
101
+ if (action !== "build") return item;
102
+ const { source: _source, ...registryItem } = item;
103
+
104
+ return {
105
+ ...registryItem,
106
+ files: includedFiles.map((file) => ({
107
+ path: path.posix.join(source.root, file),
108
+ type: "registry:file",
109
+ target: path.posix.join(source.target, file),
110
+ })),
111
+ };
112
+ });
113
+
114
+ function copySource(source, overwrite) {
115
+ const sourcePath = path.join(projectRoot, source.root);
116
+ const targetPath = path.join(projectRoot, source.target);
117
+
118
+ if (fs.existsSync(targetPath) && !overwrite) {
119
+ console.log(`${source.target}: already installed, preserved`);
120
+ return;
121
+ }
122
+
123
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
124
+ const temporaryRoot = fs.mkdtempSync(
125
+ path.join(path.dirname(targetPath), `.${path.basename(targetPath)}-install-`)
126
+ );
127
+ const temporaryTarget = path.join(temporaryRoot, path.basename(targetPath));
128
+
129
+ try {
130
+ const includedFiles = walkFiles(sourcePath).filter((file) =>
131
+ isIncluded(file, [...source.include])
132
+ );
133
+ for (const file of includedFiles) {
134
+ const sourceFile = path.join(sourcePath, file);
135
+ const targetFile = path.join(temporaryTarget, file);
136
+ fs.mkdirSync(path.dirname(targetFile), { recursive: true });
137
+ fs.copyFileSync(sourceFile, targetFile);
138
+ }
139
+ if (overwrite) fs.rmSync(targetPath, { recursive: true, force: true });
140
+ fs.renameSync(temporaryTarget, targetPath);
141
+ console.log(
142
+ `${source.target}: ${overwrite ? "preview refreshed" : "installed"}`
143
+ );
144
+ } finally {
145
+ fs.rmSync(temporaryRoot, { recursive: true, force: true });
146
+ }
147
+ }
148
+
149
+ if (action === "build") {
150
+ const registry = {
151
+ $schema: "https://ui.shadcn.com/schema/registry.json",
152
+ ...sourceConfig,
153
+ items,
154
+ };
155
+ fs.writeFileSync(outputPath, `${JSON.stringify(registry, null, 2)}\n`);
156
+ for (const item of items) {
157
+ console.log(`${item.name}: ${item.files.length} files`);
158
+ }
159
+ } else {
160
+ for (const source of sourceMappings.values()) {
161
+ if (action === "install-missing" && !source.install) continue;
162
+ copySource(source, action === "preview");
163
+ }
164
+ }
package/src/App.css CHANGED
@@ -155,4 +155,99 @@
155
155
  .resource-detail-card {
156
156
  @apply shadow-[0_1px_2px_rgba(0,0,0,0.04)];
157
157
  }
158
+
159
+ .ai-chat-window {
160
+ container-name: ai-chat;
161
+ container-type: inline-size;
162
+ }
163
+
164
+ .ai-chat-conversation-panel {
165
+ position: absolute;
166
+ inset-block: 0;
167
+ inset-inline-start: 0;
168
+ }
169
+
170
+ .ai-markdown > *:first-child {
171
+ margin-top: 0;
172
+ }
173
+
174
+ .ai-markdown > *:last-child {
175
+ margin-bottom: 0;
176
+ }
177
+
178
+ .ai-markdown p,
179
+ .ai-markdown ul,
180
+ .ai-markdown ol,
181
+ .ai-markdown pre,
182
+ .ai-markdown blockquote {
183
+ margin-block: 0.65rem;
184
+ }
185
+
186
+ .ai-markdown ul,
187
+ .ai-markdown ol {
188
+ padding-inline-start: 1.25rem;
189
+ }
190
+
191
+ .ai-markdown ul {
192
+ list-style: disc;
193
+ }
194
+
195
+ .ai-markdown ol {
196
+ list-style: decimal;
197
+ }
198
+
199
+ .ai-markdown strong {
200
+ font-weight: 600;
201
+ }
202
+
203
+ .ai-markdown a {
204
+ text-decoration: underline;
205
+ text-underline-offset: 3px;
206
+ }
207
+
208
+ .ai-markdown code {
209
+ border-radius: 0.3rem;
210
+ background: var(--muted);
211
+ padding: 0.1rem 0.3rem;
212
+ font-family: var(--font-mono);
213
+ font-size: 0.875em;
214
+ }
215
+
216
+ .ai-markdown pre {
217
+ overflow-x: auto;
218
+ border-radius: 0.65rem;
219
+ background: var(--muted);
220
+ padding: 0.75rem;
221
+ }
222
+
223
+ .ai-markdown pre code {
224
+ background: transparent;
225
+ padding: 0;
226
+ }
227
+ }
228
+
229
+ @container ai-chat (min-width: 760px) {
230
+ .ai-chat-conversation-panel {
231
+ position: relative;
232
+ }
233
+
234
+ .ai-chat-conversation-backdrop {
235
+ display: none;
236
+ }
237
+ }
238
+
239
+ .chat-side-panel-layout {
240
+ transition:
241
+ padding-inline-start 200ms ease,
242
+ padding-inline-end 200ms ease;
243
+ }
244
+
245
+ @media (min-width: 768px) {
246
+ .chat-side-panel-layout[data-open="true"][data-side="right"] {
247
+ padding-inline-end: var(--chat-side-panel-width);
248
+ }
249
+
250
+ .chat-side-panel-layout[data-open="true"][data-side="left"] {
251
+ padding-inline-start: var(--chat-side-panel-width);
252
+ }
158
253
  }
package/src/App.tsx CHANGED
@@ -1,26 +1,11 @@
1
- import {
2
- Refine,
3
- Authenticated,
4
- } from "@refinedev/core";
1
+ import { Refine, Authenticated, type ResourceProps } from "@refinedev/core";
5
2
 
6
3
  import { BrowserRouter, Route, Routes, Outlet } from "react-router";
7
4
  import routerProvider, {
8
- NavigateToResource,
9
5
  CatchAllNavigate,
10
6
  UnsavedChangesNotifier,
11
7
  } from "@refinedev/react-router";
12
- import {
13
- BlogPostList,
14
- BlogPostCreate,
15
- BlogPostEdit,
16
- BlogPostShow,
17
- } from "./pages/blog-posts";
18
- import {
19
- CategoryList,
20
- CategoryCreate,
21
- CategoryEdit,
22
- CategoryShow,
23
- } from "./pages/categories";
8
+ import { UserCreate, UserEdit, UserList, UserShow } from "./pages/users";
24
9
  import { dataProvider } from "./providers/data";
25
10
  import { Login } from "./pages/login";
26
11
  import { Register } from "./pages/register";
@@ -33,9 +18,45 @@ import { Toaster } from "./components/notifications/toaster";
33
18
  import { ThemeProvider } from "./components/theme/theme-provider";
34
19
  import { TooltipProvider } from "./components/ui/tooltip";
35
20
  import { BrandLogo } from "./components/app-shell/brand";
21
+ import {
22
+ AppExtensionProviders,
23
+ extensionI18nProvider,
24
+ extensionResources,
25
+ extensionRouteElements,
26
+ } from "./app/extensions";
36
27
  import "./App.css";
37
28
  import { authProvider } from "./providers/auth";
38
- import { FileText, Tags } from "lucide-react";
29
+ import { accessControlProvider } from "./providers/access-control";
30
+ import { AclBootstrap } from "./components/access-control/acl-bootstrap";
31
+ import { ResourceAccessGuard } from "./components/access-control/resource-access-guard";
32
+ import { NavigateToAccessibleResource } from "./components/access-control/navigate-to-accessible-resource";
33
+ import { UsersRound } from "lucide-react";
34
+
35
+ const coreResources: ResourceProps[] = [
36
+ {
37
+ name: "users",
38
+ list: "/users",
39
+ create: "/users/create",
40
+ edit: "/users/edit/:id",
41
+ show: "/users/show/:id",
42
+ meta: {
43
+ label: "Users",
44
+ singularLabel: "User",
45
+ i18nKey: "resources.users",
46
+ i18nSingularKey: "resources.user",
47
+ i18nOptions: { ns: "app" },
48
+ descriptionI18nKey: "resources.users.description",
49
+ icon: <UsersRound />,
50
+ description:
51
+ "Manage the people who can sign in and work in this NocoBase application.",
52
+ canCreate: true,
53
+ canDelete: true,
54
+ acl: {
55
+ type: "collection",
56
+ },
57
+ },
58
+ },
59
+ ];
39
60
 
40
61
  const basename = import.meta.env.BASE_URL.replace(/\/+$/, "");
41
62
 
@@ -49,38 +70,9 @@ function App() {
49
70
  notificationProvider={useNotificationProvider()}
50
71
  routerProvider={routerProvider}
51
72
  authProvider={authProvider}
52
- resources={[
53
- {
54
- name: "blog_posts",
55
- list: "/blog-posts",
56
- create: "/blog-posts/create",
57
- edit: "/blog-posts/edit/:id",
58
- show: "/blog-posts/show/:id",
59
- meta: {
60
- label: "Blog posts",
61
- icon: <FileText />,
62
- description:
63
- "Create and publish content on a reliable NocoBase data foundation.",
64
- canCreate: true,
65
- canDelete: true,
66
- },
67
- },
68
- {
69
- name: "categories",
70
- list: "/categories",
71
- create: "/categories/create",
72
- edit: "/categories/edit/:id",
73
- show: "/categories/show/:id",
74
- meta: {
75
- label: "Categories",
76
- icon: <Tags />,
77
- description:
78
- "Organize reusable structures while NocoBase keeps the underlying data consistent.",
79
- canCreate: true,
80
- canDelete: true,
81
- },
82
- },
83
- ]}
73
+ accessControlProvider={accessControlProvider}
74
+ i18nProvider={extensionI18nProvider}
75
+ resources={[...coreResources, ...extensionResources]}
84
76
  options={{
85
77
  syncWithLocation: true,
86
78
  warnWhenUnsavedChanges: true,
@@ -98,28 +90,52 @@ function App() {
98
90
  key="authenticated-inner"
99
91
  fallback={<CatchAllNavigate to="/login" />}
100
92
  >
101
- <Layout>
102
- <Outlet />
103
- </Layout>
93
+ <AclBootstrap>
94
+ <AppExtensionProviders>
95
+ <Layout>
96
+ <Outlet />
97
+ </Layout>
98
+ </AppExtensionProviders>
99
+ </AclBootstrap>
104
100
  </Authenticated>
105
101
  }
106
102
  >
107
- <Route
108
- index
109
- element={<NavigateToResource resource="blog_posts" />}
110
- />
111
- <Route path="/blog-posts">
112
- <Route index element={<BlogPostList />} />
113
- <Route path="create" element={<BlogPostCreate />} />
114
- <Route path="edit/:id" element={<BlogPostEdit />} />
115
- <Route path="show/:id" element={<BlogPostShow />} />
116
- </Route>
117
- <Route path="/categories">
118
- <Route index element={<CategoryList />} />
119
- <Route path="create" element={<CategoryCreate />} />
120
- <Route path="edit/:id" element={<CategoryEdit />} />
121
- <Route path="show/:id" element={<CategoryShow />} />
103
+ <Route index element={<NavigateToAccessibleResource />} />
104
+ <Route path="/users">
105
+ <Route
106
+ index
107
+ element={
108
+ <ResourceAccessGuard resource="users" action="list">
109
+ <UserList />
110
+ </ResourceAccessGuard>
111
+ }
112
+ />
113
+ <Route
114
+ path="create"
115
+ element={
116
+ <ResourceAccessGuard resource="users" action="create">
117
+ <UserCreate />
118
+ </ResourceAccessGuard>
119
+ }
120
+ />
121
+ <Route
122
+ path="edit/:id"
123
+ element={
124
+ <ResourceAccessGuard resource="users" action="edit">
125
+ <UserEdit />
126
+ </ResourceAccessGuard>
127
+ }
128
+ />
129
+ <Route
130
+ path="show/:id"
131
+ element={
132
+ <ResourceAccessGuard resource="users" action="show">
133
+ <UserShow />
134
+ </ResourceAccessGuard>
135
+ }
136
+ />
122
137
  </Route>
138
+ {extensionRouteElements}
123
139
  <Route path="*" element={<ErrorComponent />} />
124
140
  </Route>
125
141
  <Route
@@ -128,7 +144,9 @@ function App() {
128
144
  key="authenticated-outer"
129
145
  fallback={<Outlet />}
130
146
  >
131
- <NavigateToResource />
147
+ <AclBootstrap>
148
+ <NavigateToAccessibleResource />
149
+ </AclBootstrap>
132
150
  </Authenticated>
133
151
  }
134
152
  >
@@ -0,0 +1,11 @@
1
+ import type { I18nProvider, ResourceProps } from "@refinedev/core";
2
+ import type { ComponentType, PropsWithChildren, ReactElement } from "react";
3
+
4
+ export type AppExtension = {
5
+ id: string;
6
+ resources?: ResourceProps[];
7
+ routes?: ReactElement;
8
+ Provider?: ComponentType<PropsWithChildren>;
9
+ UserMenuItems?: ComponentType;
10
+ i18nProvider?: I18nProvider;
11
+ };
@@ -0,0 +1,49 @@
1
+ import type { PropsWithChildren, ReactNode } from "react";
2
+ import type { AppExtension } from "./extension";
3
+
4
+ const extensionModules = import.meta.glob<{ default: AppExtension }>(
5
+ "../extensions/*/extension.tsx",
6
+ { eager: true }
7
+ );
8
+
9
+ export const appExtensions = Object.values(extensionModules)
10
+ .map((module) => module.default)
11
+ .sort((left, right) => left.id.localeCompare(right.id));
12
+
13
+ export const extensionResources = appExtensions.flatMap(
14
+ (extension) => extension.resources ?? []
15
+ );
16
+
17
+ export const extensionRouteElements = appExtensions
18
+ .map((extension) => extension.routes)
19
+ .filter((routes): routes is NonNullable<typeof routes> => Boolean(routes));
20
+
21
+ const i18nExtensions = appExtensions.filter(
22
+ (extension) => extension.i18nProvider
23
+ );
24
+
25
+ if (i18nExtensions.length > 1) {
26
+ console.warn(
27
+ `Multiple app extensions provide i18nProvider (${i18nExtensions
28
+ .map((extension) => extension.id)
29
+ .join(", ")}). Using ${i18nExtensions[0].id}.`
30
+ );
31
+ }
32
+
33
+ export const extensionI18nProvider = i18nExtensions[0]?.i18nProvider;
34
+
35
+ export const extensionUserMenuItems = appExtensions
36
+ .filter((extension) => extension.UserMenuItems)
37
+ .map((extension) => ({
38
+ id: extension.id,
39
+ Component: extension.UserMenuItems!,
40
+ }));
41
+
42
+ export function AppExtensionProviders({ children }: PropsWithChildren) {
43
+ return appExtensions.reduceRight<ReactNode>((content, extension) => {
44
+ const Provider = extension.Provider;
45
+ return Provider ? <Provider>{content}</Provider> : content;
46
+ }, children);
47
+ }
48
+
49
+ export type { AppExtension } from "./extension";
@@ -0,0 +1,30 @@
1
+ import { ShieldX } from "lucide-react";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ export function AccessDenied({
6
+ className,
7
+ title = "Access denied",
8
+ description = "You don't have permission to view this content.",
9
+ }: {
10
+ className?: string;
11
+ title?: string;
12
+ description?: string;
13
+ }) {
14
+ return (
15
+ <div
16
+ className={cn(
17
+ "flex min-h-64 flex-col items-center justify-center rounded-2xl border border-dashed bg-background/70 p-8 text-center",
18
+ className
19
+ )}
20
+ >
21
+ <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted">
22
+ <ShieldX className="size-6 text-muted-foreground" />
23
+ </div>
24
+ <h2 className="text-lg font-semibold">{title}</h2>
25
+ <p className="mt-2 max-w-md text-sm leading-6 text-muted-foreground">
26
+ {description}
27
+ </p>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,9 @@
1
+ import type { PropsWithChildren } from "react";
2
+
3
+ import { AclGate } from "./acl-gate";
4
+ import { useAclRuntime } from "./use-acl-runtime";
5
+
6
+ export function AclBootstrap({ children }: PropsWithChildren) {
7
+ useAclRuntime();
8
+ return <AclGate>{children}</AclGate>;
9
+ }
@@ -0,0 +1,36 @@
1
+ import { RotateCcw } from "lucide-react";
2
+ import type { PropsWithChildren } from "react";
3
+
4
+ import { Button } from "@/components/ui/button";
5
+ import { LoadingState } from "@/components/app-shell/loading-state";
6
+ import { loadAcl, useAclSnapshot } from "@/lib/nocobase/acl";
7
+
8
+ export function AclGate({ children }: PropsWithChildren) {
9
+ const snapshot = useAclSnapshot();
10
+
11
+ if (snapshot.status === "ready") return children;
12
+
13
+ if (snapshot.status === "error") {
14
+ return (
15
+ <div className="flex min-h-screen items-center justify-center bg-background p-6">
16
+ <div className="max-w-md text-center">
17
+ <h1 className="text-xl font-semibold">Unable to load permissions</h1>
18
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
19
+ {snapshot.error?.message ??
20
+ "The current role permissions could not be loaded."}
21
+ </p>
22
+ <Button
23
+ className="mt-5"
24
+ variant="outline"
25
+ onClick={() => void loadAcl({ force: true }).catch(() => undefined)}
26
+ >
27
+ <RotateCcw />
28
+ Retry
29
+ </Button>
30
+ </div>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return <LoadingState className="min-h-screen bg-background" />;
36
+ }
@@ -0,0 +1,17 @@
1
+ import { useMenu } from "@refinedev/core";
2
+ import { Navigate } from "react-router";
3
+
4
+ import {
5
+ filterMenuItemsByAcl,
6
+ findFirstAccessibleRoute,
7
+ useAclSnapshot,
8
+ } from "@/lib/nocobase/acl";
9
+ import { AccessDenied } from "./access-denied";
10
+
11
+ export function NavigateToAccessibleResource() {
12
+ const { menuItems } = useMenu();
13
+ const acl = useAclSnapshot();
14
+ const route = findFirstAccessibleRoute(filterMenuItemsByAcl(menuItems, acl));
15
+
16
+ return route ? <Navigate to={route} replace /> : <AccessDenied />;
17
+ }
@@ -0,0 +1,21 @@
1
+ import { CanAccess } from "@refinedev/core";
2
+ import type { PropsWithChildren, ReactNode } from "react";
3
+
4
+ import { AccessDenied } from "./access-denied";
5
+
6
+ export function ResourceAccessGuard({
7
+ resource,
8
+ action,
9
+ children,
10
+ fallback = <AccessDenied />,
11
+ }: PropsWithChildren<{
12
+ resource: string;
13
+ action: string;
14
+ fallback?: ReactNode;
15
+ }>) {
16
+ return (
17
+ <CanAccess resource={resource} action={action} fallback={fallback}>
18
+ {children}
19
+ </CanAccess>
20
+ );
21
+ }
@@ -0,0 +1,28 @@
1
+ import { useQueryClient } from "@tanstack/react-query";
2
+ import { useEffect, useRef } from "react";
3
+
4
+ import { loadAcl, useAclSnapshot } from "@/lib/nocobase/acl";
5
+
6
+ export function useAclRuntime() {
7
+ const snapshot = useAclSnapshot();
8
+ const queryClient = useQueryClient();
9
+ const lastReadyVersion = useRef<number | undefined>(undefined);
10
+
11
+ useEffect(() => {
12
+ if (snapshot.status === "idle") {
13
+ void loadAcl().catch(() => undefined);
14
+ }
15
+ }, [snapshot.status]);
16
+
17
+ useEffect(() => {
18
+ if (snapshot.status !== "ready") return;
19
+ if (lastReadyVersion.current === undefined) {
20
+ lastReadyVersion.current = snapshot.version;
21
+ return;
22
+ }
23
+ if (lastReadyVersion.current === snapshot.version) return;
24
+
25
+ lastReadyVersion.current = snapshot.version;
26
+ void queryClient.invalidateQueries({ queryKey: ["access"] });
27
+ }, [queryClient, snapshot.status, snapshot.version]);
28
+ }