@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.
Files changed (38) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.MD +3 -1
  3. package/cli/plugins.ts +2 -1
  4. package/client/components/default-access-editor.tsx +183 -0
  5. package/client/components/permission-editor.tsx +216 -150
  6. package/client/components/role-list.tsx +85 -0
  7. package/client/lib/navigation-transform.ts +29 -1
  8. package/client/locales/system/settings-en-US.ts +74 -2
  9. package/client/locales/system/settings-zh-CN.ts +66 -2
  10. package/client/pages/settings/default-access-form.tsx +499 -0
  11. package/client/pages/settings/default-access-model.ts +154 -0
  12. package/client/pages/settings/default-access.tsx +407 -0
  13. package/client/pages/settings/permission-filters.tsx +43 -37
  14. package/client/pages/settings/permission-management-page.tsx +105 -0
  15. package/client/pages/settings/permissions-model.ts +37 -1
  16. package/client/pages/settings/role-form.tsx +46 -18
  17. package/client/pages/settings/roles.tsx +26 -43
  18. package/client/pages/settings/routes.ts +78 -19
  19. package/client/pages/settings/shared.tsx +3 -0
  20. package/client/pages/settings/users.tsx +1 -1
  21. package/client/plugins.ts +2 -0
  22. package/client/routing/route-navigation.ts +21 -1
  23. package/package.json +25 -23
  24. package/server/plugins.ts +2 -0
  25. package/tests/components/default-access.test.tsx +554 -0
  26. package/tests/components/page-harness.test.tsx +132 -112
  27. package/tests/components/permission-editor.test.tsx +233 -73
  28. package/tests/components/permission-management-tabs.test.tsx +128 -0
  29. package/tests/components/role-list.test.tsx +136 -0
  30. package/tests/components/roles.test.tsx +85 -0
  31. package/tests/components/user-permissions.test.tsx +12 -0
  32. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  33. package/tests/logic/client-routes.test.ts +8 -0
  34. package/tests/logic/default-access-model.test.ts +167 -0
  35. package/tests/logic/foundation-integration.test.ts +62 -0
  36. package/tests/logic/permission-management-routes.test.ts +64 -0
  37. package/tests/logic/settings-permissions.test.ts +89 -0
  38. 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
+ }