@nocobase/app-template-default 1.0.0-beta.73 → 1.0.0-beta.75
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.md +8 -2
- package/README.MD +3 -1
- package/cli/plugins.ts +2 -1
- package/client/components/default-access-editor.tsx +183 -0
- package/client/components/permission-editor.tsx +216 -150
- package/client/components/role-list.tsx +85 -0
- package/client/lib/navigation-transform.ts +29 -1
- package/client/locales/system/settings-en-US.ts +74 -2
- package/client/locales/system/settings-zh-CN.ts +66 -2
- package/client/pages/settings/default-access-form.tsx +499 -0
- package/client/pages/settings/default-access-model.ts +154 -0
- package/client/pages/settings/default-access.tsx +407 -0
- package/client/pages/settings/permission-filters.tsx +43 -37
- package/client/pages/settings/permission-management-page.tsx +105 -0
- package/client/pages/settings/permissions-model.ts +37 -1
- package/client/pages/settings/role-form.tsx +46 -18
- package/client/pages/settings/roles.tsx +26 -43
- package/client/pages/settings/routes.ts +78 -19
- package/client/pages/settings/shared.tsx +3 -0
- package/client/pages/settings/users.tsx +1 -1
- package/client/plugins.ts +2 -0
- package/client/routing/route-navigation.ts +21 -1
- package/package.json +25 -23
- package/server/plugins.ts +2 -0
- package/tests/components/default-access.test.tsx +554 -0
- package/tests/components/page-harness.test.tsx +132 -112
- package/tests/components/permission-editor.test.tsx +233 -73
- package/tests/components/permission-management-tabs.test.tsx +128 -0
- package/tests/components/role-list.test.tsx +136 -0
- package/tests/components/roles.test.tsx +85 -0
- package/tests/components/user-permissions.test.tsx +12 -0
- package/tests/logic/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/client-routes.test.ts +8 -0
- package/tests/logic/default-access-model.test.ts +167 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
- package/tests/logic/permission-management-routes.test.ts +64 -0
- package/tests/logic/settings-permissions.test.ts +89 -0
- package/vitest.config.ts +2 -2
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
|
|
2
|
+
import {
|
|
3
|
+
useCan,
|
|
4
|
+
type AuthorizationOptionsResponse,
|
|
5
|
+
type LocalizedText,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { useTranslation } from '@nocobase/i18n/client';
|
|
8
|
+
import { useRef, useState, type ReactElement } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
useLocation,
|
|
11
|
+
useNavigate,
|
|
12
|
+
useOutletContext,
|
|
13
|
+
useParams,
|
|
14
|
+
} from 'react-router';
|
|
15
|
+
import { PageContainer } from '#components/page-container';
|
|
16
|
+
import { PageHeader } from '#components/page-header';
|
|
17
|
+
import { RouteChildPage } from '#components/route-child-page';
|
|
18
|
+
import {
|
|
19
|
+
DefaultAccessEditor,
|
|
20
|
+
type DefaultAccessScopeControl,
|
|
21
|
+
} from '#components/default-access-editor';
|
|
22
|
+
import { PermissionFilter } from '#components/permission-filter';
|
|
23
|
+
import { Button } from '#components/ui/button';
|
|
24
|
+
import { Alert, AlertDescription } from '#components/ui/alert';
|
|
25
|
+
import { SettingsError, SettingsLoading } from './shared.js';
|
|
26
|
+
import { useSettingsData, useSettingsLeave } from './hooks.js';
|
|
27
|
+
import { permissionFilterLabels } from './permission-filters.js';
|
|
28
|
+
import {
|
|
29
|
+
actionKey,
|
|
30
|
+
resourceKey,
|
|
31
|
+
ruleResources,
|
|
32
|
+
ruleScopes,
|
|
33
|
+
replaceRuleScope,
|
|
34
|
+
editableRule,
|
|
35
|
+
type DefaultAccessRule,
|
|
36
|
+
type ScopeSelection,
|
|
37
|
+
} from './default-access-model.js';
|
|
38
|
+
|
|
39
|
+
function DefaultAccessFormBody(): ReactElement {
|
|
40
|
+
const { key } = useParams();
|
|
41
|
+
const creating = key === undefined;
|
|
42
|
+
const { t } = useTranslation();
|
|
43
|
+
const api = useApiClient();
|
|
44
|
+
const toaster = useToaster();
|
|
45
|
+
const navigate = useNavigate();
|
|
46
|
+
const location = useLocation();
|
|
47
|
+
const parent = useOutletContext<{
|
|
48
|
+
reload: () => void;
|
|
49
|
+
saved: (rule: DefaultAccessRule) => void;
|
|
50
|
+
}>();
|
|
51
|
+
const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
|
|
52
|
+
'authorization/defaultAccess',
|
|
53
|
+
);
|
|
54
|
+
const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
|
|
55
|
+
'authorization/defaultAccess/options',
|
|
56
|
+
);
|
|
57
|
+
const canWrite = useCan({
|
|
58
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
59
|
+
action: creating ? 'create' : 'update',
|
|
60
|
+
}).can;
|
|
61
|
+
const [draft, setDraft] = useState<DefaultAccessRule>();
|
|
62
|
+
const [busy, setBusy] = useState(false);
|
|
63
|
+
const [error, setError] = useState<unknown>();
|
|
64
|
+
const [message, setMessage] = useState<string>();
|
|
65
|
+
const [submitted, setSubmitted] = useState(false);
|
|
66
|
+
const [fieldErrors, setFieldErrors] = useState<{
|
|
67
|
+
key?: string;
|
|
68
|
+
resource?: string;
|
|
69
|
+
}>({});
|
|
70
|
+
const [scopeErrors, setScopeErrors] = useState<Record<string, string>>({});
|
|
71
|
+
const [editing, setEditing] = useState<string>();
|
|
72
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
73
|
+
const initial = creating
|
|
74
|
+
? undefined
|
|
75
|
+
: rules.data?.data.find((rule) => rule.key === key);
|
|
76
|
+
const value = draft ??
|
|
77
|
+
initial ?? { key: '', resource: { type: '', id: '' }, actions: [] };
|
|
78
|
+
const dirty =
|
|
79
|
+
draft !== undefined && JSON.stringify(draft) !== JSON.stringify(initial);
|
|
80
|
+
const leave = useSettingsLeave(dirty, busy);
|
|
81
|
+
const close = () => {
|
|
82
|
+
void navigate({ pathname: '/default-access', search: location.search });
|
|
83
|
+
};
|
|
84
|
+
const text = (value: LocalizedText): string =>
|
|
85
|
+
typeof value === 'string'
|
|
86
|
+
? value
|
|
87
|
+
: t(value.key, {
|
|
88
|
+
ns: value.ns,
|
|
89
|
+
defaultValue: value.defaultValue ?? value.key,
|
|
90
|
+
});
|
|
91
|
+
const resources = catalog.data ? ruleResources(catalog.data.data) : [];
|
|
92
|
+
const resource = resources.find(
|
|
93
|
+
(entry) => resourceKey(entry) === resourceKey(value.resource),
|
|
94
|
+
);
|
|
95
|
+
const scopes = catalog.data ? ruleScopes(resource, catalog.data.data) : [];
|
|
96
|
+
const supported = creating
|
|
97
|
+
? scopes.length > 0
|
|
98
|
+
: Boolean(catalog.data && editableRule(value, scopes, catalog.data.data));
|
|
99
|
+
const readOnly = !canWrite || busy || (!creating && !supported);
|
|
100
|
+
const options = resources
|
|
101
|
+
.filter(
|
|
102
|
+
(entry) =>
|
|
103
|
+
!creating ||
|
|
104
|
+
!rules.data?.data.some(
|
|
105
|
+
(rule) => resourceKey(rule.resource) === resourceKey(entry),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
.map((entry) => ({ value: resourceKey(entry), label: text(entry.title) }));
|
|
109
|
+
if (
|
|
110
|
+
!creating &&
|
|
111
|
+
!options.some((option) => option.value === resourceKey(value.resource))
|
|
112
|
+
)
|
|
113
|
+
options.push({
|
|
114
|
+
value: resourceKey(value.resource),
|
|
115
|
+
label: value.resource.id,
|
|
116
|
+
});
|
|
117
|
+
const invalidKey =
|
|
118
|
+
!value.key.trim() ||
|
|
119
|
+
['options', 'subjects', 'records'].includes(value.key.trim());
|
|
120
|
+
const fieldLabels: Record<string, string> = {
|
|
121
|
+
name: t('settings.defaultAccess.fieldLabels.name'),
|
|
122
|
+
id: t('settings.defaultAccess.fieldLabels.id'),
|
|
123
|
+
};
|
|
124
|
+
const labels = {
|
|
125
|
+
...permissionFilterLabels((label, fallback) =>
|
|
126
|
+
t(label, { defaultValue: fallback ?? label }),
|
|
127
|
+
),
|
|
128
|
+
description: t('settings.defaultAccess.filterDescription'),
|
|
129
|
+
};
|
|
130
|
+
const controls: DefaultAccessScopeControl[] = scopes.map((scope) => {
|
|
131
|
+
const selected = value.actions.find(
|
|
132
|
+
(entry) => actionKey(entry) === scope.id,
|
|
133
|
+
)?.selection;
|
|
134
|
+
const selectedValue = selected ? JSON.stringify(selected) : '';
|
|
135
|
+
const choices = [
|
|
136
|
+
{ value: '', label: t('settings.defaultAccess.none') },
|
|
137
|
+
...(!scope.recordAccess.includes('allRecords')
|
|
138
|
+
? [
|
|
139
|
+
{
|
|
140
|
+
value: JSON.stringify({ type: 'all' }),
|
|
141
|
+
label: t('settings.defaultAccess.all'),
|
|
142
|
+
},
|
|
143
|
+
]
|
|
144
|
+
: []),
|
|
145
|
+
...scope.recordAccess.map((preset) => ({
|
|
146
|
+
value: JSON.stringify({ type: 'recordAccess', key: preset }),
|
|
147
|
+
label: text(
|
|
148
|
+
catalog.data?.data.recordAccess.find((entry) => entry.key === preset)
|
|
149
|
+
?.title ?? preset,
|
|
150
|
+
),
|
|
151
|
+
})),
|
|
152
|
+
];
|
|
153
|
+
if (selected && !choices.some((choice) => choice.value === selectedValue))
|
|
154
|
+
choices.push({
|
|
155
|
+
value: selectedValue,
|
|
156
|
+
label:
|
|
157
|
+
selected.type === 'all'
|
|
158
|
+
? t('settings.defaultAccess.all')
|
|
159
|
+
: selected.type === 'records'
|
|
160
|
+
? t('settings.defaultAccess.records', {
|
|
161
|
+
count: selected.ids.length,
|
|
162
|
+
})
|
|
163
|
+
: selected.key === 'customFilter'
|
|
164
|
+
? t('settings.defaultAccess.custom')
|
|
165
|
+
: t('settings.defaultAccess.retained'),
|
|
166
|
+
});
|
|
167
|
+
const custom =
|
|
168
|
+
selected?.type === 'recordAccess' && selected.key === 'customFilter';
|
|
169
|
+
const params =
|
|
170
|
+
custom && selected.params && typeof selected.params === 'object'
|
|
171
|
+
? (Reflect.get(selected.params, 'filter') as unknown)
|
|
172
|
+
: undefined;
|
|
173
|
+
return {
|
|
174
|
+
id: scope.id,
|
|
175
|
+
error: scopeErrors[scope.id],
|
|
176
|
+
action: text(scope.actionTitle),
|
|
177
|
+
scope: text(scope.title),
|
|
178
|
+
value: selectedValue,
|
|
179
|
+
options: choices,
|
|
180
|
+
configuration:
|
|
181
|
+
custom || editing === scope.id ? (
|
|
182
|
+
<PermissionFilter
|
|
183
|
+
open={editing === scope.id}
|
|
184
|
+
onOpenChange={(open) => setEditing(open ? scope.id : undefined)}
|
|
185
|
+
value={params}
|
|
186
|
+
fields={scope.fields.map((field) => ({
|
|
187
|
+
value: field,
|
|
188
|
+
label: fieldLabels[field] ?? field,
|
|
189
|
+
}))}
|
|
190
|
+
labels={labels}
|
|
191
|
+
disabled={readOnly}
|
|
192
|
+
onApply={(filter) => {
|
|
193
|
+
setDraft(
|
|
194
|
+
replaceRuleScope(value, scope, {
|
|
195
|
+
type: 'recordAccess',
|
|
196
|
+
key: 'customFilter',
|
|
197
|
+
params: { filter },
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
setScopeErrors((old) => ({ ...old, [scope.id]: '' }));
|
|
201
|
+
setEditing(undefined);
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
) : undefined,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
// Never discard entries no longer present in the live plugin catalog.
|
|
208
|
+
for (const action of value.actions)
|
|
209
|
+
if (!scopes.some((scope) => scope.id === actionKey(action)))
|
|
210
|
+
controls.push({
|
|
211
|
+
id: actionKey(action),
|
|
212
|
+
action: action.action,
|
|
213
|
+
scope: action.scopeKey ?? value.resource.id,
|
|
214
|
+
value: JSON.stringify(action.selection),
|
|
215
|
+
options: [
|
|
216
|
+
{
|
|
217
|
+
value: JSON.stringify(action.selection),
|
|
218
|
+
label: t('settings.defaultAccess.retained'),
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
const save = async () => {
|
|
223
|
+
setSubmitted(true);
|
|
224
|
+
setFieldErrors({});
|
|
225
|
+
setScopeErrors({});
|
|
226
|
+
setMessage(undefined);
|
|
227
|
+
setError(undefined);
|
|
228
|
+
if (invalidKey || !resource || !supported || !canWrite || busy) {
|
|
229
|
+
requestAnimationFrame(() =>
|
|
230
|
+
formRef.current
|
|
231
|
+
?.querySelector<HTMLElement>('[aria-invalid="true"]')
|
|
232
|
+
?.focus(),
|
|
233
|
+
);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setBusy(true);
|
|
237
|
+
try {
|
|
238
|
+
const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
|
|
239
|
+
path: 'authorization/defaultAccess',
|
|
240
|
+
});
|
|
241
|
+
if (
|
|
242
|
+
!creating &&
|
|
243
|
+
JSON.stringify(latest.data.find((rule) => rule.key === key)) !==
|
|
244
|
+
JSON.stringify(initial)
|
|
245
|
+
) {
|
|
246
|
+
setMessage(t('settings.defaultAccess.changed'));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const saved = await api.request<{ data: DefaultAccessRule }>({
|
|
250
|
+
path: creating
|
|
251
|
+
? 'authorization/defaultAccess'
|
|
252
|
+
: `authorization/defaultAccess/${encodeURIComponent(key ?? '')}`,
|
|
253
|
+
method: creating ? 'POST' : 'PATCH',
|
|
254
|
+
json: creating
|
|
255
|
+
? { ...value, key: value.key.trim() }
|
|
256
|
+
: { actions: value.actions },
|
|
257
|
+
});
|
|
258
|
+
toaster.show({
|
|
259
|
+
type: 'success',
|
|
260
|
+
title: t('settings.defaultAccess.saved', { name: value.key }),
|
|
261
|
+
});
|
|
262
|
+
leave.allow();
|
|
263
|
+
parent.saved(saved.data);
|
|
264
|
+
close();
|
|
265
|
+
} catch (next) {
|
|
266
|
+
if (
|
|
267
|
+
next instanceof ApiClientError &&
|
|
268
|
+
next.payload &&
|
|
269
|
+
typeof next.payload === 'object'
|
|
270
|
+
) {
|
|
271
|
+
const detail: unknown = Reflect.get(next.payload, 'error');
|
|
272
|
+
const violations: unknown =
|
|
273
|
+
detail && typeof detail === 'object'
|
|
274
|
+
? Reflect.get(detail, 'fieldViolations')
|
|
275
|
+
: undefined;
|
|
276
|
+
if (Array.isArray(violations)) {
|
|
277
|
+
const errors: { key?: string; resource?: string } = {};
|
|
278
|
+
const scopeErrors: Record<string, string> = {};
|
|
279
|
+
for (const violation of violations) {
|
|
280
|
+
const field: unknown =
|
|
281
|
+
violation && typeof violation === 'object'
|
|
282
|
+
? Reflect.get(violation, 'field')
|
|
283
|
+
: undefined;
|
|
284
|
+
if (field === 'key')
|
|
285
|
+
errors.key = t('settings.defaultAccess.keyRequired');
|
|
286
|
+
else if (typeof field === 'string' && field.startsWith('resource'))
|
|
287
|
+
errors.resource = t('settings.defaultAccess.resourceRequired');
|
|
288
|
+
else if (typeof field === 'string') {
|
|
289
|
+
const index = /^actions\.(\d+)(?:\.|$)/.exec(field);
|
|
290
|
+
const entry = index ? value.actions[Number(index[1])] : undefined;
|
|
291
|
+
if (entry)
|
|
292
|
+
scopeErrors[actionKey(entry)] = t(
|
|
293
|
+
'settings.defaultAccess.scopeInvalid',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
setFieldErrors(errors);
|
|
298
|
+
setScopeErrors(scopeErrors);
|
|
299
|
+
requestAnimationFrame(() =>
|
|
300
|
+
formRef.current
|
|
301
|
+
?.querySelector<HTMLElement>('[aria-invalid="true"]')
|
|
302
|
+
?.focus(),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const reason: unknown =
|
|
307
|
+
next && typeof next === 'object'
|
|
308
|
+
? Reflect.get(next, 'reason')
|
|
309
|
+
: undefined;
|
|
310
|
+
if (
|
|
311
|
+
reason === 'DEFAULT_ACCESS_CONFLICT' ||
|
|
312
|
+
reason === 'RULE_ALREADY_EXISTS'
|
|
313
|
+
)
|
|
314
|
+
setMessage(t('settings.defaultAccess.duplicate'));
|
|
315
|
+
else if (
|
|
316
|
+
reason === 'INVALID_AUTHORIZATION_INPUT' ||
|
|
317
|
+
reason === 'INVALID_INPUT'
|
|
318
|
+
)
|
|
319
|
+
setMessage(t('settings.defaultAccess.invalid'));
|
|
320
|
+
else setError(next);
|
|
321
|
+
} finally {
|
|
322
|
+
setBusy(false);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
return (
|
|
326
|
+
<PageContainer className='max-w-4xl'>
|
|
327
|
+
<PageHeader
|
|
328
|
+
title={t(
|
|
329
|
+
creating
|
|
330
|
+
? 'settings.defaultAccess.new'
|
|
331
|
+
: 'settings.defaultAccess.edit',
|
|
332
|
+
)}
|
|
333
|
+
description={t('settings.defaultAccess.description')}
|
|
334
|
+
/>
|
|
335
|
+
<SettingsError
|
|
336
|
+
error={rules.error || catalog.error}
|
|
337
|
+
retry={() => {
|
|
338
|
+
rules.reload();
|
|
339
|
+
catalog.reload();
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
{(rules.loading && !rules.data) || (catalog.loading && !catalog.data) ? (
|
|
343
|
+
<SettingsLoading />
|
|
344
|
+
) : (
|
|
345
|
+
!rules.error &&
|
|
346
|
+
!catalog.error && (
|
|
347
|
+
<>
|
|
348
|
+
{!creating && !initial ? (
|
|
349
|
+
<p role='status'>{t('settings.missing')}</p>
|
|
350
|
+
) : (
|
|
351
|
+
<form
|
|
352
|
+
ref={formRef}
|
|
353
|
+
noValidate
|
|
354
|
+
className='flex flex-col gap-6'
|
|
355
|
+
onSubmit={(event) => {
|
|
356
|
+
event.preventDefault();
|
|
357
|
+
void save();
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
<SettingsError error={error} />
|
|
361
|
+
{message && (
|
|
362
|
+
<Alert variant='destructive'>
|
|
363
|
+
<AlertDescription>
|
|
364
|
+
{message}
|
|
365
|
+
{!creating && (
|
|
366
|
+
<Button
|
|
367
|
+
type='button'
|
|
368
|
+
variant='outline'
|
|
369
|
+
onClick={() => {
|
|
370
|
+
setDraft(undefined);
|
|
371
|
+
setMessage(undefined);
|
|
372
|
+
rules.reload();
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{t('settings.defaultAccess.reload')}
|
|
376
|
+
</Button>
|
|
377
|
+
)}
|
|
378
|
+
</AlertDescription>
|
|
379
|
+
</Alert>
|
|
380
|
+
)}
|
|
381
|
+
{!creating && !supported && (
|
|
382
|
+
<p className='text-sm text-muted-foreground'>
|
|
383
|
+
{t('settings.defaultAccess.unsupported')}
|
|
384
|
+
</p>
|
|
385
|
+
)}
|
|
386
|
+
{creating && !options.length && (
|
|
387
|
+
<p className='text-sm text-muted-foreground'>
|
|
388
|
+
{t('settings.defaultAccess.noResources')}
|
|
389
|
+
</p>
|
|
390
|
+
)}
|
|
391
|
+
<DefaultAccessEditor
|
|
392
|
+
ruleKey={value.key}
|
|
393
|
+
resource={
|
|
394
|
+
value.resource.type ? resourceKey(value.resource) : ''
|
|
395
|
+
}
|
|
396
|
+
resources={options}
|
|
397
|
+
scopes={controls}
|
|
398
|
+
existing={!creating}
|
|
399
|
+
readOnly={readOnly}
|
|
400
|
+
keyError={
|
|
401
|
+
submitted && invalidKey
|
|
402
|
+
? t('settings.defaultAccess.keyRequired')
|
|
403
|
+
: fieldErrors.key
|
|
404
|
+
}
|
|
405
|
+
resourceError={
|
|
406
|
+
submitted && !resource
|
|
407
|
+
? t('settings.defaultAccess.resourceRequired')
|
|
408
|
+
: fieldErrors.resource
|
|
409
|
+
}
|
|
410
|
+
labels={{
|
|
411
|
+
key: t('settings.key'),
|
|
412
|
+
resource: t('settings.defaultAccess.resource'),
|
|
413
|
+
chooseResource: t('settings.defaultAccess.chooseResource'),
|
|
414
|
+
action: t('settings.defaultAccess.action'),
|
|
415
|
+
scope: t('settings.defaultAccess.scope'),
|
|
416
|
+
noScopes: t('settings.defaultAccess.chooseResource'),
|
|
417
|
+
}}
|
|
418
|
+
onKeyChange={(key) => {
|
|
419
|
+
setDraft({ ...value, key });
|
|
420
|
+
setFieldErrors((old) => {
|
|
421
|
+
const { key: _key, ...next } = old;
|
|
422
|
+
return next;
|
|
423
|
+
});
|
|
424
|
+
}}
|
|
425
|
+
onResourceChange={(selected) => {
|
|
426
|
+
const resource = resources.find(
|
|
427
|
+
(entry) => resourceKey(entry) === selected,
|
|
428
|
+
);
|
|
429
|
+
if (resource) {
|
|
430
|
+
setDraft({
|
|
431
|
+
...value,
|
|
432
|
+
resource: { type: resource.type, id: resource.id },
|
|
433
|
+
actions: [],
|
|
434
|
+
});
|
|
435
|
+
setEditing(undefined);
|
|
436
|
+
setFieldErrors({});
|
|
437
|
+
setScopeErrors({});
|
|
438
|
+
}
|
|
439
|
+
}}
|
|
440
|
+
onScopeChange={(id, next) => {
|
|
441
|
+
const scope = scopes.find((scope) => scope.id === id);
|
|
442
|
+
if (!scope) return;
|
|
443
|
+
setScopeErrors((old) => ({ ...old, [id]: '' }));
|
|
444
|
+
const selection = next
|
|
445
|
+
? (JSON.parse(next) as ScopeSelection)
|
|
446
|
+
: undefined;
|
|
447
|
+
if (
|
|
448
|
+
selection?.type === 'recordAccess' &&
|
|
449
|
+
selection.key === 'customFilter' &&
|
|
450
|
+
!('params' in selection)
|
|
451
|
+
)
|
|
452
|
+
setEditing(id);
|
|
453
|
+
else setDraft(replaceRuleScope(value, scope, selection));
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
<div className='flex justify-end gap-2 border-t pt-4'>
|
|
457
|
+
<Button
|
|
458
|
+
type='button'
|
|
459
|
+
variant='outline'
|
|
460
|
+
disabled={busy}
|
|
461
|
+
onClick={() => {
|
|
462
|
+
void Promise.resolve(leave.guard()).then((allowed) => {
|
|
463
|
+
if (allowed) close();
|
|
464
|
+
});
|
|
465
|
+
}}
|
|
466
|
+
>
|
|
467
|
+
{t('settings.cancel')}
|
|
468
|
+
</Button>
|
|
469
|
+
{canWrite && (
|
|
470
|
+
<Button
|
|
471
|
+
type='submit'
|
|
472
|
+
disabled={busy || !dirty || !supported}
|
|
473
|
+
>
|
|
474
|
+
{t(busy ? 'settings.saving' : 'settings.save')}
|
|
475
|
+
</Button>
|
|
476
|
+
)}
|
|
477
|
+
</div>
|
|
478
|
+
</form>
|
|
479
|
+
)}
|
|
480
|
+
{!creating && !initial && (
|
|
481
|
+
<Button variant='outline' onClick={close}>
|
|
482
|
+
{t('settings.defaultAccess.back')}
|
|
483
|
+
</Button>
|
|
484
|
+
)}
|
|
485
|
+
</>
|
|
486
|
+
)
|
|
487
|
+
)}
|
|
488
|
+
{leave.dialog}
|
|
489
|
+
</PageContainer>
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
export default function DefaultAccessForm(): ReactElement {
|
|
493
|
+
const { key } = useParams();
|
|
494
|
+
return (
|
|
495
|
+
<RouteChildPage>
|
|
496
|
+
<DefaultAccessFormBody key={key ?? 'create'} />
|
|
497
|
+
</RouteChildPage>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthorizationOptionsResponse,
|
|
3
|
+
LocalizedText,
|
|
4
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
5
|
+
import type { CatalogResource } from './permissions-model.js';
|
|
6
|
+
|
|
7
|
+
export type ScopeSelection =
|
|
8
|
+
| { readonly type: 'all' }
|
|
9
|
+
| { readonly type: 'records'; readonly ids: readonly string[] }
|
|
10
|
+
| {
|
|
11
|
+
readonly type: 'recordAccess';
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly params?: unknown;
|
|
14
|
+
};
|
|
15
|
+
export interface DefaultAccessAction {
|
|
16
|
+
readonly action: string;
|
|
17
|
+
readonly scopeKey?: string;
|
|
18
|
+
readonly selection: ScopeSelection;
|
|
19
|
+
}
|
|
20
|
+
export interface DefaultAccessRule {
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly resource: { readonly type: string; readonly id: string };
|
|
23
|
+
readonly actions: readonly DefaultAccessAction[];
|
|
24
|
+
}
|
|
25
|
+
export interface RuleScope {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly action: string;
|
|
28
|
+
readonly scopeKey?: string;
|
|
29
|
+
readonly title: LocalizedText;
|
|
30
|
+
readonly actionTitle: LocalizedText;
|
|
31
|
+
readonly collection: string;
|
|
32
|
+
readonly fields: readonly string[];
|
|
33
|
+
readonly recordAccess: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
export const resourceKey = (resource: DefaultAccessRule['resource']): string =>
|
|
36
|
+
JSON.stringify([resource.type, resource.id]);
|
|
37
|
+
export const actionKey = (
|
|
38
|
+
entry: Pick<DefaultAccessAction, 'action' | 'scopeKey'>,
|
|
39
|
+
): string => JSON.stringify([entry.action, entry.scopeKey ?? null]);
|
|
40
|
+
export function ruleResources(
|
|
41
|
+
catalog: AuthorizationOptionsResponse,
|
|
42
|
+
): readonly CatalogResource[] {
|
|
43
|
+
const resources = catalog.sections
|
|
44
|
+
.flatMap((section) => section.subsections.flatMap((sub) => sub.resources))
|
|
45
|
+
.filter((resource) =>
|
|
46
|
+
Object.values(resource.dataScopes ?? {}).some((scopes) => scopes.length),
|
|
47
|
+
);
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
return [
|
|
50
|
+
...resources,
|
|
51
|
+
...catalog.collections.map((collection) => ({
|
|
52
|
+
type: 'database.collection',
|
|
53
|
+
id: collection.name,
|
|
54
|
+
title: collection.name,
|
|
55
|
+
actions: ['read', 'update', 'delete'].map((name) => ({
|
|
56
|
+
name,
|
|
57
|
+
title: {
|
|
58
|
+
key: `options.actions.${name}`,
|
|
59
|
+
ns: '@nocobase/app-plugin-authorization',
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
})),
|
|
63
|
+
].filter((resource) => {
|
|
64
|
+
const key = resourceKey(resource);
|
|
65
|
+
if (seen.has(key)) return false;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
return true;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export function ruleScopes(
|
|
71
|
+
resource: CatalogResource | undefined,
|
|
72
|
+
catalog: AuthorizationOptionsResponse,
|
|
73
|
+
): readonly RuleScope[] {
|
|
74
|
+
if (!resource) return [];
|
|
75
|
+
return resource.actions.flatMap<RuleScope>((action) =>
|
|
76
|
+
resource.type === 'database.collection'
|
|
77
|
+
? [
|
|
78
|
+
{
|
|
79
|
+
id: actionKey({ action: action.name }),
|
|
80
|
+
action: action.name,
|
|
81
|
+
actionTitle: action.title,
|
|
82
|
+
title: resource.title,
|
|
83
|
+
collection: resource.id,
|
|
84
|
+
fields:
|
|
85
|
+
catalog.collections.find((entry) => entry.name === resource.id)
|
|
86
|
+
?.fields ?? [],
|
|
87
|
+
recordAccess: catalog.recordAccess
|
|
88
|
+
.filter(
|
|
89
|
+
(entry) =>
|
|
90
|
+
entry.collections.includes('*') ||
|
|
91
|
+
entry.collections.includes(resource.id),
|
|
92
|
+
)
|
|
93
|
+
.map((entry) => entry.key),
|
|
94
|
+
},
|
|
95
|
+
]
|
|
96
|
+
: (resource.dataScopes?.[action.name] ?? []).map((scope) => ({
|
|
97
|
+
id: actionKey({ action: action.name, scopeKey: scope.key }),
|
|
98
|
+
action: action.name,
|
|
99
|
+
scopeKey: scope.key,
|
|
100
|
+
actionTitle: action.title,
|
|
101
|
+
title: scope.title,
|
|
102
|
+
collection: scope.collection,
|
|
103
|
+
fields: scope.fields,
|
|
104
|
+
recordAccess: scope.recordAccess,
|
|
105
|
+
})),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
export function replaceRuleScope(
|
|
109
|
+
rule: DefaultAccessRule,
|
|
110
|
+
scope: RuleScope,
|
|
111
|
+
selection: ScopeSelection | undefined,
|
|
112
|
+
): DefaultAccessRule {
|
|
113
|
+
const existing = rule.actions.find((entry) => actionKey(entry) === scope.id);
|
|
114
|
+
if (JSON.stringify(existing?.selection) === JSON.stringify(selection))
|
|
115
|
+
return rule;
|
|
116
|
+
const replacement = selection
|
|
117
|
+
? {
|
|
118
|
+
action: scope.action,
|
|
119
|
+
...(scope.scopeKey === undefined ? {} : { scopeKey: scope.scopeKey }),
|
|
120
|
+
selection,
|
|
121
|
+
}
|
|
122
|
+
: undefined;
|
|
123
|
+
const actions = rule.actions.flatMap((entry) =>
|
|
124
|
+
actionKey(entry) === scope.id
|
|
125
|
+
? replacement
|
|
126
|
+
? [replacement]
|
|
127
|
+
: []
|
|
128
|
+
: [entry],
|
|
129
|
+
);
|
|
130
|
+
if (!existing && replacement) actions.push(replacement);
|
|
131
|
+
return { ...rule, actions };
|
|
132
|
+
}
|
|
133
|
+
/** Unknown catalog entries must not be silently rewritten by a whole-actions PATCH. */
|
|
134
|
+
export function editableRule(
|
|
135
|
+
rule: DefaultAccessRule,
|
|
136
|
+
scopes: readonly RuleScope[],
|
|
137
|
+
catalog: AuthorizationOptionsResponse,
|
|
138
|
+
): boolean {
|
|
139
|
+
return (
|
|
140
|
+
scopes.length > 0 &&
|
|
141
|
+
rule.actions.every((entry) => {
|
|
142
|
+
const scope = scopes.find((scope) => scope.id === actionKey(entry));
|
|
143
|
+
const selection = entry.selection;
|
|
144
|
+
return (
|
|
145
|
+
scope !== undefined &&
|
|
146
|
+
(selection.type !== 'recordAccess' ||
|
|
147
|
+
(scope.recordAccess.includes(selection.key) &&
|
|
148
|
+
catalog.recordAccess.some(
|
|
149
|
+
(preset) => preset.key === selection.key,
|
|
150
|
+
)))
|
|
151
|
+
);
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
}
|