@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.
- package/README.MD +70 -0
- package/components.json +3 -1
- package/package.json +19 -5
- package/registry/nocobase-acl/README.md +19 -0
- package/registry/nocobase-acl/components/acl-boundary.tsx +106 -0
- package/registry/nocobase-acl/components/index.ts +3 -0
- package/registry/nocobase-acl/components/role-options.ts +29 -0
- package/registry/nocobase-acl/components/role-switcher.tsx +130 -0
- package/registry/nocobase-acl/demo/boundary-api.tsx +115 -0
- package/registry/nocobase-acl/demo/components.tsx +210 -0
- package/registry/nocobase-acl/demo/index.tsx +474 -0
- package/registry/nocobase-acl/demo/lazy-route.tsx +15 -0
- package/registry/nocobase-acl/demo/prompt-generator.tsx +115 -0
- package/registry/nocobase-acl/demo/role-switcher-prompt-generator.tsx +179 -0
- package/registry/nocobase-acl/demo/scenario-section.tsx +37 -0
- package/registry/nocobase-acl/extension.tsx +71 -0
- package/registry/nocobase-acl/index.ts +1 -0
- package/registry/nocobase-acl/tests/acl-regression.mjs +313 -0
- package/registry/nocobase-ai/README.md +70 -0
- package/registry/nocobase-ai/adapters/react-hook-form.ts +20 -0
- package/registry/nocobase-ai/assets/nocobase-ai-chat.svg +50 -0
- package/registry/nocobase-ai/components/ai-root-provider.tsx +32 -0
- package/registry/nocobase-ai/components/chat/ai-employee-avatar.tsx +31 -0
- package/registry/nocobase-ai/components/chat/chat-attachment.tsx +92 -0
- package/registry/nocobase-ai/components/chat/chat-compact.tsx +90 -0
- package/registry/nocobase-ai/components/chat/chat-composer.tsx +429 -0
- package/registry/nocobase-ai/components/chat/chat-empty-state.tsx +51 -0
- package/registry/nocobase-ai/components/chat/chat-header.tsx +88 -0
- package/registry/nocobase-ai/components/chat/chat-history-dialog.tsx +77 -0
- package/registry/nocobase-ai/components/chat/chat-message.tsx +252 -0
- package/registry/nocobase-ai/components/chat/chat-messages.tsx +174 -0
- package/registry/nocobase-ai/components/chat/chat-window.tsx +118 -0
- package/registry/nocobase-ai/components/chat/conversation-list.tsx +345 -0
- package/registry/nocobase-ai/components/chat/markdown-message.tsx +165 -0
- package/registry/nocobase-ai/components/chat/model-select-options.tsx +30 -0
- package/registry/nocobase-ai/components/chat/reasoning-panel.tsx +29 -0
- package/registry/nocobase-ai/components/chat/sub-agent-conversation.tsx +142 -0
- package/registry/nocobase-ai/components/chat/tool-call-card.tsx +316 -0
- package/registry/nocobase-ai/components/chat/user-prompt-editor.tsx +99 -0
- package/registry/nocobase-ai/components/chat/work-context-chip.tsx +114 -0
- package/registry/nocobase-ai/components/index.ts +59 -0
- package/registry/nocobase-ai/components/page-elements/ai-form.tsx +44 -0
- package/registry/nocobase-ai/components/page-elements/page-element-provider.tsx +469 -0
- package/registry/nocobase-ai/components/surfaces/chat-dialog.tsx +31 -0
- package/registry/nocobase-ai/components/surfaces/chat-inline.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-page.tsx +18 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel-layout.tsx +54 -0
- package/registry/nocobase-ai/components/surfaces/chat-side-panel.tsx +74 -0
- package/registry/nocobase-ai/components/surfaces/chat-surface-actions.tsx +54 -0
- package/registry/nocobase-ai/components/tools/builtin-tool-renderers.tsx +31 -0
- package/registry/nocobase-ai/components/tools/business-report-dialog.tsx +367 -0
- package/registry/nocobase-ai/components/tools/business-report-renderer.tsx +122 -0
- package/registry/nocobase-ai/components/tools/business-report-utils.tsx +282 -0
- package/registry/nocobase-ai/components/tools/chart-renderer.tsx +110 -0
- package/registry/nocobase-ai/components/tools/echarts-preview.tsx +62 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-advanced.ts +18 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-common.ts +20 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-components.ts +34 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime-hierarchy.ts +24 -0
- package/registry/nocobase-ai/components/tools/echarts-runtime.ts +138 -0
- package/registry/nocobase-ai/components/tools/sub-agent-renderer.tsx +67 -0
- package/registry/nocobase-ai/components/tools/suggestions-renderer.tsx +59 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-provider.tsx +55 -0
- package/registry/nocobase-ai/components/tools/tool-renderer-utils.ts +18 -0
- package/registry/nocobase-ai/components/tools/workflow-renderer.tsx +108 -0
- package/registry/nocobase-ai/components/triggers/ai-chat-floating-trigger.tsx +73 -0
- package/registry/nocobase-ai/components/triggers/ai-employee-shortcut.tsx +149 -0
- package/registry/nocobase-ai/demo/configuration-gate.tsx +52 -0
- package/registry/nocobase-ai/demo/container-showcase.tsx +210 -0
- package/registry/nocobase-ai/demo/floating.tsx +134 -0
- package/registry/nocobase-ai/demo/index.tsx +346 -0
- package/registry/nocobase-ai/demo/interaction-showcase.tsx +151 -0
- package/registry/nocobase-ai/demo/page-context-prompt-generator.tsx +355 -0
- package/registry/nocobase-ai/demo/page-context.tsx +884 -0
- package/registry/nocobase-ai/demo/page-element-showcase.tsx +197 -0
- package/registry/nocobase-ai/demo/prompt-card.tsx +41 -0
- package/registry/nocobase-ai/demo/prompt-generator.tsx +452 -0
- package/registry/nocobase-ai/demo/shortcut.tsx +1207 -0
- package/registry/nocobase-ai/demo/tool-cards.tsx +530 -0
- package/registry/nocobase-ai/extension.tsx +163 -0
- package/registry/nocobase-ai/global-ai-chat.tsx +213 -0
- package/registry/nocobase-ai/index.ts +3 -0
- package/registry/nocobase-ai/providers/ai-provider.tsx +323 -0
- package/registry/nocobase-ai/providers/avatars.ts +817 -0
- package/registry/nocobase-ai/providers/chat-context.tsx +109 -0
- package/registry/nocobase-ai/providers/chat-controller.ts +84 -0
- package/registry/nocobase-ai/providers/chat-message-utils.ts +198 -0
- package/registry/nocobase-ai/providers/chat-provider.tsx +871 -0
- package/registry/nocobase-ai/providers/chat-reducer.ts +147 -0
- package/registry/nocobase-ai/providers/chat-task-utils.ts +105 -0
- package/registry/nocobase-ai/providers/chat-transport.ts +184 -0
- package/registry/nocobase-ai/providers/form-registry.tsx +271 -0
- package/registry/nocobase-ai/providers/frontend-tool-registry.tsx +231 -0
- package/registry/nocobase-ai/providers/index.ts +61 -0
- package/registry/nocobase-ai/providers/model.ts +32 -0
- package/registry/nocobase-ai/providers/page-context.tsx +136 -0
- package/registry/nocobase-ai/providers/stream-coalescer.ts +60 -0
- package/registry/nocobase-ai/providers/stream-event-utils.ts +82 -0
- package/registry/nocobase-ai/providers/stream-parser.ts +61 -0
- package/registry/nocobase-ai/providers/sub-agent-stream.ts +316 -0
- package/registry/nocobase-ai/providers/types.ts +183 -0
- package/registry/nocobase-ai/providers/ui-message-stream.ts +463 -0
- package/registry/nocobase-ai/providers/use-automatic-tool-approval.ts +94 -0
- package/registry/nocobase-ai/providers/use-chat-attachments.ts +206 -0
- package/registry/nocobase-ai/providers/use-chat-message-actions.ts +554 -0
- package/registry/nocobase-ai/providers/use-chat-runtime.ts +210 -0
- package/registry/nocobase-ai/providers/use-chat-work-context.ts +93 -0
- package/registry/nocobase-ai/providers/use-conversation-catalog.ts +108 -0
- package/registry/nocobase-ai/providers/use-conversation-history.ts +178 -0
- package/registry/nocobase-ai/services/index.ts +7 -0
- package/registry/nocobase-ai/services/nocobase-ai-service.ts +454 -0
- package/registry/nocobase-ai/services/types.ts +82 -0
- package/registry/nocobase-ai/tests/business-report-regression.mjs +70 -0
- package/registry/nocobase-ai/tests/form-filler-regression.mjs +129 -0
- package/registry/nocobase-ai/tests/frontend-tool-regression.mjs +128 -0
- package/registry/nocobase-ai/tests/page-context-regression.mjs +48 -0
- package/registry/nocobase-ai/tests/stream-regression.mjs +207 -0
- package/registry.config.json +143 -0
- package/scripts/registry.mjs +161 -0
- package/src/App.css +95 -0
- package/src/App.tsx +127 -50
- package/src/app/extension.ts +9 -0
- package/src/app/extensions.tsx +28 -0
- package/src/components/access-control/access-denied.tsx +30 -0
- package/src/components/access-control/acl-bootstrap.tsx +9 -0
- package/src/components/access-control/acl-gate.tsx +42 -0
- package/src/components/access-control/navigate-to-accessible-resource.tsx +17 -0
- package/src/components/access-control/resource-access-guard.tsx +21 -0
- package/src/components/access-control/use-acl-runtime.ts +28 -0
- package/src/components/app-shell/header.tsx +4 -1
- package/src/components/app-shell/sidebar.tsx +34 -4
- package/src/lib/nocobase/acl/action.ts +147 -0
- package/src/lib/nocobase/acl/data-source.ts +24 -0
- package/src/lib/nocobase/acl/index.ts +6 -0
- package/src/lib/nocobase/acl/menu.ts +41 -0
- package/src/lib/nocobase/acl/record-permissions.ts +111 -0
- package/src/lib/nocobase/acl/store.ts +182 -0
- package/src/lib/nocobase/acl/types.ts +96 -0
- package/src/lib/nocobase/client.ts +262 -0
- package/src/lib/nocobase/error.ts +32 -0
- package/src/providers/access-control.ts +34 -0
- package/src/providers/auth.ts +74 -60
- package/src/providers/constants.ts +2 -37
- package/src/providers/data.ts +242 -85
- package/vite.config.ts +17 -0
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
};
|
|
85
|
+
source.include.forEach((entry) => mapping.include.add(entry));
|
|
86
|
+
sourceMappings.set(mappingKey, mapping);
|
|
87
|
+
|
|
88
|
+
const allFiles = sourceFiles.get(source.root) ?? walkFiles(sourceRoot);
|
|
89
|
+
sourceFiles.set(source.root, allFiles);
|
|
90
|
+
const includedFiles = allFiles.filter((file) =>
|
|
91
|
+
isIncluded(file, source.include)
|
|
92
|
+
);
|
|
93
|
+
if (!includedFiles.length) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Registry item ${item.name} include paths did not match any files`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (action !== "build") return item;
|
|
100
|
+
const { source: _source, ...registryItem } = item;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
...registryItem,
|
|
104
|
+
files: includedFiles.map((file) => ({
|
|
105
|
+
path: path.posix.join(source.root, file),
|
|
106
|
+
type: "registry:file",
|
|
107
|
+
target: path.posix.join(source.target, file),
|
|
108
|
+
})),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function copySource(source, overwrite) {
|
|
113
|
+
const sourcePath = path.join(projectRoot, source.root);
|
|
114
|
+
const targetPath = path.join(projectRoot, source.target);
|
|
115
|
+
|
|
116
|
+
if (fs.existsSync(targetPath) && !overwrite) {
|
|
117
|
+
console.log(`${source.target}: already installed, preserved`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
122
|
+
const temporaryRoot = fs.mkdtempSync(
|
|
123
|
+
path.join(path.dirname(targetPath), `.${path.basename(targetPath)}-install-`)
|
|
124
|
+
);
|
|
125
|
+
const temporaryTarget = path.join(temporaryRoot, path.basename(targetPath));
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const includedFiles = walkFiles(sourcePath).filter((file) =>
|
|
129
|
+
isIncluded(file, [...source.include])
|
|
130
|
+
);
|
|
131
|
+
for (const file of includedFiles) {
|
|
132
|
+
const sourceFile = path.join(sourcePath, file);
|
|
133
|
+
const targetFile = path.join(temporaryTarget, file);
|
|
134
|
+
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
|
135
|
+
fs.copyFileSync(sourceFile, targetFile);
|
|
136
|
+
}
|
|
137
|
+
if (overwrite) fs.rmSync(targetPath, { recursive: true, force: true });
|
|
138
|
+
fs.renameSync(temporaryTarget, targetPath);
|
|
139
|
+
console.log(
|
|
140
|
+
`${source.target}: ${overwrite ? "preview refreshed" : "installed"}`
|
|
141
|
+
);
|
|
142
|
+
} finally {
|
|
143
|
+
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (action === "build") {
|
|
148
|
+
const registry = {
|
|
149
|
+
$schema: "https://ui.shadcn.com/schema/registry.json",
|
|
150
|
+
...sourceConfig,
|
|
151
|
+
items,
|
|
152
|
+
};
|
|
153
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(registry, null, 2)}\n`);
|
|
154
|
+
for (const item of items) {
|
|
155
|
+
console.log(`${item.name}: ${item.files.length} files`);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
for (const source of sourceMappings.values()) {
|
|
159
|
+
copySource(source, action === "preview");
|
|
160
|
+
}
|
|
161
|
+
}
|
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,11 +1,7 @@
|
|
|
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";
|
|
@@ -33,10 +29,58 @@ import { Toaster } from "./components/notifications/toaster";
|
|
|
33
29
|
import { ThemeProvider } from "./components/theme/theme-provider";
|
|
34
30
|
import { TooltipProvider } from "./components/ui/tooltip";
|
|
35
31
|
import { BrandLogo } from "./components/app-shell/brand";
|
|
32
|
+
import {
|
|
33
|
+
AppExtensionProviders,
|
|
34
|
+
extensionResources,
|
|
35
|
+
extensionRouteElements,
|
|
36
|
+
} from "./app/extensions";
|
|
36
37
|
import "./App.css";
|
|
37
38
|
import { authProvider } from "./providers/auth";
|
|
39
|
+
import { accessControlProvider } from "./providers/access-control";
|
|
40
|
+
import { AclBootstrap } from "./components/access-control/acl-bootstrap";
|
|
41
|
+
import { ResourceAccessGuard } from "./components/access-control/resource-access-guard";
|
|
42
|
+
import { NavigateToAccessibleResource } from "./components/access-control/navigate-to-accessible-resource";
|
|
38
43
|
import { FileText, Tags } from "lucide-react";
|
|
39
44
|
|
|
45
|
+
const coreResources: ResourceProps[] = [
|
|
46
|
+
{
|
|
47
|
+
name: "blog_posts",
|
|
48
|
+
list: "/blog-posts",
|
|
49
|
+
create: "/blog-posts/create",
|
|
50
|
+
edit: "/blog-posts/edit/:id",
|
|
51
|
+
show: "/blog-posts/show/:id",
|
|
52
|
+
meta: {
|
|
53
|
+
label: "Blog posts",
|
|
54
|
+
icon: <FileText />,
|
|
55
|
+
description:
|
|
56
|
+
"Create and publish content on a reliable NocoBase data foundation.",
|
|
57
|
+
canCreate: true,
|
|
58
|
+
canDelete: true,
|
|
59
|
+
acl: {
|
|
60
|
+
type: "collection",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "categories",
|
|
66
|
+
list: "/categories",
|
|
67
|
+
create: "/categories/create",
|
|
68
|
+
edit: "/categories/edit/:id",
|
|
69
|
+
show: "/categories/show/:id",
|
|
70
|
+
meta: {
|
|
71
|
+
label: "Categories",
|
|
72
|
+
icon: <Tags />,
|
|
73
|
+
description:
|
|
74
|
+
"Organize reusable structures while NocoBase keeps the underlying data consistent.",
|
|
75
|
+
canCreate: true,
|
|
76
|
+
canDelete: true,
|
|
77
|
+
acl: {
|
|
78
|
+
type: "collection",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
40
84
|
const basename = import.meta.env.BASE_URL.replace(/\/+$/, "");
|
|
41
85
|
|
|
42
86
|
function App() {
|
|
@@ -49,38 +93,8 @@ function App() {
|
|
|
49
93
|
notificationProvider={useNotificationProvider()}
|
|
50
94
|
routerProvider={routerProvider}
|
|
51
95
|
authProvider={authProvider}
|
|
52
|
-
|
|
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
|
-
]}
|
|
96
|
+
accessControlProvider={accessControlProvider}
|
|
97
|
+
resources={[...coreResources, ...extensionResources]}
|
|
84
98
|
options={{
|
|
85
99
|
syncWithLocation: true,
|
|
86
100
|
warnWhenUnsavedChanges: true,
|
|
@@ -98,28 +112,89 @@ function App() {
|
|
|
98
112
|
key="authenticated-inner"
|
|
99
113
|
fallback={<CatchAllNavigate to="/login" />}
|
|
100
114
|
>
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
115
|
+
<AclBootstrap>
|
|
116
|
+
<AppExtensionProviders>
|
|
117
|
+
<Layout>
|
|
118
|
+
<Outlet />
|
|
119
|
+
</Layout>
|
|
120
|
+
</AppExtensionProviders>
|
|
121
|
+
</AclBootstrap>
|
|
104
122
|
</Authenticated>
|
|
105
123
|
}
|
|
106
124
|
>
|
|
107
125
|
<Route
|
|
108
126
|
index
|
|
109
|
-
element={<
|
|
127
|
+
element={<NavigateToAccessibleResource />}
|
|
110
128
|
/>
|
|
111
129
|
<Route path="/blog-posts">
|
|
112
|
-
<Route
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
130
|
+
<Route
|
|
131
|
+
index
|
|
132
|
+
element={
|
|
133
|
+
<ResourceAccessGuard resource="blog_posts" action="list">
|
|
134
|
+
<BlogPostList />
|
|
135
|
+
</ResourceAccessGuard>
|
|
136
|
+
}
|
|
137
|
+
/>
|
|
138
|
+
<Route
|
|
139
|
+
path="create"
|
|
140
|
+
element={
|
|
141
|
+
<ResourceAccessGuard resource="blog_posts" action="create">
|
|
142
|
+
<BlogPostCreate />
|
|
143
|
+
</ResourceAccessGuard>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
<Route
|
|
147
|
+
path="edit/:id"
|
|
148
|
+
element={
|
|
149
|
+
<ResourceAccessGuard resource="blog_posts" action="edit">
|
|
150
|
+
<BlogPostEdit />
|
|
151
|
+
</ResourceAccessGuard>
|
|
152
|
+
}
|
|
153
|
+
/>
|
|
154
|
+
<Route
|
|
155
|
+
path="show/:id"
|
|
156
|
+
element={
|
|
157
|
+
<ResourceAccessGuard resource="blog_posts" action="show">
|
|
158
|
+
<BlogPostShow />
|
|
159
|
+
</ResourceAccessGuard>
|
|
160
|
+
}
|
|
161
|
+
/>
|
|
116
162
|
</Route>
|
|
117
163
|
<Route path="/categories">
|
|
118
|
-
<Route
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
164
|
+
<Route
|
|
165
|
+
index
|
|
166
|
+
element={
|
|
167
|
+
<ResourceAccessGuard resource="categories" action="list">
|
|
168
|
+
<CategoryList />
|
|
169
|
+
</ResourceAccessGuard>
|
|
170
|
+
}
|
|
171
|
+
/>
|
|
172
|
+
<Route
|
|
173
|
+
path="create"
|
|
174
|
+
element={
|
|
175
|
+
<ResourceAccessGuard resource="categories" action="create">
|
|
176
|
+
<CategoryCreate />
|
|
177
|
+
</ResourceAccessGuard>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
<Route
|
|
181
|
+
path="edit/:id"
|
|
182
|
+
element={
|
|
183
|
+
<ResourceAccessGuard resource="categories" action="edit">
|
|
184
|
+
<CategoryEdit />
|
|
185
|
+
</ResourceAccessGuard>
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
188
|
+
<Route
|
|
189
|
+
path="show/:id"
|
|
190
|
+
element={
|
|
191
|
+
<ResourceAccessGuard resource="categories" action="show">
|
|
192
|
+
<CategoryShow />
|
|
193
|
+
</ResourceAccessGuard>
|
|
194
|
+
}
|
|
195
|
+
/>
|
|
122
196
|
</Route>
|
|
197
|
+
{extensionRouteElements}
|
|
123
198
|
<Route path="*" element={<ErrorComponent />} />
|
|
124
199
|
</Route>
|
|
125
200
|
<Route
|
|
@@ -128,7 +203,9 @@ function App() {
|
|
|
128
203
|
key="authenticated-outer"
|
|
129
204
|
fallback={<Outlet />}
|
|
130
205
|
>
|
|
131
|
-
<
|
|
206
|
+
<AclBootstrap>
|
|
207
|
+
<NavigateToAccessibleResource />
|
|
208
|
+
</AclBootstrap>
|
|
132
209
|
</Authenticated>
|
|
133
210
|
}
|
|
134
211
|
>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { 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
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
export function AppExtensionProviders({ children }: PropsWithChildren) {
|
|
22
|
+
return appExtensions.reduceRight<ReactNode>((content, extension) => {
|
|
23
|
+
const Provider = extension.Provider;
|
|
24
|
+
return Provider ? <Provider>{content}</Provider> : content;
|
|
25
|
+
}, children);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
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,42 @@
|
|
|
1
|
+
import { Loader2, RotateCcw } from "lucide-react";
|
|
2
|
+
import type { PropsWithChildren } from "react";
|
|
3
|
+
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { loadAcl, useAclSnapshot } from "@/lib/nocobase/acl";
|
|
6
|
+
|
|
7
|
+
export function AclGate({ children }: PropsWithChildren) {
|
|
8
|
+
const snapshot = useAclSnapshot();
|
|
9
|
+
|
|
10
|
+
if (snapshot.status === "ready") return children;
|
|
11
|
+
|
|
12
|
+
if (snapshot.status === "error") {
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
|
15
|
+
<div className="max-w-md text-center">
|
|
16
|
+
<h1 className="text-xl font-semibold">Unable to load permissions</h1>
|
|
17
|
+
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
18
|
+
{snapshot.error?.message ??
|
|
19
|
+
"The current role permissions could not be loaded."}
|
|
20
|
+
</p>
|
|
21
|
+
<Button
|
|
22
|
+
className="mt-5"
|
|
23
|
+
variant="outline"
|
|
24
|
+
onClick={() =>
|
|
25
|
+
void loadAcl({ force: true }).catch(() => undefined)
|
|
26
|
+
}
|
|
27
|
+
>
|
|
28
|
+
<RotateCcw />
|
|
29
|
+
Retry
|
|
30
|
+
</Button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex min-h-screen items-center justify-center bg-background">
|
|
38
|
+
<Loader2 className="size-7 animate-spin text-muted-foreground" />
|
|
39
|
+
<span className="sr-only">Loading permissions…</span>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -84,7 +84,10 @@ function MobileHeader() {
|
|
|
84
84
|
)}
|
|
85
85
|
/>
|
|
86
86
|
<Brand logoClassName="h-6" />
|
|
87
|
-
<
|
|
87
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
88
|
+
<ThemeToggle className="size-9" />
|
|
89
|
+
<UserDropdown />
|
|
90
|
+
</div>
|
|
88
91
|
</header>
|
|
89
92
|
);
|
|
90
93
|
}
|