@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,105 @@
1
+ import { useEffect, useRef, type ReactElement, type ReactNode } from 'react';
2
+ import { useNavigate, useResolvedPath, useLocation } from 'react-router';
3
+ import { useCan } from '@nocobase/app-plugin-authorization/client';
4
+ import { useTranslation } from '@nocobase/i18n/client';
5
+ import { PageContainer } from '#components/page-container';
6
+ import { PageHeader } from '#components/page-header';
7
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '#components/ui/tabs';
8
+
9
+ export function PermissionManagementPage({
10
+ tab,
11
+ actions,
12
+ children,
13
+ }: {
14
+ tab: 'roles' | 'default-access';
15
+ actions?: ReactNode;
16
+ children: ReactNode;
17
+ }): ReactElement {
18
+ const { t } = useTranslation();
19
+ const navigate = useNavigate();
20
+ const location = useLocation();
21
+ const activeRef = useRef<HTMLButtonElement>(null);
22
+ const focusedRef = useRef(false);
23
+ const current = useResolvedPath('.').pathname;
24
+ const prefix = current.slice(0, current.lastIndexOf('/'));
25
+ const roles = useCan({
26
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
27
+ action: 'read',
28
+ }).can;
29
+ const defaults = useCan({
30
+ resource: { type: 'settings', id: 'authorization.default-access' },
31
+ action: 'read',
32
+ }).can;
33
+ // Route changes remount each existing page; keep keyboard focus on the selected tab.
34
+ useEffect(() => {
35
+ if (
36
+ !focusedRef.current &&
37
+ location.state &&
38
+ typeof location.state === 'object' &&
39
+ Reflect.get(location.state, 'permissionTabNavigation') === true &&
40
+ activeRef.current
41
+ ) {
42
+ const frame = requestAnimationFrame(() => {
43
+ if (activeRef.current) {
44
+ const selected = activeRef.current;
45
+ if (
46
+ !selected
47
+ .closest('[role="tablist"]')
48
+ ?.contains(selected.ownerDocument.activeElement)
49
+ )
50
+ selected.focus();
51
+ focusedRef.current = true;
52
+ }
53
+ });
54
+ return () => cancelAnimationFrame(frame);
55
+ }
56
+ }, [location.state, roles, defaults]);
57
+ return (
58
+ <PageContainer>
59
+ <PageHeader
60
+ title={t('settings.permissionManagement')}
61
+ description={t(
62
+ tab === 'roles'
63
+ ? 'settings.rolesDescription'
64
+ : 'settings.defaultAccess.description',
65
+ )}
66
+ actions={actions}
67
+ />
68
+ <Tabs
69
+ className='gap-6'
70
+ value={tab}
71
+ onValueChange={(value) => {
72
+ if (value === 'roles' || value === 'default-access')
73
+ void navigate(`${prefix}/${value}`, {
74
+ state: { permissionTabNavigation: true },
75
+ });
76
+ }}
77
+ >
78
+ <TabsList
79
+ variant='line'
80
+ aria-label={t('settings.permissionManagement')}
81
+ >
82
+ {roles && (
83
+ <TabsTrigger
84
+ ref={tab === 'roles' ? activeRef : undefined}
85
+ value='roles'
86
+ >
87
+ {t('settings.roles')}
88
+ </TabsTrigger>
89
+ )}
90
+ {defaults && (
91
+ <TabsTrigger
92
+ ref={tab === 'default-access' ? activeRef : undefined}
93
+ value='default-access'
94
+ >
95
+ {t('settings.defaultAccess.title')}
96
+ </TabsTrigger>
97
+ )}
98
+ </TabsList>
99
+ <TabsContent value={tab} className='space-y-6'>
100
+ {children}
101
+ </TabsContent>
102
+ </Tabs>
103
+ </PageContainer>
104
+ );
105
+ }
@@ -60,19 +60,43 @@ export function setPermission(
60
60
  return next.filter((item) => item.actions.length);
61
61
  }
62
62
 
