@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
package/README.MD CHANGED
@@ -30,6 +30,76 @@ pnpm dev
30
30
  pnpm build
31
31
  ```
32
32
 
33
+ ### Extensions and the NocoBase Shadcn Registry
34
+
35
+ The starter separates framework-owned extension support from installed Registry
36
+ source:
37
+
38
+ - `src/app/extension.ts` defines the generic extension contract.
39
+ - `src/app/extensions.tsx` discovers `src/extensions/*/extension.tsx` and adds
40
+ its providers, Refine resources, and routes to the app.
41
+ - `registry/<name>` contains the canonical authoring source for a Registry
42
+ package while it is developed in this repository.
43
+ - `src/extensions/<name>` contains installed, application-owned source.
44
+ Downstream applications can commit this directory like any other source code.
45
+
46
+ `registry.config.json` is the only authoring manifest. In addition to standard
47
+ Shadcn Registry item fields, each item has a local `source` mapping used to
48
+ generate its standard `files` array. The same mapping lets the starter's
49
+ `postinstall` hook copy a locally authored extension only when its target is
50
+ missing. Existing installed source is never compared, updated, or overwritten.
51
+
52
+ You can run the same missing-only installation explicitly with:
53
+
54
+ ```bash
55
+ pnpm registry:install
56
+ ```
57
+
58
+ This repository currently authors four incremental NocoBase AI entries:
59
+
60
+ - `ai-runtime` provides AI services, providers, transports, and
61
+ controllers. Its NocoBase requests use the starter's shared
62
+ `src/lib/nocobase/client.ts` client.
63
+ - `ai-chat` adds the reusable chat components and depends on the
64
+ runtime entry.
65
+ - `ai-react-hook-form` adds the optional React Hook Form adapter for the fixed
66
+ Form filler Tool.
67
+ - `ai` adds the Refine extension integration and Demo pages and depends on the
68
+ chat entry.
69
+
70
+ To refresh the local preview from the canonical Registry source, run:
71
+
72
+ ```bash
73
+ pnpm registry:preview
74
+ ```
75
+
76
+ This is the only command that intentionally replaces the local preview at
77
+ `src/extensions/nocobase-ai`. Normal `pnpm dev`, `pnpm install`, and
78
+ `pnpm registry:install` do not overwrite an installed extension.
79
+
80
+ Build the standard Shadcn Registry JSON files into `public/r` with:
81
+
82
+ ```bash
83
+ pnpm registry:build
84
+ ```
85
+
86
+ With the development server running, a compatible starter can also add the
87
+ complete item through Shadcn:
88
+
89
+ ```bash
90
+ pnpm dlx shadcn@latest add @nocobase/ai
91
+ ```
92
+
93
+ Shadcn does not keep a Registry item lock or merge local changes. Re-running
94
+ `add` downloads the current item, skips identical files, and asks before
95
+ replacing different files. Use `--diff` to inspect an update and `--overwrite`
96
+ to accept the Registry version explicitly.
97
+
98
+ The Registry alias is configured in `components.json` and resolves to
99
+ `http://localhost:5173/r/{name}.json` during local development. The same generic
100
+ extension host and Registry source mapping can be reused for future Registry
101
+ packages without adding package-specific logic to `App.tsx` or the installer.
102
+
33
103
  ### Running the production server.
34
104
 
35
105
  ```bash
package/components.json CHANGED
@@ -21,5 +21,7 @@
21
21
  },
22
22
  "menuColor": "default",
23
23
  "menuAccent": "subtle",
24
- "registries": {}
24
+ "registries": {
25
+ "@nocobase": "http://localhost:5173/r/{name}.json"
26
+ }
25
27
  }
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@nocobase/portal-template-default",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
- "dependencies": {
5
+ "devDependencies": {
6
+ "@ai-sdk/react": "2.0.0",
6
7
  "@base-ui/react": "^1.6.0",
8
+ "@dicebear/core": "^9.4.3",
7
9
  "@fontsource-variable/geist": "^5.3.0",
8
10
  "@hookform/resolvers": "^5.0.1",
11
+ "@nocobase/ai-employee-avatars": "^1.0.2",
9
12
  "@refinedev/cli": "^2.16.48",
10
13
  "@refinedev/core": "^5.0.8",
11
14
  "@refinedev/devtools": "^2.0.1",
@@ -15,13 +18,19 @@
15
18
  "@refinedev/rest": "^2.1.0",
16
19
  "@refinedev/simple-rest": "^6.0.0",
17
20
  "@tailwindcss/vite": "^4.1.8",
21
+ "@tanstack/react-query": "^5.81.5",
18
22
  "@tanstack/react-table": "^8.2.6",
23
+ "ai": "5.0.0",
19
24
  "class-variance-authority": "^0.7.1",
20
25
  "clsx": "^2.1.1",
21
26
  "date-fns": "^4.1.0",
22
27
  "dayjs": "^1.10.7",
28
+ "echarts": "^5.5.0",
29
+ "echarts-for-react": "3.0.2",
23
30
  "embla-carousel-react": "^8.6.0",
31
+ "eventsource-parser": "3.1.0",
24
32
  "input-otp": "^1.4.2",
33
+ "ignore": "^7.0.6",
25
34
  "lucide-react": "^0.487.0",
26
35
  "next-themes": "^0.4.6",
27
36
  "postcss": "^8.5.3",
@@ -29,16 +38,16 @@
29
38
  "react-day-picker": "10.0.1",
30
39
  "react-dom": "19.1.0",
31
40
  "react-hook-form": "^7.57.0",
41
+ "react-markdown": "^10.1.0",
32
42
  "react-resizable-panels": "^4.12.2",
33
43
  "react-router": "^7.0.2",
34
44
  "recharts": "^3.8.0",
45
+ "remark-gfm": "^4.0.1",
35
46
  "shadcn": "^4.13.1",
36
47
  "sonner": "^2.0.3",
37
48
  "tailwind-merge": "^3.2.0",
38
49
  "tw-animate-css": "^1.2.5",
39
- "zod": "^3.24.3"
40
- },
41
- "devDependencies": {
50
+ "zod": "^3.24.3",
42
51
  "@eslint/js": "^9.25.0",
43
52
  "@tailwindcss/postcss": "^4.1.4",
44
53
  "@types/node": "^20",
@@ -64,8 +73,13 @@
64
73
  "url": "git+https://github.com/nocobase/portal-template-default.git"
65
74
  },
66
75
  "scripts": {
76
+ "postinstall": "node ./scripts/registry.mjs install-missing",
67
77
  "dev": "vite",
68
78
  "build": "tsc && refine build",
79
+ "registry:install": "node ./scripts/registry.mjs install-missing",
80
+ "registry:preview": "node ./scripts/registry.mjs preview",
81
+ "registry:build": "node ./scripts/registry.mjs build && shadcn build ./registry.json -o ./public/r",
82
+ "test:acl": "node registry/nocobase-acl/tests/acl-regression.mjs",
69
83
  "start": "refine start",
70
84
  "refine": "refine"
71
85
  },
@@ -0,0 +1,19 @@
1
+ # NocoBase ACL UI
2
+
3
+ Reusable ACL composition components for the NocoBase Refine starter.
4
+
5
+ After installation, import the components from `@/extensions/nocobase-acl`.
6
+
7
+ - `AclPage` combines page-level permissions.
8
+ - `AclRegion` hides or replaces individual data regions.
9
+ - `AclField` applies NocoBase field whitelists.
10
+ - `RoleSwitcher` can be placed in any application surface. It supports
11
+ normal roles, Anonymous when enabled, and Full permissions through the
12
+ `__union__` role when the system role mode allows it.
13
+
14
+ Mark non-collection resources with `meta.acl.type = "authenticated"`, or use
15
+ the `collection`, `snippet`, and `route` ACL metadata variants when the resource
16
+ should participate in NocoBase permission checks.
17
+
18
+ The underlying `accessControlProvider`, `roles:check` cache, record permissions,
19
+ and Refine integration are provided by the compatible Starter.
@@ -0,0 +1,106 @@
1
+ import type { BaseKey } from "@refinedev/core";
2
+ import type { PropsWithChildren, ReactNode } from "react";
3
+
4
+ import { AccessDenied } from "@/components/access-control/access-denied";
5
+ import {
6
+ canAccessWithSnapshot,
7
+ useAclSnapshot,
8
+ } from "@/lib/nocobase/acl";
9
+
10
+ export type AclPermission = {
11
+ resource: string;
12
+ action: string;
13
+ id?: BaseKey;
14
+ field?: string;
15
+ dataSourceKey?: string;
16
+ };
17
+
18
+ export type AclPageProps = PropsWithChildren<{
19
+ anyOf?: AclPermission[];
20
+ allOf?: AclPermission[];
21
+ fallback?: ReactNode;
22
+ }>;
23
+
24
+ export type AclRegionProps = PropsWithChildren<
25
+ Omit<AclPermission, "field"> & {
26
+ fallback?: "hidden" | "forbidden" | ReactNode;
27
+ }
28
+ >;
29
+
30
+ export type AclFieldProps = PropsWithChildren<{
31
+ resource: string;
32
+ action: string;
33
+ field: string;
34
+ dataSourceKey?: string;
35
+ fallback?: ReactNode;
36
+ }>;
37
+
38
+ const canAccess = (
39
+ snapshot: ReturnType<typeof useAclSnapshot>,
40
+ permission: AclPermission
41
+ ) =>
42
+ canAccessWithSnapshot(snapshot, {
43
+ resource: permission.resource,
44
+ action: permission.action,
45
+ params: {
46
+ id: permission.id,
47
+ field: permission.field,
48
+ dataSourceKey: permission.dataSourceKey,
49
+ },
50
+ });
51
+
52
+ export function AclPage({
53
+ children,
54
+ anyOf,
55
+ allOf,
56
+ fallback = <AccessDenied />,
57
+ }: AclPageProps) {
58
+ const snapshot = useAclSnapshot();
59
+ const anyAllowed = !anyOf?.length || anyOf.some((item) => canAccess(snapshot, item));
60
+ const allAllowed = !allOf?.length || allOf.every((item) => canAccess(snapshot, item));
61
+
62
+ return anyAllowed && allAllowed ? children : fallback;
63
+ }
64
+
65
+ export function AclRegion({
66
+ children,
67
+ resource,
68
+ action,
69
+ id,
70
+ dataSourceKey,
71
+ fallback = "hidden",
72
+ }: AclRegionProps) {
73
+ const snapshot = useAclSnapshot();
74
+ const allowed = canAccess(snapshot, {
75
+ resource,
76
+ action,
77
+ id,
78
+ dataSourceKey,
79
+ });
80
+
81
+ if (allowed) return children;
82
+ if (fallback === "hidden") return null;
83
+ if (fallback === "forbidden") {
84
+ return <AccessDenied className="min-h-40" />;
85
+ }
86
+ return fallback;
87
+ }
88
+
89
+ export function AclField({
90
+ children,
91
+ resource,
92
+ action,
93
+ field,
94
+ dataSourceKey,
95
+ fallback = null,
96
+ }: AclFieldProps) {
97
+ const snapshot = useAclSnapshot();
98
+ return canAccess(snapshot, {
99
+ resource,
100
+ action,
101
+ field,
102
+ dataSourceKey,
103
+ })
104
+ ? children
105
+ : fallback;
106
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./acl-boundary";
2
+ export * from "./role-options";
3
+ export * from "./role-switcher";
@@ -0,0 +1,29 @@
1
+ import type { Role, RoleMode } from "@/lib/nocobase/acl";
2
+
3
+ export const UNION_ROLE = "__union__";
4
+ export const ANONYMOUS_ROLE = "anonymous";
5
+
6
+ export function getRoleOptions({
7
+ roles,
8
+ roleMode,
9
+ allowAnonymous = false,
10
+ }: {
11
+ roles: Role[];
12
+ roleMode?: RoleMode;
13
+ allowAnonymous?: boolean;
14
+ }) {
15
+ if (roleMode === "only-use-union") {
16
+ return [{ name: UNION_ROLE, title: "Full permissions" }];
17
+ }
18
+
19
+ const options = roles.filter(
20
+ (role) => role.name !== UNION_ROLE && role.name !== ANONYMOUS_ROLE
21
+ );
22
+ if (allowAnonymous) {
23
+ options.push({ name: ANONYMOUS_ROLE, title: "Anonymous" });
24
+ }
25
+ if (roleMode === "allow-use-union") {
26
+ options.unshift({ name: UNION_ROLE, title: "Full permissions" });
27
+ }
28
+ return options;
29
+ }
@@ -0,0 +1,130 @@
1
+ import { useGetIdentity } from "@refinedev/core";
2
+ import { Loader2, ShieldCheck } from "lucide-react";
3
+ import { useMemo, useState, type ReactNode } from "react";
4
+
5
+ import { Badge } from "@/components/ui/badge";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectItem,
10
+ SelectSeparator,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@/components/ui/select";
14
+ import {
15
+ switchRole,
16
+ useAclSnapshot,
17
+ type AclIdentity,
18
+ type Role,
19
+ } from "@/lib/nocobase/acl";
20
+ import { cn } from "@/lib/utils";
21
+ import { getRoleOptions, UNION_ROLE } from "./role-options";
22
+
23
+ export type RoleSwitcherProps = {
24
+ className?: string;
25
+ triggerClassName?: string;
26
+ label?: ReactNode | false;
27
+ showWhenUnavailable?: boolean;
28
+ };
29
+
30
+ export function RoleSwitcher({
31
+ className,
32
+ triggerClassName,
33
+ label = "Switch role",
34
+ showWhenUnavailable = false,
35
+ }: RoleSwitcherProps) {
36
+ const { data: identity, isLoading } = useGetIdentity<AclIdentity>();
37
+ const acl = useAclSnapshot();
38
+ const [switching, setSwitching] = useState(false);
39
+ const [error, setError] = useState<string>();
40
+
41
+ const roles = useMemo(
42
+ () =>
43
+ getRoleOptions({
44
+ roles: identity?.roles ?? [],
45
+ roleMode: acl.data.roleMode,
46
+ allowAnonymous: acl.data.allowAnonymous,
47
+ }),
48
+ [acl.data.allowAnonymous, acl.data.roleMode, identity?.roles]
49
+ );
50
+
51
+ const currentRole = acl.data.role ?? roles[0]?.name;
52
+ const canSwitch = roles.length > 1 && acl.data.roleMode !== "only-use-union";
53
+
54
+ if (isLoading) {
55
+ return <Loader2 className="size-4 animate-spin text-muted-foreground" />;
56
+ }
57
+ if (!canSwitch && !showWhenUnavailable) return null;
58
+
59
+ if (!canSwitch) {
60
+ return (
61
+ <div className={cn("flex items-center gap-2 text-sm", className)}>
62
+ <ShieldCheck className="size-4 text-muted-foreground" />
63
+ <span className="text-muted-foreground">Current role</span>
64
+ <Badge variant="secondary">{getRoleTitle(roles, currentRole)}</Badge>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className={cn("space-y-2", className)}>
71
+ {label === false ? null : (
72
+ <p className="text-xs font-medium text-muted-foreground">{label}</p>
73
+ )}
74
+ <Select
75
+ value={currentRole}
76
+ disabled={switching}
77
+ onValueChange={(value) => {
78
+ if (!value || value === currentRole) return;
79
+ setSwitching(true);
80
+ setError(undefined);
81
+ void switchRole(value, { reloadAcl: false })
82
+ .then(() => window.location.reload())
83
+ .catch((reason) => {
84
+ setError(
85
+ reason instanceof Error ? reason.message : "Unable to switch role"
86
+ );
87
+ setSwitching(false);
88
+ });
89
+ }}
90
+ >
91
+ <SelectTrigger
92
+ className={cn("w-full min-w-52", triggerClassName)}
93
+ aria-label="Switch role"
94
+ >
95
+ {switching ? <Loader2 className="animate-spin" /> : <ShieldCheck />}
96
+ <SelectValue>{getRoleTitle(roles, currentRole)}</SelectValue>
97
+ </SelectTrigger>
98
+ <SelectContent>
99
+ {roles.map((role, index) => (
100
+ <RoleOption
101
+ key={role.name}
102
+ role={role}
103
+ showSeparator={index === 1 && roles[0]?.name === UNION_ROLE}
104
+ />
105
+ ))}
106
+ </SelectContent>
107
+ </Select>
108
+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function RoleOption({
114
+ role,
115
+ showSeparator,
116
+ }: {
117
+ role: Role;
118
+ showSeparator: boolean;
119
+ }) {
120
+ return (
121
+ <>
122
+ {showSeparator ? <SelectSeparator /> : null}
123
+ <SelectItem value={role.name}>{role.title || role.name}</SelectItem>
124
+ </>
125
+ );
126
+ }
127
+
128
+ function getRoleTitle(roles: Role[], roleName?: string) {
129
+ return roles.find((role) => role.name === roleName)?.title || roleName || "Role";
130
+ }
@@ -0,0 +1,115 @@
1
+ import { Card } from "@/components/ui/card";
2
+ import {
3
+ Table,
4
+ TableBody,
5
+ TableCell,
6
+ TableHead,
7
+ TableHeader,
8
+ TableRow,
9
+ } from "@/components/ui/table";
10
+
11
+ type PropRow = [name: string, type: string, defaultValue: string, description: string];
12
+
13
+ const pageProps: PropRow[] = [
14
+ ["anyOf", "AclPermission[]", "undefined", "Allows the page when any listed permission is available."],
15
+ ["allOf", "AclPermission[]", "undefined", "Requires every listed permission before rendering the page."],
16
+ ["fallback", "ReactNode", "AccessDenied", "Replaces the complete page when its permission test fails."],
17
+ ["children", "ReactNode", "required", "The protected page content."],
18
+ ];
19
+
20
+ const regionProps: PropRow[] = [
21
+ ["resource", "string", "required", "NocoBase collection or ACL resource name."],
22
+ ["action", "string", "required", "Refine action such as list, show, create, edit, or delete."],
23
+ ["id", "BaseKey", "undefined", "Optional record ID for record-scoped permission checks."],
24
+ ["dataSourceKey", "string", "undefined", "Selects a non-main NocoBase data source."],
25
+ ["fallback", '"hidden" | "forbidden" | ReactNode', '"hidden"', "Hides the region or replaces it with a local denied state."],
26
+ ["children", "ReactNode", "required", "The protected page region."],
27
+ ];
28
+
29
+ const fieldProps: PropRow[] = [
30
+ ["resource", "string", "required", "NocoBase collection containing the field."],
31
+ ["action", "string", "required", "The action whose field whitelist should be checked."],
32
+ ["field", "string", "required", "Exact NocoBase field name."],
33
+ ["dataSourceKey", "string", "undefined", "Selects a non-main NocoBase data source."],
34
+ ["fallback", "ReactNode", "null", "Optional read-only replacement for a denied field."],
35
+ ["children", "ReactNode", "required", "The protected field or field group."],
36
+ ];
37
+
38
+ export function AclBoundaryApi() {
39
+ return (
40
+ <section className="space-y-5 border-t pt-8">
41
+ <div>
42
+ <p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
43
+ Permission boundaries
44
+ </p>
45
+ <h2 className="mt-2 text-xl font-semibold tracking-tight">
46
+ Components behind the patterns
47
+ </h2>
48
+ <p className="mt-1.5 max-w-3xl text-sm leading-6 text-muted-foreground">
49
+ Use the smallest boundary that matches the intended user experience:
50
+ complete page, independent region, or individual field.
51
+ </p>
52
+ </div>
53
+ <div className="space-y-7">
54
+ <ComponentApi
55
+ title="AclPage"
56
+ description="Protects a complete route or business page. This corresponds to the Page permission pattern above."
57
+ rows={pageProps}
58
+ />
59
+ <ComponentApi
60
+ title="AclRegion"
61
+ description="Protects one independent area inside a larger page. This corresponds to the Region permission pattern."
62
+ rows={regionProps}
63
+ />
64
+ <ComponentApi
65
+ title="AclField"
66
+ description="Protects one field or field group. This corresponds to the Field permission pattern."
67
+ rows={fieldProps}
68
+ />
69
+ </div>
70
+ </section>
71
+ );
72
+ }
73
+
74
+ function ComponentApi({
75
+ title,
76
+ description,
77
+ rows,
78
+ }: {
79
+ title: string;
80
+ description: string;
81
+ rows: PropRow[];
82
+ }) {
83
+ return (
84
+ <div className="space-y-3">
85
+ <div>
86
+ <h3 className="font-semibold">{title}</h3>
87
+ <p className="mt-1 text-sm text-muted-foreground">{description}</p>
88
+ </div>
89
+ <Card className="gap-0 overflow-hidden py-0">
90
+ <Table>
91
+ <TableHeader>
92
+ <TableRow>
93
+ <TableHead>Prop</TableHead>
94
+ <TableHead>Type</TableHead>
95
+ <TableHead>Default</TableHead>
96
+ <TableHead>Description</TableHead>
97
+ </TableRow>
98
+ </TableHeader>
99
+ <TableBody>
100
+ {rows.map(([name, type, defaultValue, propDescription]) => (
101
+ <TableRow key={name}>
102
+ <TableCell className="font-mono text-xs font-medium">{name}</TableCell>
103
+ <TableCell className="font-mono text-xs text-muted-foreground">{type}</TableCell>
104
+ <TableCell className="font-mono text-xs text-muted-foreground">{defaultValue}</TableCell>
105
+ <TableCell className="min-w-72 whitespace-normal text-muted-foreground">
106
+ {propDescription}
107
+ </TableCell>
108
+ </TableRow>
109
+ ))}
110
+ </TableBody>
111
+ </Table>
112
+ </Card>
113
+ </div>
114
+ );
115
+ }