@nocobase/app-template-default 1.0.0-beta.68 → 1.0.0-beta.69

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 (63) hide show
  1. package/.agents/rules/components.md +1 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/permission-editor.tsx +589 -0
  13. package/client/components/permission-filter.tsx +455 -0
  14. package/client/components/route-overlay.tsx +26 -13
  15. package/client/components/ui/checkbox.tsx +25 -0
  16. package/client/components/ui/sidebar.tsx +1 -4
  17. package/client/components/ui/switch.tsx +30 -0
  18. package/client/components/ui/table.tsx +113 -0
  19. package/client/layouts/components/app-sidebar.tsx +1 -1
  20. package/client/layouts/use-sidebar-preference.ts +62 -42
  21. package/client/locales/system/en-US.ts +7 -0
  22. package/client/locales/system/settings-en-US.ts +141 -0
  23. package/client/locales/system/settings-zh-CN.ts +135 -0
  24. package/client/locales/system/zh-CN.ts +7 -0
  25. package/client/pages/auth/auth-page.css +15 -0
  26. package/client/pages/auth/login.tsx +1 -1
  27. package/client/pages/auth/shared.tsx +3 -1
  28. package/client/pages/settings/hooks.tsx +137 -0
  29. package/client/pages/settings/permission-filters.tsx +119 -0
  30. package/client/pages/settings/permissions-model.ts +260 -0
  31. package/client/pages/settings/role-form.tsx +426 -0
  32. package/client/pages/settings/roles.tsx +163 -0
  33. package/client/pages/settings/routes.ts +79 -0
  34. package/client/pages/settings/shared.tsx +119 -0
  35. package/client/pages/settings/user-detail.tsx +268 -0
  36. package/client/pages/settings/user-form.tsx +427 -0
  37. package/client/pages/settings/users.tsx +451 -0
  38. package/client/routing/app-router.tsx +4 -1
  39. package/client/routing/session-preparation.tsx +65 -0
  40. package/client/styles.css +4 -7
  41. package/package.json +24 -23
  42. package/tests/components/app-layout-routes.test.tsx +14 -7
  43. package/tests/components/data-table.test.tsx +127 -0
  44. package/tests/components/layout-i18n.test.tsx +8 -1
  45. package/tests/components/navigation-menu.test.tsx +24 -14
  46. package/tests/components/permission-editor.test.tsx +477 -0
  47. package/tests/components/permission-filter-binding.test.tsx +150 -0
  48. package/tests/components/permission-filter.test.tsx +202 -0
  49. package/tests/components/settings.test.tsx +294 -0
  50. package/tests/components/sidebar-permissions.test.tsx +12 -4
  51. package/tests/components/sidebar-preference.test.tsx +209 -14
  52. package/tests/components/user-permissions.test.tsx +142 -0
  53. package/tests/fixtures/theme-tokens.tsx +9 -1
  54. package/tests/logic/asset-url-build.test.ts +0 -1
  55. package/tests/logic/client-routes.test.ts +40 -0
  56. package/tests/logic/client-shell.test.tsx +10 -2
  57. package/tests/logic/config-example.test.ts +30 -0
  58. package/tests/logic/dev-client-watch.test.ts +8 -1
  59. package/tests/logic/route-overlay.test.tsx +42 -0
  60. package/tests/logic/session-preparation.test.tsx +223 -0
  61. package/tests/logic/settings-permissions.test.ts +168 -0
  62. package/vite.config.ts +0 -22
  63. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,137 @@
