@nocobase/app-template-default 1.0.0-beta.72 → 1.0.0-beta.74

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 (33) hide show
  1. package/AGENTS.md +1 -1
  2. package/README.MD +2 -0
  3. package/client/components/default-access-editor.tsx +183 -0
  4. package/client/components/permission-editor.tsx +216 -150
  5. package/client/components/role-list.tsx +61 -0
  6. package/client/lib/navigation-transform.ts +28 -1
  7. package/client/locales/system/settings-en-US.ts +73 -2
  8. package/client/locales/system/settings-zh-CN.ts +65 -2
  9. package/client/pages/settings/default-access-form.tsx +499 -0
  10. package/client/pages/settings/default-access-model.ts +154 -0
  11. package/client/pages/settings/default-access.tsx +407 -0
  12. package/client/pages/settings/permission-filters.tsx +43 -37
  13. package/client/pages/settings/permission-management-page.tsx +105 -0
  14. package/client/pages/settings/permissions-model.ts +37 -1
  15. package/client/pages/settings/role-form.tsx +46 -18
  16. package/client/pages/settings/roles.tsx +23 -43
  17. package/client/pages/settings/routes.ts +78 -19
  18. package/client/pages/settings/shared.tsx +3 -0
  19. package/client/pages/settings/users.tsx +1 -1
  20. package/client/routing/route-navigation.ts +21 -1
  21. package/package.json +22 -21
  22. package/tests/components/default-access.test.tsx +554 -0
  23. package/tests/components/page-harness.test.tsx +132 -112
  24. package/tests/components/permission-editor.test.tsx +233 -73
  25. package/tests/components/permission-management-tabs.test.tsx +128 -0
  26. package/tests/components/role-list.test.tsx +71 -0
  27. package/tests/components/roles.test.tsx +78 -0
  28. package/tests/components/user-permissions.test.tsx +12 -0
  29. package/tests/logic/client-routes.test.ts +8 -0
  30. package/tests/logic/default-access-model.test.ts +167 -0
  31. package/tests/logic/permission-management-routes.test.ts +64 -0
  32. package/tests/logic/settings-permissions.test.ts +89 -0
  33. package/vitest.config.ts +7 -6