63
+ const sectionDescriptions: Readonly<Record<string, string>> = {
64
+ pages: 'pageAccessHint',
65
+ business: 'businessOperationsHint',
66
+ users: 'userAccountsHint',
67
+ administration: 'systemToolsHint',
68
+ };
69
+
63
70
  export function permissionSections(
64
71
  catalog: AuthorizationOptionsResponse,
65
72
  grants: readonly PermissionGrant[],
66
73
  text: (value: LocalizedText) => string,
67
74
  ): readonly PermissionEditorSection[] {
75
+ const sharedActionHint = (resource: CatalogResource): string | undefined => {
76
+ const first = resource.actions[0]?.description;
77
+ if (!first) return undefined;
78
+ const hint = text(first);
79
+ return resource.actions.every(
80
+ (action) => action.description && text(action.description) === hint,
81
+ )
82
+ ? hint
83
+ : undefined;
84
+ };
68
85
  return catalog.sections
69
86
  .map((section) => ({
70
87
  id: section.name,
71
88
  title: text(section.title),
89
+ description: sectionDescriptions[section.name]
90
+ ? text({ key: `settings.${sectionDescriptions[section.name]}` })
91
+ : undefined,
72
92
  groups: section.subsections.flatMap((subsection) =>
73
93
  subsection.resources.map((resource) => ({
74
94
  id: JSON.stringify([subsection.name, resource.type, resource.id]),
75
95
  title: text(resource.title),
96
+ actionHint:
97
+ resource.actions.length > 1
98
+ ? sharedActionHint(resource)
99
+ : undefined,
76
100
  accessOnly:
77
101
  resource.type === 'page' &&
78
102
  resource.actions.length === 1 &&
@@ -89,6 +113,11 @@ export function permissionSections(
89
113
  const row = {
90
114
  id: permissionRowId(resource, action.name),
91
115
  label: text(action.title),
116
+ hint:
117
+ action.description &&
118
+ !(resource.actions.length > 1 && sharedActionHint(resource))
119
+ ? text(action.description)
120
+ : undefined,
92
121
  actionKey: action.name,
93
122
  enabled: Boolean(granted),
94
123
  };
@@ -143,6 +172,10 @@ export function permissionSections(
143
172
  })),
144
173
  ],
145
174
  level: value,
175
+ hint:
176
+ value === ''
177
+ ? text({ key: 'settings.noAdditionalScopeHint' })
178
+ : undefined,
146
179
  };
147
180
  })
148
181
  : [],
@@ -203,7 +236,10 @@ export function setPermissions(
203
236
  const [type, resourceId, action, scope] = JSON.parse(id) as string[];
204
237
  if (scope) return current;
205
238
  const resource = resources.find(
206
- (item) => item.type === type && item.id === resourceId,
239
+ (item) =>
240
+ item.type === type &&
241
+ item.id === resourceId &&
242
+ item.actions.some((entry) => entry.name === action),
207
243
  );
208
244
  if (
209
245
  !resource ||
@@ -11,6 +11,7 @@ import {
11
11
  import { useTranslation } from '@nocobase/i18n/client';
12
12
  import {
13
13
  useAuthorizationClient,
14
+ useCan,
14
15
  type AuthorizationOptionsResponse,
15
16
  type LocalizedText,
16
17
  type PermissionGrant,
@@ -20,6 +21,7 @@ import { pagePermissionResources } from './page-permissions.js';
20
21
  import { pagePermissionTitles } from './page-permission-titles.js';
21
22
  import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
22
23
  import {
24
+ Link,
23
25
  useLocation,
24
26
  useNavigate,
25
27
  useOutletContext,
@@ -72,6 +74,10 @@ function RoleEditor(): ReactElement {
72
74
  const options = useSettingsData<{ data: AuthorizationOptionsResponse }>(
73
75
  'authorization/permissionSets/options',
74
76
  );
77
+ const canReadDefaults = useCan({
78
+ resource: { type: 'settings', id: 'authorization.default-access' },
79
+ action: 'read',
80
+ }).can;
75
81
  const canCreate = useRolePermission('create');
76
82
  const canUpdate = useRolePermission('update');
77
83
  const [nameDraft, setNameDraft] = useState<string>();
@@ -113,14 +119,35 @@ function RoleEditor(): ReactElement {
113
119
  title: { key: 'settings.userPermissions' },
114
120
  order: 0,
115
121
  subsections: [
116
- {
117
- name: 'users.accounts',
118
- title: { key: 'settings.users' },
122
+ ...[
123
+ {
124
+ name: 'users.information',
125
+ title: 'userInformation',
126
+ actions: ['read', 'create', 'update', 'invite', 'delete'],
127
+ },
128
+ {
129
+ name: 'users.security',
130
+ title: 'accountSecurity',
131
+ actions: [
132
+ 'reset-password',
133
+ 'disable',
134
+ 'enable',
135
+ 'revoke-sessions',
136
+ ],
137
+ },
138
+ {
139
+ name: 'users.roles',
140
+ title: 'roleAssignment',
141
+ actions: ['assign-role'],
142
+ },
143
+ ].map((group) => ({
144
+ name: group.name,
145
+ title: { key: `settings.${group.title}` },
119
146
  resources: [
120
147
  {
121
148
  type: 'user',
122
149
  id: '*',
123
- title: { key: 'settings.users' },
150
+ title: { key: `settings.${group.title}` },
124
151
  actions: [
125
152
  { name: 'read', title: { key: 'settings.readUsers' } },
126
153
  { name: 'create', title: { key: 'settings.createUsers' } },
@@ -141,10 +168,10 @@ function RoleEditor(): ReactElement {
141
168
  name: 'revoke-sessions',
142
169
  title: { key: 'settings.revokeSessions' },
143
170
  },
144
- ],
171
+ ].filter((action) => group.actions.includes(action.name)),
145
172
  },
146
173
  ],
147
- },
174
+ })),
148
175
  ],
149
176
  },
150
177
  ...source.sections.map((section) => ({
@@ -316,13 +343,23 @@ function RoleEditor(): ReactElement {
316
343
  {t('settings.protectedHint')}
317
344
  </p>
318
345
  )}
346
+ {canReadDefaults && (
347
+ <div>
348
+ <Link
349
+ to='/default-access'
350
+ className='text-sm text-foreground underline-offset-4 hover:underline'
351
+ >
352
+ {t('settings.defaultAccess.manage')}
353
+ </Link>
354
+ </div>
355
+ )}
319
356
  <PermissionEditor
320
357
  sections={sections}
321
358
  readOnly={!allowed || busy}
322
359
  labels={{
323
- selectAll: t('settings.selectAllPermissions'),
324
- selectVisible: (name) =>
325
- t('settings.selectVisiblePermissions', { name }),
360
+ dataRanges: (count) => t('settings.dataRanges', { count }),
361
+ scopeExplanation: t('settings.scopeExplanation'),
362
+ scopeHintFor: (name) => t('settings.scopeHintFor', { name }),
326
363
  levelFor: (name) => t('settings.levelFor', { name }),
327
364
  search: t('settings.permissionSearch'),
328
365
  enabledOnly: t('settings.enabledOnly'),
@@ -330,8 +367,6 @@ function RoleEditor(): ReactElement {
330
367
  empty: t('settings.noPermissions'),
331
368
  noResults: t('settings.noResults'),
332
369
  navigation: t('settings.permissionCategories'),
333
- summary: (enabled, total) =>
334
- t('settings.permissionSummary', { enabled, total }),
335
370
  }}
336
371
  onEnabledChange={(id, enabled) => {
337
372
  if (!catalog) return;
@@ -342,13 +377,6 @@ function RoleEditor(): ReactElement {
342
377
  );
343
378
  setDirty(true);
344
379
  }}
345
- onEnabledBatchChange={(changes) => {
346
- if (!catalog) return;
347
- setGrantsDraft((current) =>
348
- setPermissions(current ?? grants, catalog, changes),
349
- );
350
- setDirty(true);
351
- }}
352
380
  onLevelChange={(id, value) => {
353
381
  if (value === 'customFilter') {
354
382
  setEditingScope(id);
@@ -7,11 +7,10 @@ import type {
7
7
  import { type ReactElement } from 'react';
8
8
  import { Link, Outlet, useLocation, useSearchParams } from 'react-router';
9
9
  import { ShieldCheckIcon, PlusIcon } from 'lucide-react';
10
- import { PageContainer } from '#components/page-container';
11
- import { PageHeader } from '#components/page-header';
10
+ import { PermissionManagementPage } from './permission-management-page.js';
12
11
  import { Input } from '#components/ui/input';
13
12
  import { Button } from '#components/ui/button';
14
- import { Badge } from '#components/ui/badge';
13
+ import { RoleList } from '#components/role-list';
15
14
  import {
16
15
  Empty,
17
16
  EmptyTitle,
@@ -60,12 +59,10 @@ export default function RolesPage(): ReactElement {
60
59
  ) : null;
61
60
  return (
62
61
  <>
63
- <PageContainer>
64
- <PageHeader
65
- title={t('settings.roles')}
66
- description={t('settings.rolesDescription')}
67
- actions={rows.length || search ? create : undefined}
68
- />
62
+ <PermissionManagementPage
63
+ tab='roles'
64
+ actions={rows.length || search ? create : undefined}
65
+ >
69
66
  <div className='flex items-center gap-3'>
70
67
  <Input
71
68
  className='sm:max-w-xs'
@@ -95,39 +92,22 @@ export default function RolesPage(): ReactElement {
95
92
  ) : (
96
93
  !request.error &&
97
94
  (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>
95
+ <RoleList
96
+ ariaLabel={t('settings.roles')}
97
+ roles={rows.map((role) => ({
98
+ key: role.key,
99
+ title: text(role.title ?? role.key),
100
+ to: {
101
+ pathname: `${encodeURIComponent(role.key)}/edit`,
102
+ search: location.search,
103
+ },
104
+ protected: Boolean(role.protection),
105
+ }))}
106
+ labels={{
107
+ protected: t('settings.protected'),
108
+ custom: t('settings.custom'),
109
+ }}
110
+ />
131
111
  ) : (
132
112
  <Empty className='min-h-48 border border-dashed'>
133
113
  <EmptyMedia variant='icon'>
@@ -156,7 +136,7 @@ export default function RolesPage(): ReactElement {
156
136
  </Empty>
157
137
  ))
158
138
  )}
159
- </PageContainer>
139
+ </PermissionManagementPage>
160
140
  <Outlet context={{ reload: request.reload }} />
161
141
  </>
162
142
  );
@@ -1,4 +1,10 @@
1
- import { Settings, Users, ShieldCheck } from 'lucide-react';
1
+ import {
2
+ Settings,
3
+ Users,
4
+ ShieldCheck,
5
+ KeyRound,
6
+ ListFilter,
7
+ } from 'lucide-react';
2
8
  import {
3
9
  defineAppRoutes,
4
10
  type AppClientRouteContribution,
@@ -41,35 +47,88 @@ const systemSettingsRoutes: AppClientRouteContribution = defineAppRoutes([
41
47
  ],
42
48
  },
43
49
  {
44
- name: 'settingsRoles',
45
- path: '/roles',
46
- auth: 'required',
47
- authz: {
48
- resource: { type: 'settings', id: 'authorization.permission-sets' },
49
- action: 'read',
50
+ name: 'permissionManagement',
51
+ navigation: {
52
+ title: 'settings.permissionManagement',
53
+ icon: ShieldCheck,
50
54
  },
51
- navigation: { title: 'settings.roles', icon: ShieldCheck },
52
- breadcrumb: { title: 'settings.roles' },
53
- componentLoader: () => import('./roles.js'),
55
+ breadcrumb: { title: 'settings.permissionManagement' },
54
56
  children: [
55
57
  {
56
- name: 'settingsRoleCreate',
57
- path: 'create',
58
+ name: 'settingsRoles',
59
+ path: '/roles',
60
+ auth: 'required',
58
61
  authz: {
59
62
  resource: {
60
63
  type: 'settings',
61
64
  id: 'authorization.permission-sets',
62
65
  },
63
- action: 'create',
66
+ action: 'read',
64
67
  },
65
- breadcrumb: { title: 'settings.newRole' },
66
- componentLoader: () => import('./role-form.js'),
68
+ navigation: { title: 'settings.roles', icon: KeyRound },
69
+ breadcrumb: { title: 'settings.roles' },
70
+ componentLoader: () => import('./roles.js'),
71
+ children: [
72
+ {
73
+ name: 'settingsRoleCreate',
74
+ path: 'create',
75
+ authz: {
76
+ resource: {
77
+ type: 'settings',
78
+ id: 'authorization.permission-sets',
79
+ },
80
+ action: 'create',
81
+ },
82
+ breadcrumb: { title: 'settings.newRole' },
83
+ componentLoader: () => import('./role-form.js'),
84
+ },
85
+ {
86
+ name: 'settingsRoleEdit',
87
+ path: ':key/edit',
88
+ breadcrumb: { title: 'settings.editRole' },
89
+ componentLoader: () => import('./role-form.js'),
90
+ },
91
+ ],
67
92
  },
68
93
  {
69
- name: 'settingsRoleEdit',
70
- path: ':key/edit',
71
- breadcrumb: { title: 'settings.editRole' },
72
- componentLoader: () => import('./role-form.js'),
94
+ name: 'settingsDefaultAccess',
95
+ path: '/default-access',
96
+ auth: 'required',
97
+ authz: {
98
+ resource: {
99
+ type: 'settings',
100
+ id: 'authorization.default-access',
101
+ },
102
+ action: 'read',
103
+ },
104
+ navigation: {
105
+ title: 'settings.defaultAccess.title',
106
+ icon: ListFilter,
107
+ },
108
+ breadcrumb: { title: 'settings.defaultAccess.title' },
109
+ componentLoader: () => import('./default-access.js'),
110
+ children: [
111
+ {
112
+ name: 'settingsDefaultAccessCreate',
113
+ path: 'create',
114
+ authz: {
115
+ resource: {
116
+ type: 'settings',
117
+ id: 'authorization.default-access',
118
+ },
119
+ action: 'create',
120
+ },
121
+ breadcrumb: { title: 'settings.defaultAccess.new' },
122
+ componentLoader: () => import('./default-access-form.js'),
123
+ },
124
+ // Read permission opens the full rule even when the viewer may not update it.
125
+ {
126
+ name: 'settingsDefaultAccessEdit',
127
+ path: ':key/edit',
128
+ breadcrumb: { title: 'settings.defaultAccess.edit' },
129
+ componentLoader: () => import('./default-access-form.js'),
130
+ },
131
+ ],
73
132
  },
74
133
  ],
75
134
  },
@@ -78,6 +78,7 @@ export function ConfirmAction({
78
78
  title,
79
79
  description,
80
80
  confirmLabel,
81
+ error,
81
82
  busy,
82
83
  onCancel,
83
84
  onConfirm,
@@ -86,6 +87,7 @@ export function ConfirmAction({
86
87
  readonly title: string;
87
88
  readonly description: ReactNode;
88
89
  readonly confirmLabel?: string;
90
+ readonly error?: ReactNode;
89
91
  readonly busy: boolean;
90
92
  readonly onCancel: () => void;
91
93
  readonly onConfirm: () => void;
@@ -102,6 +104,7 @@ export function ConfirmAction({
102
104
  <AlertDialogHeader>
103
105
  <AlertDialogTitle>{title}</AlertDialogTitle>
104
106
  <AlertDialogDescription>{description}</AlertDialogDescription>
107
+ {error}
105
108
  </AlertDialogHeader>
106
109
  <AlertDialogFooter>
107
110
  <AlertDialogCancel disabled={busy}>
@@ -250,7 +250,7 @@ export default function UsersPage(): ReactElement {
250
250
  enableHiding: false,
251
251
  cell: ({ row }) => (
252
252
  <Link
253
- className='font-medium text-primary hover:underline'
253
+ className='font-medium text-foreground underline-offset-4 hover:underline'
254
254
  to={{
255
255
  pathname: row.original.id,
256
256
  search: location.search,
@@ -11,6 +11,8 @@ import { EMPTY_ARRAY } from '#lib/constants';
11
11
  import { transformNavigation } from '#lib/navigation-transform';
12
12
 
13
13
  export interface RouteNavigationItem {
14
+ /** Route keys represented by a single sidebar entry whose content uses tabs. */
15
+ readonly activeRouteKeys?: readonly string[];
14
16
  readonly section?: boolean;
15
17
  readonly route: AppClientRegisteredRoute;
16
18
  readonly children: readonly RouteNavigationItem[];
@@ -45,7 +47,25 @@ export function selectedNavigationId(
45
47
  denied: ReadonlySet<string> = new Set(),
46
48
  ): string | undefined {
47
49
  const matches = matchRouteTree(routes, pathname);
48
- const pages = navigationPages(buildRouteNavigation(routes, denied));
50
+ const items = buildRouteNavigation(routes, denied);
51
+ const represented = (
52
+ nodes: readonly RouteNavigationItem[],
53
+ ): AppClientRegisteredRoute | undefined => {
54
+ for (const item of nodes) {
55
+ if (
56
+ item.activeRouteKeys?.some((key) =>
57
+ matches?.some(({ route }) => routeKey(route) === key),
58
+ )
59
+ )
60
+ return item.route;
61
+ const child = represented(item.children);
62
+ if (child) return child;
63
+ }
64
+ return undefined;
65
+ };
66
+ const active = represented(items);
67
+ if (active) return routeKey(active);
68
+ const pages = navigationPages(items);
49
69
  const visible = new Set(pages.map(routeKey));
50
70
  const selected =
51
71
  matches