1
+ import {
2
+ ApiClientError,
3
+ useApiClient,
4
+ useNavigationGuard,
5
+ } from '@nocobase/app-client';
6
+ import { useCan } from '@nocobase/app-plugin-authorization/client';
7
+ import { useTranslation } from '@nocobase/i18n/client';
8
+ import { useCallback, useEffect, useRef, useState } from 'react';
9
+ import { useLocation } from 'react-router';
10
+ import { ConfirmAction } from './shared.js';
11
+
12
+ export function useSettingsData<T>(path: string, keepPreviousData = false) {
13
+ const api = useApiClient();
14
+ const [revision, setRevision] = useState(0);
15
+ const [result, setResult] = useState<{
16
+ path: string;
17
+ revision: number;
18
+ data?: T;
19
+ error?: unknown;
20
+ loading: boolean;
21
+ }>({ path, revision: -1, loading: true });
22
+ useEffect(() => {
23
+ let active = true;
24
+ void api.request<T>({ path }).then(
25
+ (data) => {
26
+ if (active) setResult({ path, revision, data, loading: false });
27
+ },
28
+ (error: unknown) => {
29
+ if (active)
30
+ setResult((old) => ({
31
+ ...(old.path === path &&
32
+ !(
33
+ error instanceof ApiClientError &&
34
+ [401, 403, 404].includes(error.status)
35
+ )
36
+ ? old
37
+ : {}),
38
+ path,
39
+ revision,
40
+ error,
41
+ loading: false,
42
+ }));
43
+ },
44
+ );
45
+ return () => {
46
+ active = false;
47
+ };
48
+ }, [api, path, revision]);
49
+ return {
50
+ ...(result.path === path || keepPreviousData
51
+ ? result
52
+ : { path, loading: true, data: undefined, error: undefined }),
53
+ loading: result.path !== path || result.revision !== revision,
54
+ error:
55
+ result.path === path && result.revision === revision
56
+ ? result.error
57
+ : undefined,
58
+ reload: useCallback(() => setRevision((value) => value + 1), []),
59
+ replaceData: useCallback(
60
+ (data: T) => setResult({ path, revision, data, loading: false }),
61
+ [path, revision],
62
+ ),
63
+ };
64
+ }
65
+
66
+ export function useUserPermission(action: string, id = '*') {
67
+ return useCan({ resource: { type: 'user', id }, action }).can;
68
+ }
69
+ export function useRolePermission(action: string) {
70
+ return useCan({
71
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
72
+ action,
73
+ }).can;
74
+ }
75
+ /** The same decision covers overlay close, sidebar links, browser history and refresh. */
76
+ export function useSettingsLeave(
77
+ dirty: boolean,
78
+ busy: boolean,
79
+ onDiscard?: () => void,
80
+ ) {
81
+ const { t } = useTranslation();
82
+ const location = useLocation();
83
+ const allowedRef = useRef(false);
84
+ useEffect(() => {
85
+ allowedRef.current = false;
86
+ }, [location.key]);
87
+ const pendingRef = useRef<
88
+ { promise: Promise<boolean>; resolve: (value: boolean) => void } | undefined
89
+ >(undefined);
90
+ const [open, setOpen] = useState(false);
91
+ const guard = () => {
92
+ if (allowedRef.current) return true;
93
+ if (busy) return false;
94
+ if (!dirty) return true;
95
+ if (pendingRef.current) return pendingRef.current.promise;
96
+ let resolve!: (value: boolean) => void;
97
+ const promise = new Promise<boolean>((done) => {
98
+ resolve = done;
99
+ });
100
+ pendingRef.current = { promise, resolve };
101
+ setOpen(true);
102
+ return promise;
103
+ };
104
+ useNavigationGuard(guard);
105
+ useEffect(() => {
106
+ if (!dirty) return;
107
+ const handler = (event: BeforeUnloadEvent) => {
108
+ event.preventDefault();
109
+ event.returnValue = '';
110
+ };
111
+ window.addEventListener('beforeunload', handler);
112
+ return () => window.removeEventListener('beforeunload', handler);
113
+ }, [dirty]);
114
+ const decide = (value: boolean) => {
115
+ if (value) onDiscard?.();
116
+ allowedRef.current = value;
117
+ pendingRef.current?.resolve(value);
118
+ pendingRef.current = undefined;
119
+ setOpen(false);
120
+ };
121
+ return {
122
+ guard,
123
+ allow: () => {
124
+ allowedRef.current = true;
125
+ },
126
+ dialog: (
127
+ <ConfirmAction
128
+ open={open}
129
+ title={t('settings.discardTitle')}
130
+ description={t('settings.discardHint')}
131
+ busy={false}
132
+ onCancel={() => decide(false)}
133
+ onConfirm={() => decide(true)}
134
+ />
135
+ ),
136
+ };
137
+ }
@@ -0,0 +1,119 @@
1
+ import type { PermissionEditorSection } from '#components/permission-editor';
2
+ import {
3
+ PermissionFilter,
4
+ type PermissionFilterLabels,
5
+ } from '#components/permission-filter';
6
+ import type {
7
+ PermissionGrant,
8
+ AuthorizationOptionsResponse,
9
+ } from '@nocobase/app-plugin-authorization/client';
10
+ import { getScopeSelection } from './permissions-model.js';
11
+
12
+ export function configurePermissionScopes(
13
+ sections: readonly PermissionEditorSection[],
14
+ catalog: AuthorizationOptionsResponse,
15
+ grants: readonly PermissionGrant[],
16
+ text: (key: string, fallback?: string) => string,
17
+ editing: string | undefined,
18
+ setEditing: (id: string | undefined) => void,
19
+ onApply: (id: string, value: unknown) => void,
20
+ disabled: boolean,
21
+ ): readonly PermissionEditorSection[] {
22
+ const labels: PermissionFilterLabels = {
23
+ limit: text('settings.permissionFilter.limit'),
24
+ configure: text('settings.permissionFilter.configure'),
25
+ title: text('settings.permissionFilter.title'),
26
+ description: text('settings.permissionFilter.description'),
27
+ all: text('settings.permissionFilter.all'),
28
+ any: text('settings.permissionFilter.any'),
29
+ field: text('settings.permissionFilter.field'),
30
+ operator: text('settings.permissionFilter.operator'),
31
+ valueType: text('settings.permissionFilter.valueType'),
32
+ value: text('settings.permissionFilter.value'),
33
+ text: text('settings.permissionFilter.text'),
34
+ number: text('settings.permissionFilter.number'),
35
+ boolean: text('settings.permissionFilter.boolean'),
36
+ true: text('settings.permissionFilter.true'),
37
+ false: text('settings.permissionFilter.false'),
38
+ add: text('settings.permissionFilter.add'),
39
+ remove: text('settings.permissionFilter.remove'),
40
+ cancel: text('settings.permissionFilter.cancel'),
41
+ apply: text('settings.permissionFilter.apply'),
42
+ invalid: text('settings.permissionFilter.invalid'),
43
+ advanced: text('settings.permissionFilter.advanced'),
44
+ reconfigure: text('settings.permissionFilter.reconfigure'),
45
+ operators: Object.fromEntries(
46
+ [
47
+ '$eq',
48
+ '$ne',
49
+ '$includes',
50
+ '$gt',
51
+ '$gte',
52
+ '$lt',
53
+ '$lte',
54
+ '$empty',
55
+ '$notEmpty',
56
+ ].map((key) => [key, text(`settings.permissionFilter.operators.${key}`)]),
57
+ ),
58
+ };
59
+ const resources = catalog.sections.flatMap((section) =>
60
+ section.subsections.flatMap((sub) => sub.resources),
61
+ );
62
+ return sections.map((section) => ({
63
+ ...section,
64
+ groups: section.groups.map((group) => ({
65
+ ...group,
66
+ rows: group.rows.map((row) => ({
67
+ ...row,
68
+ scopes:
69
+ row.scopes?.map((scope) => {
70
+ if (scope.level !== 'customFilter' && editing !== scope.id)
71
+ return scope;
72
+ const [type, resourceId, action, key] = JSON.parse(
73
+ scope.id,
74
+ ) as string[];
75
+ const definition = resources
76
+ .find(
77
+ (resource) =>
78
+ resource.type === type && resource.id === resourceId,
79
+ )
80
+ ?.dataScopes?.[action ?? '']?.find((item) => item.key === key);
81
+ const value = getScopeSelection(grants, scope.id);
82
+ const params: unknown =
83
+ value && typeof value === 'object'
84
+ ? Reflect.get(value, 'params')
85
+ : undefined;
86
+ const filter: unknown =
87
+ params && typeof params === 'object'
88
+ ? Reflect.get(params, 'filter')
89
+ : undefined;
90
+ return {
91
+ ...scope,
92
+ configuration: (
93
+ <PermissionFilter
94
+ open={editing === scope.id}
95
+ onOpenChange={(open) =>
96
+ setEditing(open ? scope.id : undefined)
97
+ }
98
+ value={filter}
99
+ fields={(definition?.fields ?? []).map((field) => ({
100
+ value: field,
101
+ label: text(`settings.permissionFields.${field}`, field),
102
+ }))}
103
+ labels={labels}
104
+ disabled={disabled}
105
+ onApply={(filter) =>
106
+ onApply(scope.id, {
107
+ type: 'recordAccess',
108
+ key: 'customFilter',
109
+ params: { filter },
110
+ })
111
+ }
112
+ />
113
+ ),
114
+ };
115
+ }) ?? [],
116
+ })),
117
+ })),
118
+ }));
119
+ }
@@ -0,0 +1,260 @@
1
+ import type {
2
+ AuthorizationOptionsResponse,
3
+ PermissionGrant,
4
+ LocalizedText,
5
+ } from '@nocobase/app-plugin-authorization/client';
6
+ import type { PermissionEditorSection } from '#components/permission-editor';
7
+
8
+ export type CatalogResource =
9
+ AuthorizationOptionsResponse['sections'][number]['subsections'][number]['resources'][number];
10
+ export const permissionRowId = (
11
+ resource: { type: string; id: string },
12
+ action: string,
13
+ ): string => JSON.stringify([resource.type, resource.id, action]);
14
+ export function setPermission(
15
+ grants: readonly PermissionGrant[],
16
+ resource: CatalogResource,
17
+ action: string,
18
+ enabled: boolean,
19
+ ): readonly PermissionGrant[] {
20
+ const next = grants.map((grant) => ({
21
+ ...grant,
22
+ actions: [...grant.actions],
23
+ }));
24
+ let grant = next.find(
25
+ (item) =>
26
+ item.resource.type === resource.type && item.resource.id === resource.id,
27
+ );
28
+ if (!grant && enabled) {
29
+ grant = { resource: { type: resource.type, id: resource.id }, actions: [] };
30
+ next.push(grant);
31
+ }
32
+ if (grant) {
33
+ grant.actions = grant.actions.filter((item) => item.action !== action);
34
+ if (enabled) {
35
+ const scopes = resource.dataScopes?.[action];
36
+ grant.actions.push({
37
+ action,
38
+ ...(scopes?.length
39
+ ? {
40
+ policy: {
41
+ type: 'composite',
42
+ scopes: Object.fromEntries(
43
+ scopes.map((scope) => [scope.key, scope.defaultValue ?? '']),
44
+ ),
45
+ },
46
+ }
47
+ : {}),
48
+ });
49
+ }
50
+ }
51
+ return next.filter((item) => item.actions.length);
52
+ }
53
+
54
+ export function permissionSections(
55
+ catalog: AuthorizationOptionsResponse,
56
+ grants: readonly PermissionGrant[],
57
+ text: (value: LocalizedText) => string,
58
+ ): readonly PermissionEditorSection[] {
59
+ return catalog.sections
60
+ .map((section) => ({
61
+ id: section.name,
62
+ title: text(section.title),
63
+ groups: section.subsections.flatMap((subsection) =>
64
+ subsection.resources.map((resource) => ({
65
+ id: JSON.stringify([subsection.name, resource.type, resource.id]),
66
+ title: text(resource.title),
67
+ accessOnly:
68
+ resource.type === 'page' &&
69
+ resource.actions.length === 1 &&
70
+ resource.actions[0]?.name === 'access',
71
+ hint: resource.description ? text(resource.description) : undefined,
72
+ rows: resource.actions.map((action) => {
73
+ const granted = grants
74
+ .find(
75
+ (grant) =>
76
+ grant.resource.type === resource.type &&
77
+ grant.resource.id === resource.id,
78
+ )
79
+ ?.actions.find((entry) => entry.action === action.name);
80
+ const row = {
81
+ id: permissionRowId(resource, action.name),
82
+ label: text(action.title),
83
+ actionKey: action.name,
84
+ enabled: Boolean(granted),
85
+ };
86
+ // Scope selectors belong to the action and do not inflate permission counts.
87
+ return {
88
+ ...row,
89
+ scopes: granted
90
+ ? (resource.dataScopes?.[action.name] ?? []).map((scope) => ({
91
+ id: JSON.stringify([
92
+ resource.type,
93
+ resource.id,
94
+ action.name,
95
+ scope.key,
96
+ ]),
97
+ label: text(
98
+ typeof scope.title === 'string' &&
99
+ scope.title === scope.collection
100
+ ? { key: 'settings.recordScope' }
101
+ : scope.title,
102
+ ),
103
+ levels: [
104
+ {
105
+ value: '',
106
+ label: text({ key: 'settings.noRecordScope' }),
107
+ },
108
+ ...scope.recordAccess.map((key) => ({
109
+ value: key,
110
+ label: text(
111
+ catalog.recordAccess.find(
112
+ (entry) => entry.key === key,
113
+ )?.title ?? key,
114
+ ),
115
+ })),
116
+ ],
117
+ level: scopeSelectionKey(
118
+ granted.policy?.scopes &&
119
+ typeof granted.policy.scopes === 'object'
120
+ ? Reflect.get(granted.policy.scopes, scope.key)
121
+ : undefined,
122
+ ),
123
+ }))
124
+ : [],
125
+ };
126
+ }),
127
+ })),
128
+ ),
129
+ }))
130
+ .filter((section) => section.groups.length);
131
+ }
132
+
133
+ export function setScope(
134
+ grants: readonly PermissionGrant[],
135
+ rowId: string,
136
+ value: unknown,
137
+ ): readonly PermissionGrant[] {
138
+ const [type, id, action, scope] = JSON.parse(rowId) as string[];
139
+ return grants.map((grant) =>
140
+ grant.resource.type !== type || grant.resource.id !== id
141
+ ? grant
142
+ : {
143
+ ...grant,
144
+ actions: grant.actions.map((entry) =>
145
+ entry.action !== action
146
+ ? entry
147
+ : {
148
+ ...entry,
149
+ policy: {
150
+ ...entry.policy,
151
+ type: 'composite',
152
+ scopes: {
153
+ ...(entry.policy?.scopes as
154
+ Record<string, unknown> | undefined),
155
+ [scope]: value,
156
+ },
157
+ },
158
+ },
159
+ ),
160
+ },
161
+ );
162
+ }
163
+
164
+ /** Fold an explicit edit batch into one draft without replacing unrelated policies. */
165
+ export function setPermissions(
166
+ grants: readonly PermissionGrant[],
167
+ catalog: AuthorizationOptionsResponse,
168
+ changes: readonly { id: string; enabled: boolean }[],
169
+ ): readonly PermissionGrant[] {
170
+ const resources = catalog.sections.flatMap((section) =>
171
+ section.subsections.flatMap((sub) => sub.resources),
172
+ );
173
+ return changes.reduce((current, { id, enabled }) => {
174
+ const [type, resourceId, action, scope] = JSON.parse(id) as string[];
175
+ if (scope) return current;
176
+ const resource = resources.find(
177
+ (item) => item.type === type && item.id === resourceId,
178
+ );
179
+ if (
180
+ !resource ||
181
+ !action ||
182
+ !resource.actions.some((item) => item.name === action)
183
+ )
184
+ return current;
185
+ const existing =
186
+ current
187
+ .find(
188
+ (grant) =>
189
+ grant.resource.type === type && grant.resource.id === resourceId,
190
+ )
191
+ ?.actions.some((item) => item.action === action) ?? false;
192
+ return existing === enabled
193
+ ? current
194
+ : setPermission(current, resource, action, enabled);
195
+ }, grants);
196
+ }
197
+
198
+ export function getScopeSelection(
199
+ grants: readonly PermissionGrant[],
200
+ rowId: string,
201
+ ): unknown {
202
+ const [type, id, action, scope] = JSON.parse(rowId) as string[];
203
+ const policy = grants
204
+ .find((grant) => grant.resource.type === type && grant.resource.id === id)
205
+ ?.actions.find((entry) => entry.action === action)?.policy;
206
+ return policy?.scopes && typeof policy.scopes === 'object' && scope
207
+ ? Reflect.get(policy.scopes, scope)
208
+ : undefined;
209
+ }
210
+ function scopeSelectionKey(value: unknown): string {
211
+ if (typeof value === 'string') return value;
212
+ if (
213
+ value &&
214
+ typeof value === 'object' &&
215
+ Reflect.get(value, 'type') === 'recordAccess' &&
216
+ typeof Reflect.get(value, 'key') === 'string'
217
+ )
218
+ return String(Reflect.get(value, 'key'));
219
+ return '';
220
+ }
221
+
222
+ /** Legacy bare custom-filter keys require configuration before saving. */
223
+ export function unconfiguredFilter(
224
+ grants: readonly PermissionGrant[],
225
+ ): string | undefined {
226
+ for (const grant of grants)
227
+ for (const action of grant.actions) {
228
+ const scopes = action.policy?.scopes;
229
+ if (!scopes || typeof scopes !== 'object') continue;
230
+ for (const [key, value] of Object.entries(scopes)) {
231
+ if (value === 'customFilter')
232
+ return JSON.stringify([
233
+ grant.resource.type,
234
+ grant.resource.id,
235
+ action.action,
236
+ key,
237
+ ]);
238
+ if (
239
+ !value ||
240
+ typeof value !== 'object' ||
241
+ Reflect.get(value, 'type') !== 'recordAccess' ||
242
+ Reflect.get(value, 'key') !== 'customFilter'
243
+ )
244
+ continue;
245
+ const params: unknown = Reflect.get(value, 'params');
246
+ const filter: unknown =
247
+ params && typeof params === 'object'
248
+ ? Reflect.get(params, 'filter')
249
+ : undefined;
250
+ if (filter === null || !['object', 'boolean'].includes(typeof filter))
251
+ return JSON.stringify([
252
+ grant.resource.type,
253
+ grant.resource.id,
254
+ action.action,
255
+ key,
256
+ ]);
257
+ }
258
+ }
259
+ return undefined;
260
+ }