@@ -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
+ }
@@ -0,0 +1,407 @@
1
+ import { 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 { Link, Outlet, useLocation, useSearchParams } from 'react-router';
10
+ import { ListFilterIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react';
11
+ import type { ColumnDef } from '@tanstack/react-table';
12
+ import { Alert, AlertDescription } from '#components/ui/alert';
13
+ import { PermissionManagementPage } from './permission-management-page.js';
14
+ import { DataTable, DataTableColumnHeader } from '#components/data-table';
15
+ import { Button } from '#components/ui/button';
16
+ import { Input } from '#components/ui/input';
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from '#components/ui/dropdown-menu';
24
+ import {
25
+ Empty,
26
+ EmptyMedia,
27
+ EmptyTitle,
28
+ EmptyDescription,
29
+ EmptyContent,
30
+ } from '#components/ui/empty';
31
+ import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
32
+ import { useSettingsData } from './hooks.js';
33
+ import {
34
+ actionKey,
35
+ resourceKey,
36
+ ruleResources,
37
+ ruleScopes,
38
+ type DefaultAccessRule,
39
+ } from './default-access-model.js';
40
+
41
+ export default function DefaultAccessPage(): ReactElement {
42
+ const { t, i18n } = useTranslation();
43
+ const api = useApiClient();
44
+ const toaster = useToaster();
45
+ const location = useLocation();
46
+ const [params, setParams] = useSearchParams();
47
+ const search = params.get('q') ?? '';
48
+ const searchRef = useRef<HTMLInputElement>(null);
49
+ const rules = useSettingsData<{ data: readonly DefaultAccessRule[] }>(
50
+ 'authorization/defaultAccess',
51
+ );
52
+ const catalog = useSettingsData<{ data: AuthorizationOptionsResponse }>(
53
+ 'authorization/defaultAccess/options',
54
+ );
55
+ const canCreate = useCan({
56
+ resource: { type: 'settings', id: 'authorization.default-access' },
57
+ action: 'create',
58
+ }).can;
59
+ const canDelete = useCan({
60
+ resource: { type: 'settings', id: 'authorization.default-access' },
61
+ action: 'delete',
62
+ }).can;
63
+ const [removing, setRemoving] = useState<DefaultAccessRule>();
64
+ const [busy, setBusy] = useState(false);
65
+ const [error, setError] = useState<unknown>();
66
+ const [conflict, setConflict] = useState(false);
67
+ const text = (value: LocalizedText): string =>
68
+ typeof value === 'string'
69
+ ? value
70
+ : t(value.key, {
71
+ ns: value.ns,
72
+ defaultValue: value.defaultValue ?? value.key,
73
+ });
74
+ const resources = catalog.data ? ruleResources(catalog.data.data) : [];
75
+ const name = (rule: DefaultAccessRule): string =>
76
+ text(
77
+ resources.find(
78
+ (resource) => resourceKey(resource) === resourceKey(rule.resource),
79
+ )?.title ?? rule.resource.id,
80
+ );
81
+ const summary = (rule: DefaultAccessRule): string => {
82
+ const resource = resources.find(
83
+ (resource) => resourceKey(resource) === resourceKey(rule.resource),
84
+ );
85
+ const scopes = catalog.data ? ruleScopes(resource, catalog.data.data) : [];
86
+ return (
87
+ rule.actions
88
+ .map((entry) => {
89
+ const scope = scopes.find((scope) => scope.id === actionKey(entry));
90
+ const label =
91
+ entry.selection.type === 'all'
92
+ ? t('settings.defaultAccess.all')
93
+ : entry.selection.type === 'records'
94
+ ? t('settings.defaultAccess.records', {
95
+ count: entry.selection.ids.length,
96
+ })
97
+ : text(
98
+ catalog.data?.data.recordAccess.find(
99
+ (item) =>
100
+ entry.selection.type === 'recordAccess' &&
101
+ item.key === entry.selection.key,
102
+ )?.title ?? entry.selection.key,
103
+ );
104
+ return `${text(scope?.actionTitle ?? entry.action)}${scope ? ` · ${text(scope.title)}` : entry.scopeKey ? ` · ${entry.scopeKey}` : ''}: ${label}`;
105
+ })
106
+ .join('; ') || t('settings.defaultAccess.none')
107
+ );
108
+ };
109
+ const columns: ColumnDef<DefaultAccessRule>[] = [
110
+ {
111
+ id: 'resource',
112
+ accessorFn: name,
113
+ sortingFn: (a, b) =>
114
+ name(a.original).localeCompare(name(b.original), i18n.language),
115
+ header: ({ column }) => (
116
+ <DataTableColumnHeader
117
+ column={column}
118
+ title={t('settings.defaultAccess.resource')}
119
+ />
120
+ ),
121
+ cell: ({ row }) => (
122
+ <Link
123
+ className='text-foreground underline-offset-4 hover:underline'
124
+ to={{
125
+ pathname: `${encodeURIComponent(row.original.key)}/edit`,
126
+ search: location.search,
127
+ }}
128
+ >
129
+ {name(row.original)}
130
+ </Link>
131
+ ),
132
+ },
133
+ {
134
+ accessorKey: 'key',
135
+ header: t('settings.key'),
136
+ enableSorting: false,
137
+ cell: ({ row }) => (
138
+ <span
139
+ className='block max-w-48 truncate text-muted-foreground'
140
+ title={row.original.key}
141
+ >
142
+ {row.original.key}
143
+ </span>
144
+ ),
145
+ },
146
+ {
147
+ id: 'scopes',
148
+ header: t('settings.defaultAccess.scopes'),
149
+ enableSorting: false,
150
+ cell: ({ row }) => (
151
+ <span className='block max-w-96 truncate' title={summary(row.original)}>
152
+ {summary(row.original)}
153
+ </span>
154
+ ),
155
+ },
156
+ {
157
+ id: 'actions',
158
+ enableSorting: false,
159
+ cell: ({ row }) => (
160
+ <DropdownMenu>
161
+ <DropdownMenuTrigger
162
+ render={
163
+ <Button
164
+ variant='ghost'
165
+ size='icon-sm'
166
+ aria-label={t('settings.actionsFor', {
167
+ name: name(row.original),
168
+ })}
169
+ >
170
+ <MoreHorizontalIcon />
171
+ </Button>
172
+ }
173
+ />
174
+ <DropdownMenuContent align='end' className='w-auto min-w-40'>
175
+ <DropdownMenuItem
176
+ render={
177
+ <Link
178
+ to={{
179
+ pathname: `${encodeURIComponent(row.original.key)}/edit`,
180
+ search: location.search,
181
+ }}
182
+ />
183
+ }
184
+ >
185
+ {t('settings.defaultAccess.open')}
186
+ </DropdownMenuItem>
187
+ {canDelete && (
188
+ <>
189
+ <DropdownMenuSeparator />
190
+ <DropdownMenuItem
191
+ variant='destructive'
192
+ onClick={() => {
193
+ setError(undefined);
194
+ setRemoving(row.original);
195
+ }}
196
+ >
197
+ {t('settings.defaultAccess.delete')}
198
+ </DropdownMenuItem>
199
+ </>
200
+ )}
201
+ </DropdownMenuContent>
202
+ </DropdownMenu>
203
+ ),
204
+ },
205
+ ];
206
+ const rows = (rules.data?.data ?? [])
207
+ .filter((rule) =>
208
+ `${name(rule)} ${rule.key}`
209
+ .toLocaleLowerCase()
210
+ .includes(search.toLocaleLowerCase()),
211
+ )
212
+ .sort((a, b) => name(a).localeCompare(name(b), i18n.language));
213
+ const eligible = resources.some(
214
+ (resource) =>
215
+ !rules.data?.data.some(
216
+ (rule) => resourceKey(rule.resource) === resourceKey(resource),
217
+ ),
218
+ );
219
+ const create =
220
+ canCreate && rules.data && catalog.data ? (
221
+ eligible ? (
222
+ <Button
223
+ nativeButton={false}
224
+ render={<Link to={{ pathname: 'create', search: location.search }} />}
225
+ >
226
+ <PlusIcon />
227
+ {t('settings.defaultAccess.new')}
228
+ </Button>
229
+ ) : (
230
+ <div className='flex flex-wrap items-center gap-2'>
231
+ <span className='text-sm text-muted-foreground'>
232
+ {t('settings.defaultAccess.noResourcesShort')}
233
+ </span>
234
+ <Button disabled>
235
+ <PlusIcon />
236
+ {t('settings.defaultAccess.new')}
237
+ </Button>
238
+ </div>
239
+ )
240
+ ) : null;
241
+ const remove = async () => {
242
+ if (!removing || busy || !canDelete) return;
243
+ setBusy(true);
244
+ setError(undefined);
245
+ try {
246
+ const latest = await api.request<{ data: readonly DefaultAccessRule[] }>({
247
+ path: 'authorization/defaultAccess',
248
+ });
249
+ if (
250
+ JSON.stringify(
251
+ latest.data.find((rule) => rule.key === removing.key),
252
+ ) !== JSON.stringify(removing)
253
+ ) {
254
+ rules.reload();
255
+ setRemoving(undefined);
256
+ setConflict(true);
257
+ requestAnimationFrame(() => searchRef.current?.focus());
258
+ return;
259
+ }
260
+ await api.request({
261
+ path: `authorization/defaultAccess/${encodeURIComponent(removing.key)}`,
262
+ method: 'DELETE',
263
+ });
264
+ toaster.show({
265
+ type: 'success',
266
+ title: t('settings.defaultAccess.deleted', { name: name(removing) }),
267
+ });
268
+ rules.replaceData({
269
+ data: (rules.data?.data ?? []).filter(
270
+ (rule) => rule.key !== removing.key,
271
+ ),
272
+ });
273
+ setRemoving(undefined);
274
+ rules.reload();
275
+ requestAnimationFrame(() => searchRef.current?.focus());
276
+ } catch (next) {
277
+ setError(next);
278
+ } finally {
279
+ setBusy(false);
280
+ }
281
+ };
282
+ return (
283
+ <>
284
+ <PermissionManagementPage
285
+ tab='default-access'
286
+ actions={
287
+ rules.data && (rules.data.data.length || search) ? create : undefined
288
+ }
289
+ >
290
+ <div className='flex flex-wrap items-center gap-2'>
291
+ <Input
292
+ ref={searchRef}
293
+ className='sm:max-w-xs'
294
+ value={search}
295
+ placeholder={t('settings.defaultAccess.search')}
296
+ aria-label={t('settings.defaultAccess.search')}
297
+ onChange={(event) =>
298
+ setParams(event.target.value ? { q: event.target.value } : {}, {
299
+ replace: true,
300
+ })
301
+ }
302
+ />
303
+ {search && (
304
+ <Button variant='ghost' onClick={() => setParams({})}>
305
+ {t('settings.clear')}
306
+ </Button>
307
+ )}
308
+ </div>
309
+ {conflict && (
310
+ <Alert variant='destructive'>
311
+ <AlertDescription>
312
+ {t('settings.defaultAccess.deleteChanged')}
313
+ </AlertDescription>
314
+ </Alert>
315
+ )}
316
+ <SettingsError
317
+ error={rules.error || catalog.error || (!removing && error)}
318
+ retry={() => {
319
+ rules.reload();
320
+ catalog.reload();
321
+ setError(undefined);
322
+ }}
323
+ />
324
+ {(rules.loading && !rules.data) ||
325
+ (catalog.loading && !catalog.data) ? (
326
+ <SettingsLoading />
327
+ ) : (
328
+ !rules.error &&
329
+ !catalog.error &&
330
+ (rows.length ? (
331
+ <DataTable
332
+ columns={columns}
333
+ data={rows}
334
+ getRowId={(rule) => rule.key}
335
+ showSelectedCount={false}
336
+ pageSize={20}
337
+ />
338
+ ) : (
339
+ <Empty className='min-h-48 border border-dashed'>
340
+ <EmptyMedia variant='icon'>
341
+ <ListFilterIcon />
342
+ </EmptyMedia>
343
+ <EmptyTitle>
344
+ {t(
345
+ search
346
+ ? 'settings.noResults'
347
+ : 'settings.defaultAccess.empty',
348
+ )}
349
+ </EmptyTitle>
350
+ <EmptyDescription>
351
+ {t(
352
+ search
353
+ ? 'settings.clearHint'
354
+ : canCreate
355
+ ? 'settings.defaultAccess.emptyHint'
356
+ : 'settings.defaultAccess.readOnlyEmpty',
357
+ )}
358
+ </EmptyDescription>
359
+ <EmptyContent>
360
+ {search ? (
361
+ <Button variant='outline' onClick={() => setParams({})}>
362
+ {t('settings.clear')}
363
+ </Button>
364
+ ) : (
365
+ create
366
+ )}
367
+ </EmptyContent>
368
+ </Empty>
369
+ ))
370
+ )}
371
+ <ConfirmAction
372
+ error={<SettingsError error={error} />}
373
+ open={Boolean(removing)}
374
+ title={t('settings.defaultAccess.deleteTitle', {
375
+ name: removing ? name(removing) : '',
376
+ })}
377
+ description={t('settings.defaultAccess.deleteHint')}
378
+ confirmLabel={t('settings.defaultAccess.delete')}
379
+ busy={busy}
380
+ onCancel={() => {
381
+ setRemoving(undefined);
382
+ setError(undefined);
383
+ }}
384
+ onConfirm={() => {
385
+ void remove();
386
+ }}
387
+ />
388
+ </PermissionManagementPage>
389
+ <Outlet
390
+ context={{
391
+ reload: rules.reload,
392
+ saved: (updated: DefaultAccessRule) => {
393
+ rules.replaceData({
394
+ data: [
395
+ ...(rules.data?.data ?? []).filter(
396
+ (rule) => rule.key !== updated.key,
397
+ ),
398
+ updated,
399
+ ],
400
+ });
401
+ rules.reload();
402
+ },
403
+ }}
404
+ />
405
+ </>
406
+ );
407
+ }
@@ -19,43 +19,7 @@ export function configurePermissionScopes(
19
19
  onApply: (id: string, value: unknown) => void,
20
20
  disabled: boolean,
21
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
- };
22
+ const labels = permissionFilterLabels(text);
59
23
  const resources = catalog.sections.flatMap((section) =>
60
24
  section.subsections.flatMap((sub) => sub.resources),
61
25
  );
@@ -117,3 +81,45 @@ export function configurePermissionScopes(
117
81
  })),
