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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.agents/rules/components.md +1 -1
  2. package/.agents/rules/shell.md +3 -1
  3. package/AGENTS.md +5 -3
  4. package/Dockerfile.dockerignore +0 -1
  5. package/README.MD +3 -1
  6. package/client/app.ts +15 -0
  7. package/client/components/data-table/column-header.tsx +91 -0
  8. package/client/components/data-table/data-table.tsx +216 -0
  9. package/client/components/data-table/index.ts +13 -0
  10. package/client/components/data-table/pagination.tsx +165 -0
  11. package/client/components/data-table/view-options.tsx +77 -0
  12. package/client/components/permission-editor.tsx +589 -0
  13. package/client/components/permission-filter.tsx +455 -0
  14. package/client/components/route-overlay.tsx +26 -13
  15. package/client/components/ui/checkbox.tsx +25 -0
  16. package/client/components/ui/sidebar.tsx +1 -4
  17. package/client/components/ui/switch.tsx +30 -0
  18. package/client/components/ui/table.tsx +113 -0
  19. package/client/layouts/components/app-sidebar.tsx +1 -1
  20. package/client/layouts/use-sidebar-preference.ts +62 -42
  21. package/client/locales/system/en-US.ts +7 -0
  22. package/client/locales/system/settings-en-US.ts +141 -0
  23. package/client/locales/system/settings-zh-CN.ts +135 -0
  24. package/client/locales/system/zh-CN.ts +7 -0
  25. package/client/pages/auth/auth-page.css +15 -0
  26. package/client/pages/auth/login.tsx +1 -1
  27. package/client/pages/auth/shared.tsx +3 -1
  28. package/client/pages/settings/hooks.tsx +137 -0
  29. package/client/pages/settings/permission-filters.tsx +119 -0
  30. package/client/pages/settings/permissions-model.ts +260 -0
  31. package/client/pages/settings/role-form.tsx +426 -0
  32. package/client/pages/settings/roles.tsx +163 -0
  33. package/client/pages/settings/routes.ts +79 -0
  34. package/client/pages/settings/shared.tsx +119 -0
  35. package/client/pages/settings/user-detail.tsx +268 -0
  36. package/client/pages/settings/user-form.tsx +427 -0
  37. package/client/pages/settings/users.tsx +451 -0
  38. package/client/routing/app-router.tsx +4 -1
  39. package/client/routing/session-preparation.tsx +65 -0
  40. package/client/styles.css +4 -7
  41. package/package.json +24 -23
  42. package/tests/components/app-layout-routes.test.tsx +14 -7
  43. package/tests/components/data-table.test.tsx +127 -0
  44. package/tests/components/layout-i18n.test.tsx +8 -1
  45. package/tests/components/navigation-menu.test.tsx +24 -14
  46. package/tests/components/permission-editor.test.tsx +477 -0
  47. package/tests/components/permission-filter-binding.test.tsx +150 -0
  48. package/tests/components/permission-filter.test.tsx +202 -0
  49. package/tests/components/settings.test.tsx +294 -0
  50. package/tests/components/sidebar-permissions.test.tsx +12 -4
  51. package/tests/components/sidebar-preference.test.tsx +209 -14
  52. package/tests/components/user-permissions.test.tsx +142 -0
  53. package/tests/fixtures/theme-tokens.tsx +9 -1
  54. package/tests/logic/asset-url-build.test.ts +0 -1
  55. package/tests/logic/client-routes.test.ts +40 -0
  56. package/tests/logic/client-shell.test.tsx +10 -2
  57. package/tests/logic/config-example.test.ts +30 -0
  58. package/tests/logic/dev-client-watch.test.ts +8 -1
  59. package/tests/logic/route-overlay.test.tsx +42 -0
  60. package/tests/logic/session-preparation.test.tsx +223 -0
  61. package/tests/logic/settings-permissions.test.ts +168 -0
  62. package/vite.config.ts +0 -22
  63. package/client/agent-annotations-host.ts +0 -25
