@nocobase/app-template-default 1.0.0-beta.67 → 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.
- package/.agents/rules/components.md +2 -2
- package/.agents/rules/shell.md +5 -1
- package/AGENTS.md +5 -3
- package/Dockerfile.dockerignore +0 -1
- package/README.MD +3 -1
- package/client/app.ts +15 -0
- package/client/components/data-table/column-header.tsx +91 -0
- package/client/components/data-table/data-table.tsx +216 -0
- package/client/components/data-table/index.ts +13 -0
- package/client/components/data-table/pagination.tsx +165 -0
- package/client/components/data-table/view-options.tsx +77 -0
- package/client/components/page-header.tsx +1 -1
- package/client/components/permission-editor.tsx +589 -0
- package/client/components/permission-filter.tsx +455 -0
- package/client/components/route-overlay.tsx +26 -13
- package/client/components/status-badge.tsx +7 -1
- package/client/components/ui/checkbox.tsx +25 -0
- package/client/components/ui/sidebar.tsx +1 -4
- package/client/components/ui/switch.tsx +30 -0
- package/client/components/ui/table.tsx +113 -0
- package/client/extensions/nocobase-inbox/inbox-item.tsx +44 -58
- package/client/extensions/nocobase-inbox/inbox-list.tsx +73 -75
- package/client/index.tsx +4 -0
- package/client/layouts/components/app-sidebar.tsx +6 -6
- package/client/layouts/components/layout-header.tsx +1 -1
- package/client/layouts/components/navigation-menu.tsx +223 -12
- package/client/layouts/components/sidebar-footer.tsx +1 -1
- package/client/layouts/components/user-menu.tsx +1 -1
- package/client/layouts/use-sidebar-preference.ts +62 -42
- package/client/lib/navigation-transform.ts +8 -0
- package/client/lib/scrollbar.ts +88 -0
- package/client/locales/system/en-US.ts +11 -0
- package/client/locales/system/settings-en-US.ts +141 -0
- package/client/locales/system/settings-zh-CN.ts +135 -0
- package/client/locales/system/zh-CN.ts +22 -12
- package/client/pages/auth/auth-page.css +52 -0
- package/client/pages/auth/login.tsx +1 -1
- package/client/pages/auth/reset-password.tsx +57 -14
- package/client/pages/auth/shared.tsx +26 -24
- package/client/pages/home/index.tsx +2 -2
- package/client/pages/settings/hooks.tsx +137 -0
- package/client/pages/settings/permission-filters.tsx +119 -0
- package/client/pages/settings/permissions-model.ts +260 -0
- package/client/pages/settings/role-form.tsx +426 -0
- package/client/pages/settings/roles.tsx +163 -0
- package/client/pages/settings/routes.ts +79 -0
- package/client/pages/settings/shared.tsx +119 -0
- package/client/pages/settings/user-detail.tsx +268 -0
- package/client/pages/settings/user-form.tsx +427 -0
- package/client/pages/settings/users.tsx +451 -0
- package/client/routing/app-router.tsx +4 -1
- package/client/routing/route-navigation.ts +10 -1
- package/client/routing/session-preparation.tsx +65 -0
- package/client/styles.css +257 -24
- package/client/theme/theme-settings.tsx +12 -7
- package/client/theme/themes/compact.css +9 -14
- package/client/theme/themes/default.css +7 -12
- package/package.json +26 -25
- package/server/app.ts +2 -0
- package/server/config/notification.ts +4 -7
- package/tests/components/app-layout-routes.test.tsx +14 -7
- package/tests/components/auth-i18n.test.tsx +57 -5
- package/tests/components/data-table.test.tsx +127 -0
- package/tests/components/layout-i18n.test.tsx +10 -3
- package/tests/components/navigation-menu.test.tsx +155 -16
- package/tests/components/permission-editor.test.tsx +477 -0
- package/tests/components/permission-filter-binding.test.tsx +150 -0
- package/tests/components/permission-filter.test.tsx +202 -0
- package/tests/components/settings.test.tsx +294 -0
- package/tests/components/sidebar-permissions.test.tsx +17 -19
- package/tests/components/sidebar-preference.test.tsx +209 -14
- package/tests/components/status-badge.test.tsx +22 -0
- package/tests/components/user-permissions.test.tsx +142 -0
- package/tests/fixtures/theme-tokens.tsx +9 -1
- package/tests/logic/asset-url-build.test.ts +0 -1
- package/tests/logic/client-routes.test.ts +40 -0
- package/tests/logic/client-shell.test.tsx +10 -2
- package/tests/logic/config-example.test.ts +30 -0
- package/tests/logic/dev-client-watch.test.ts +8 -1
- package/tests/logic/route-overlay.test.tsx +42 -0
- package/tests/logic/scrollbar.test.ts +88 -0
- package/tests/logic/session-preparation.test.tsx +223 -0
- package/tests/logic/settings-permissions.test.ts +168 -0
- package/tests/logic/theme-tokens.test.ts +1 -0
- package/vite.config.ts +0 -22
- package/vitest.config.ts +1 -0
- 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
|
+
}
|