118
82
  }));
119
83
  }
84
+
85
+ export function permissionFilterLabels(
86
+ text: (key: string, fallback?: string) => string,
87
+ ): PermissionFilterLabels {
88
+ return {
89
+ limit: text('settings.permissionFilter.limit'),
90
+ configure: text('settings.permissionFilter.configure'),
91
+ title: text('settings.permissionFilter.title'),
92
+ description: text('settings.permissionFilter.description'),
93
+ all: text('settings.permissionFilter.all'),
94
+ any: text('settings.permissionFilter.any'),
95
+ field: text('settings.permissionFilter.field'),
96
+ operator: text('settings.permissionFilter.operator'),
97
+ valueType: text('settings.permissionFilter.valueType'),
98
+ value: text('settings.permissionFilter.value'),
99
+ text: text('settings.permissionFilter.text'),
100
+ number: text('settings.permissionFilter.number'),
101
+ boolean: text('settings.permissionFilter.boolean'),
102
+ true: text('settings.permissionFilter.true'),
103
+ false: text('settings.permissionFilter.false'),
104
+ add: text('settings.permissionFilter.add'),
105
+ remove: text('settings.permissionFilter.remove'),
106
+ cancel: text('settings.permissionFilter.cancel'),
107
+ apply: text('settings.permissionFilter.apply'),
108
+ invalid: text('settings.permissionFilter.invalid'),
109
+ advanced: text('settings.permissionFilter.advanced'),
110
+ reconfigure: text('settings.permissionFilter.reconfigure'),
111
+ operators: Object.fromEntries(
112
+ [
113
+ '$eq',
114
+ '$ne',
115
+ '$includes',
116
+ '$gt',
117
+ '$gte',
118
+ '$lt',
119
+ '$lte',
120
+ '$empty',
121
+ '$notEmpty',
122
+ ].map((key) => [key, text(`settings.permissionFilter.operators.${key}`)]),
123
+ ),
124
+ };
125
+ }