@@ -0,0 +1,426 @@
1
+ import {
2
+ useSettingsLeave,
3
+ useSettingsData,
4
+ useRolePermission,
5
+ } from './hooks.js';
6
+ import {
7
+ ApiClientError,
8
+ useClientApplication,
9
+ useToaster,
10
+ } from '@nocobase/app-client';
11
+ import { useTranslation } from '@nocobase/i18n/client';
12
+ import {
13
+ useAuthorizationClient,
14
+ type AuthorizationOptionsResponse,
15
+ type LocalizedText,
16
+ type PermissionGrant,
17
+ type PermissionSet,
18
+ } from '@nocobase/app-plugin-authorization/client';
19
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
20
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
21
+ import {
22
+ useLocation,
23
+ useNavigate,
24
+ useOutletContext,
25
+ useParams,
26
+ } from 'react-router';
27
+ import { RouteChildPage } from '#components/route-child-page';
28
+ import { BackButton } from '#components/back-button';
29
+ import { PageContainer } from '#components/page-container';
30
+ import { PageHeader } from '#components/page-header';
31
+ import { PermissionEditor } from '#components/permission-editor';
32
+ import { Button } from '#components/ui/button';
33
+ import { Input } from '#components/ui/input';
34
+ import { Label } from '#components/ui/label';
35
+ import { Badge } from '#components/ui/badge';
36
+ import { ConfirmAction, SettingsError, SettingsLoading } from './shared.js';
37
+ import { configurePermissionScopes } from './permission-filters.js';
38
+ import {
39
+ permissionSections,
40
+ unconfiguredFilter,
41
+ setPermissions,
42
+ setScope,
43
+ type CatalogResource,
44
+ } from './permissions-model.js';
45
+
46
+ function RoleEditor(): ReactElement {
47
+ const { key } = useParams();
48
+ const creating = !key;
49
+ const { t } = useTranslation();
50
+ const app = useClientApplication();
51
+ const authz = useAuthorizationClient();
52
+ const toaster = useToaster();
53
+ const navigate = useNavigate();
54
+ const location = useLocation();
55
+ const parent = useOutletContext<{ reload: () => void }>();
56
+ const request = useSettingsData<{ data: PermissionSet }>(
57
+ key
58
+ ? `authorization/permissionSets/${encodeURIComponent(key)}`
59
+ : 'authorization/permissionSets/options',
60
+ );
61
+ const missingRef = useRef<unknown>(undefined);
62
+ useEffect(() => {
63
+ if (
64
+ request.error instanceof ApiClientError &&
65
+ request.error.status === 404 &&
66
+ missingRef.current !== request.error
67
+ ) {
68
+ missingRef.current = request.error;
69
+ parent.reload();
70
+ }
71
+ }, [request.error, parent]);
72
+ const options = useSettingsData<{ data: AuthorizationOptionsResponse }>(
73
+ 'authorization/permissionSets/options',
74
+ );
75
+ const canCreate = useRolePermission('create');
76
+ const canUpdate = useRolePermission('update');
77
+ const [nameDraft, setNameDraft] = useState<string>();
78
+ const [keyDraft, setKeyDraft] = useState<string>();
79
+ const [grantsDraft, setGrantsDraft] = useState<readonly PermissionGrant[]>();
80
+ const [dirty, setDirty] = useState(false);
81
+ const [editingScope, setEditingScope] = useState<string>();
82
+ const [busy, setBusy] = useState(false);
83
+ const leave = useSettingsLeave(dirty, busy);
84
+ const [error, setError] = useState<unknown>();
85
+
86
+ const [reloadConfirm, setReloadConfirm] = useState(false);
87
+ const [submitted, setSubmitted] = useState(false);
88
+ const [keyConflict, setKeyConflict] = useState(false);
89
+ const text = (value: LocalizedText): string =>
90
+ typeof value === 'string'
91
+ ? value
92
+ : t(value.key, {
93
+ ns: value.ns,
94
+ defaultValue: value.defaultValue ?? value.key,
95
+ });
96
+ const initial = !creating ? request.data?.data : undefined;
97
+ const name = nameDraft ?? (initial ? text(initial.title ?? initial.key) : '');
98
+ const roleKey = keyDraft ?? initial?.key ?? '';
99
+ const grants = grantsDraft ?? initial?.grants ?? [];
100
+ const catalog = useMemo(() => {
101
+ if (!options.data) return undefined;
102
+ const source = options.data.data;
103
+ const pages: CatalogResource[] = [];
104
+ const collect = (routes: readonly AppClientRegisteredRoute[]) => {
105
+ for (const route of routes) {
106
+ const pageTitle = route.navigation?.title ?? route.breadcrumb?.title;
107
+ if (
108
+ route.componentLoader &&
109
+ typeof route.authz === 'object' &&
110
+ route.authz.resource.type === 'page'
111
+ )
112
+ pages.push({
113
+ type: 'page',
114
+ id: route.authz.resource.id,
115
+ title: pageTitle
116
+ ? { key: pageTitle, ns: route.packageName }
117
+ : route.id,
118
+ actions: [
119
+ {
120
+ name: 'access',
121
+ title: { key: 'settings.access' },
122
+ },
123
+ ],
124
+ });
125
+ collect(route.children ?? []);
126
+ }
127
+ };
128
+ collect(app.runtime.routes);
129
+ return {
130
+ ...source,
131
+ sections: [
132
+ {
133
+ name: 'users',
134
+ title: { key: 'settings.userPermissions' },
135
+ order: 0,
136
+ subsections: [
137
+ {
138
+ name: 'users.accounts',
139
+ title: { key: 'settings.users' },
140
+ resources: [
141
+ {
142
+ type: 'user',
143
+ id: '*',
144
+ title: { key: 'settings.users' },
145
+ actions: [
146
+ { name: 'read', title: { key: 'settings.readUsers' } },
147
+ { name: 'create', title: { key: 'settings.createUsers' } },
148
+ { name: 'update', title: { key: 'settings.updateUsers' } },
149
+ { name: 'invite', title: { key: 'settings.inviteUsers' } },
150
+ { name: 'delete', title: { key: 'settings.deleteUsers' } },
151
+ {
152
+ name: 'reset-password',
153
+ title: { key: 'settings.resetUserPassword' },
154
+ },
155
+ { name: 'disable', title: { key: 'settings.disable' } },
156
+ { name: 'enable', title: { key: 'settings.enable' } },
157
+ {
158
+ name: 'assign-role',
159
+ title: { key: 'settings.assignRole' },
160
+ },
161
+ {
162
+ name: 'revoke-sessions',
163
+ title: { key: 'settings.revokeSessions' },
164
+ },
165
+ ],
166
+ },
167
+ ],
168
+ },
169
+ ],
170
+ },
171
+ ...source.sections.map((section) => ({
172
+ ...section,
173
+ subsections: section.subsections.map((sub) => ({
174
+ ...sub,
175
+ resources:
176
+ sub.recordType?.type === 'page'
177
+ ? [...sub.resources, ...pages]
178
+ : sub.resources,
179
+ })),
180
+ })),
181
+ ],
182
+ };
183
+ }, [options.data, app]);
184
+ const allowed = creating
185
+ ? canCreate
186
+ : canUpdate &&
187
+ (!request.data?.data.protection ||
188
+ request.data.data.protection.allow.includes('update'));
189
+ const closeTo = { pathname: '/settings/roles', search: location.search };
190
+ const save = async (event: React.FormEvent<HTMLFormElement>) => {
191
+ event.preventDefault();
192
+ if (options.error || !catalog || (!creating && request.error)) return;
193
+ setSubmitted(true);
194
+ if (!name.trim() || !roleKey.trim()) {
195
+ event.currentTarget
196
+ .querySelector<HTMLInputElement>('input:invalid')
197
+ ?.focus();
198
+ return;
199
+ }
200
+ const incomplete = unconfiguredFilter(grants);
201
+ if (incomplete) {
202
+ setEditingScope(incomplete);
203
+ return;
204
+ }
205
+ setEditingScope(undefined);
206
+ setBusy(true);
207
+ setError(undefined);
208
+ setKeyConflict(false);
209
+ try {
210
+ const input = {
211
+ key: roleKey,
212
+ title:
213
+ !creating && initial && nameDraft === undefined
214
+ ? initial.title
215
+ : name.trim(),
216
+ grants,
217
+ ...(!creating && request.data?.data.revision
218
+ ? { expectedRevision: request.data.data.revision }
219
+ : {}),
220
+ };
221
+ if (creating) await authz.createPermissionSet(input);
222
+ else await authz.updatePermissionSet(key, input);
223
+ setDirty(false);
224
+ parent.reload();
225
+ toaster.show({ type: 'success', title: t('settings.saved', { name }) });
226
+ leave.allow();
227
+ void navigate(closeTo);
228
+ } catch (next) {
229
+ if (
230
+ creating &&
231
+ next instanceof Error &&
232
+ 'status' in next &&
233
+ next.status === 409
234
+ ) {
235
+ setKeyConflict(true);
236
+ document.getElementById('role-key')?.focus();
237
+ } else setError(next);
238
+ } finally {
239
+ setBusy(false);
240
+ }
241
+ };
242
+ const sections = catalog
243
+ ? configurePermissionScopes(
244
+ permissionSections(catalog, grants, text),
245
+ catalog,
246
+ grants,
247
+ (key, fallback) => t(key, { defaultValue: fallback ?? key }),
248
+ editingScope,
249
+ setEditingScope,
250
+ (id, value) => {
251
+ setGrantsDraft((current) => setScope(current ?? grants, id, value));
252
+ setDirty(true);
253
+ },
254
+ !allowed || busy,
255
+ )
256
+ : [];
257
+ return (
258
+ <RouteChildPage>
259
+ <PageContainer>
260
+ <BackButton to={closeTo}>{t('settings.roles')}</BackButton>
261
+ <PageHeader
262
+ title={t(creating ? 'settings.newRole' : 'settings.editRole')}
263
+ description={t('settings.rolesDescription')}
264
+ actions={
265
+ dirty ? (
266
+ <Badge variant='secondary'>{t('settings.unsaved')}</Badge>
267
+ ) : undefined
268
+ }
269
+ />
270
+ <SettingsError error={request.error} retry={request.reload} />
271
+ <SettingsError error={options.error} retry={options.reload} />
272
+ <SettingsError error={error} />
273
+ {error instanceof Error &&
274
+ 'status' in error &&
275
+ error.status === 409 && (
276
+ <Button variant='outline' onClick={() => setReloadConfirm(true)}>
277
+ {t('settings.reload')}
278
+ </Button>
279
+ )}
280
+ {(request.loading && !creating) || options.loading ? (
281
+ <SettingsLoading />
282
+ ) : (
283
+ !request.error &&
284
+ !options.error && (
285
+ <form
286
+ onSubmit={(event) => void save(event)}
287
+ noValidate
288
+ className='flex flex-col gap-6'
289
+ >
290
+ <div className='grid gap-4 sm:grid-cols-2'>
291
+ <div className='flex flex-col gap-2'>
292
+ <Label htmlFor='role-key'>{t('settings.key')} *</Label>
293
+ <Input
294
+ id='role-key'
295
+ required
296
+ value={roleKey}
297
+ disabled={!creating || !allowed || busy}
298
+ aria-invalid={keyConflict || (submitted && !roleKey.trim())}
299
+ onChange={(event) => {
300
+ setKeyConflict(false);
301
+ setKeyDraft(event.target.value);
302
+ setDirty(true);
303
+ }}
304
+ />
305
+ {(keyConflict || (submitted && !roleKey.trim())) && (
306
+ <p className='text-xs text-destructive'>
307
+ {t(
308
+ keyConflict
309
+ ? 'settings.keyConflict'
310
+ : 'settings.required',
311
+ )}
312
+ </p>
313
+ )}
314
+ </div>
315
+ <div className='flex flex-col gap-2'>
316
+ <Label htmlFor='role-name'>{t('settings.roleName')} *</Label>
317
+ <Input
318
+ id='role-name'
319
+ required
320
+ value={name}
321
+ disabled={!allowed || busy}
322
+ aria-invalid={submitted && !name.trim()}
323
+ onChange={(event) => {
324
+ setNameDraft(event.target.value);
325
+ setDirty(true);
326
+ }}
327
+ />
328
+ {submitted && !name.trim() && (
329
+ <p className='text-xs text-destructive'>
330
+ {t('settings.required')}
331
+ </p>
332
+ )}
333
+ </div>
334
+ </div>
335
+ {request.data?.data.protection && (
336
+ <p className='text-sm text-muted-foreground'>
337
+ {t('settings.protectedHint')}
338
+ </p>
339
+ )}
340
+ <PermissionEditor
341
+ sections={sections}
342
+ readOnly={!allowed || busy}
343
+ labels={{
344
+ selectAll: t('settings.selectAllPermissions'),
345
+ selectVisible: (name) =>
346
+ t('settings.selectVisiblePermissions', { name }),
347
+ levelFor: (name) => t('settings.levelFor', { name }),
348
+ search: t('settings.permissionSearch'),
349
+ enabledOnly: t('settings.enabledOnly'),
350
+ clear: t('settings.clear'),
351
+ empty: t('settings.noPermissions'),
352
+ noResults: t('settings.noResults'),
353
+ navigation: t('settings.permissionCategories'),
354
+ summary: (enabled, total) =>
355
+ t('settings.permissionSummary', { enabled, total }),
356
+ }}
357
+ onEnabledChange={(id, enabled) => {
358
+ if (!catalog) return;
359
+ setGrantsDraft((current) =>
360
+ setPermissions(current ?? grants, catalog, [
361
+ { id, enabled },
362
+ ]),
363
+ );
364
+ setDirty(true);
365
+ }}
366
+ onEnabledBatchChange={(changes) => {
367
+ if (!catalog) return;
368
+ setGrantsDraft((current) =>
369
+ setPermissions(current ?? grants, catalog, changes),
370
+ );
371
+ setDirty(true);
372
+ }}
373
+ onLevelChange={(id, value) => {
374
+ if (value === 'customFilter') {
375
+ setEditingScope(id);
376
+ return;
377
+ }
378
+ setGrantsDraft((current) =>
379
+ setScope(current ?? grants, id, value),
380
+ );
381
+ setDirty(true);
382
+ }}
383
+ />
384
+ <div className='sticky bottom-0 flex justify-end gap-2 border-t bg-background py-4'>
385
+ <Button
386
+ variant='outline'
387
+ disabled={busy}
388
+ onClick={() => void navigate(closeTo)}
389
+ >
390
+ {t('settings.cancel')}
391
+ </Button>
392
+ {allowed && (
393
+ <Button type='submit' disabled={busy || !dirty}>
394
+ {t(busy ? 'settings.saving' : 'settings.save')}
395
+ </Button>
396
+ )}
397
+ </div>
398
+ </form>
399
+ )
400
+ )}
401
+ {leave.dialog}
402
+ <ConfirmAction
403
+ open={reloadConfirm}
404
+ title={t('settings.discardTitle')}
405
+ description={t('settings.discardHint')}
406
+ busy={false}
407
+ onCancel={() => setReloadConfirm(false)}
408
+ onConfirm={() => {
409
+ setNameDraft(undefined);
410
+ setKeyDraft(undefined);
411
+ setGrantsDraft(undefined);
412
+ setDirty(false);
413
+ setError(undefined);
414
+ setReloadConfirm(false);
415
+ request.reload();
416
+ }}
417
+ />
418
+ </PageContainer>
419
+ </RouteChildPage>
420
+ );
421
+ }
422
+
423
+ export default function RoleForm(): ReactElement {
424
+ const { key } = useParams();
425
+ return <RoleEditor key={key ?? '$new'} />;
426
+ }
@@ -0,0 +1,163 @@
1
+ import { useSettingsData, useRolePermission } from './hooks.js';
2
+ import { useTranslation } from '@nocobase/i18n/client';
3
+ import type {
4
+ PermissionSet,
5
+ LocalizedText,
6
+ } from '@nocobase/app-plugin-authorization/client';
7
+ import { type ReactElement } from 'react';
8
+ import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
9
+ import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
+ import { PageContainer } from '#components/page-container';
11
+ import { PageHeader } from '#components/page-header';
12
+ import { Input } from '#components/ui/input';
13
+ import { Button } from '#components/ui/button';
14
+ import { Badge } from '#components/ui/badge';
15
+ import {
16
+ Empty,
17
+ EmptyTitle,
18
+ EmptyDescription,
19
+ EmptyContent,
20
+ EmptyMedia,
21
+ } from '#components/ui/empty';
22
+ import { SettingsError, SettingsLoading } from './shared.js';
23
+
24
+ export default function RolesPage(): ReactElement {
25
+ const { t, i18n } = useTranslation();
26
+ const [params, setParams] = useSearchParams();
27
+ const location = useLocation();
28
+ const search = params.get('q') ?? '';
29
+ const request = useSettingsData<{ data: readonly PermissionSet[] }>(
30
+ 'authorization/permissionSets',
31
+ );
32
+ const canCreate = useRolePermission('create');
33
+ const text = (value: LocalizedText): string =>
34
+ typeof value === 'string'
35
+ ? value
36
+ : t(value.key, {
37
+ ns: value.ns,
38
+ defaultValue: value.defaultValue ?? value.key,
39
+ });
40
+ const rows = (request.data?.data ?? [])
41
+ .filter((role) =>
42
+ text(role.title ?? role.key)
43
+ .toLocaleLowerCase()
44
+ .includes(search.toLocaleLowerCase()),
45
+ )
46
+ .sort((a, b) =>
47
+ text(a.title ?? a.key).localeCompare(
48
+ text(b.title ?? b.key),
49
+ i18n.language,
50
+ ),
51
+ );
52
+ const create = canCreate ? (
53
+ <Button
54
+ nativeButton={false}
55
+ render={<Link to={{ pathname: 'create', search: location.search }} />}
56
+ >
57
+ <PlusIcon />
58
+ {t('settings.newRole')}
59
+ </Button>
60
+ ) : null;
61
+ return (
62
+ <>
63
+ <PageContainer>
64
+ <PageHeader
65
+ title={t('settings.roles')}
66
+ description={t('settings.rolesDescription')}
67
+ actions={rows.length || search ? create : undefined}
68
+ />
69
+ <div className='flex items-center gap-3'>
70
+ <Input
71
+ className='sm:max-w-xs'
72
+ value={search}
73
+ placeholder={t('settings.roleSearch')}
74
+ aria-label={t('settings.roleSearch')}
75
+ onChange={(event) => {
76
+ setParams(event.target.value ? { q: event.target.value } : {}, {
77
+ replace: true,
78
+ });
79
+ }}
80
+ />
81
+ {search && (
82
+ <Button
83
+ variant='ghost'
84
+ onClick={() => {
85
+ setParams({});
86
+ }}
87
+ >
88
+ {t('settings.clear')}
89
+ </Button>
90
+ )}
91
+ </div>
92
+ <SettingsError error={request.error} retry={request.reload} />
93
+ {request.loading && !request.data ? (
94
+ <SettingsLoading />
95
+ ) : (
96
+ !request.error &&
97
+ (rows.length ? (
98
+ <div className='divide-y overflow-hidden rounded-xl border bg-card'>
99
+ {rows.map((role) => (
100
+ <Link
101
+ key={role.key}
102
+ to={{
103
+ pathname: `${encodeURIComponent(role.key)}/edit`,
104
+ search: location.search,
105
+ }}
106
+ className='flex items-center justify-between gap-4 p-4 transition-colors hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring'
107
+ >
108
+ <div className='flex min-w-0 items-center gap-3'>
109
+ <div className='rounded-lg bg-primary/10 p-2 text-primary'>
110
+ <ShieldCheckIcon className='size-4' />
111
+ </div>
112
+ <div className='min-w-0'>
113
+ <p className='font-medium'>
114
+ {text(role.title ?? role.key)}
115
+ </p>
116
+ <p className='text-xs text-muted-foreground'>
117
+ {role.key}
118
+ </p>
119
+ </div>
120
+ </div>
121
+ <Badge variant='secondary'>
122
+ {t(
123
+ role.protection
124
+ ? 'settings.protected'
125
+ : 'settings.custom',
126
+ )}
127
+ </Badge>
128
+ </Link>
129
+ ))}
130
+ </div>
131
+ ) : (
132
+ <Empty className='min-h-48 border border-dashed'>
133
+ <EmptyMedia variant='icon'>
134
+ <ShieldCheckIcon />
135
+ </EmptyMedia>
136
+ <EmptyTitle>
137
+ {t(search ? 'settings.noResults' : 'settings.noRoles')}
138
+ </EmptyTitle>
139
+ <EmptyDescription>
140
+ {t(search ? 'settings.clearHint' : 'settings.rolesDescription')}
141
+ </EmptyDescription>
142
+ <EmptyContent>
143
+ {search ? (
144
+ <Button
145
+ variant='outline'
146
+ onClick={() => {
147
+ setParams({});
148
+ }}
149
+ >
150
+ {t('settings.clear')}
151
+ </Button>
152
+ ) : (
153
+ create
154
+ )}
155
+ </EmptyContent>
156
+ </Empty>
157
+ ))
158
+ )}
159
+ </PageContainer>
160
+ <Outlet context={{ reload: request.reload }} />
161
+ </>
162
+ );
163
+ }
@@ -0,0 +1,79 @@
1
+ import { Settings, Users, ShieldCheck } from 'lucide-react';
2
+ import {
3
+ defineAppRoutes,
4
+ type AppClientRouteContribution,
5
+ } from '@nocobase/app-client/plugins';
6
+
7
+ /** Register this contribution as business capabilities are developed; keep a bare Home-only application free of settings. */
8
+ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
9
+ {
10
+ name: 'settings',
11
+ path: '/settings',
12
+ navigation: { title: 'settings.title', icon: Settings, order: 100 },
13
+ children: [
14
+ {
15
+ name: 'settingsUsers',
16
+ path: '/users',
17
+ auth: 'required',
18
+ authz: { resource: { type: 'user', id: '*' }, action: 'read' },
19
+ navigation: { title: 'settings.users', icon: Users },
20
+ breadcrumb: { title: 'settings.users' },
21
+ componentLoader: () => import('./users.js'),
22
+ children: [
23
+ {
24
+ name: 'settingsUserCreate',
25
+ path: 'create',
26
+ authz: { resource: { type: 'user', id: '*' }, action: 'create' },
27
+ componentLoader: () => import('./user-form.js'),
28
+ },
29
+ {
30
+ name: 'settingsUser',
31
+ path: ':userId',
32
+ componentLoader: () => import('./user-detail.js'),
33
+ children: [
34
+ {
35
+ name: 'settingsUserEdit',
36
+ path: 'edit',
37
+ componentLoader: () => import('./user-form.js'),
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ },
43
+ {
44
+ name: 'settingsRoles',
45
+ path: '/roles',
46
+ auth: 'required',
47
+ authz: {
48
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
49
+ action: 'read',
50
+ },
51
+ navigation: { title: 'settings.roles', icon: ShieldCheck },
52
+ breadcrumb: { title: 'settings.roles' },
53
+ componentLoader: () => import('./roles.js'),
54
+ children: [
55
+ {
56
+ name: 'settingsRoleCreate',
57
+ path: 'create',
58
+ authz: {
59
+ resource: {
60
+ type: 'settings',
61
+ id: 'authorization.permission-sets',
62
+ },
63
+ action: 'create',
64
+ },
65
+ breadcrumb: { title: 'settings.newRole' },
66
+ componentLoader: () => import('./role-form.js'),
67
+ },
68
+ {
69
+ name: 'settingsRoleEdit',
70
+ path: ':key/edit',
71
+ breadcrumb: { title: 'settings.editRole' },
72
+ componentLoader: () => import('./role-form.js'),
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ },
78
+ ]);
79
+ export default systemSettingsRoutes;