@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.89

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 (107) hide show
  1. package/.agents/rules/components.md +3 -1
  2. package/.agents/rules/notification-templates.md +5 -0
  3. package/.agents/rules/shell.md +9 -3
  4. package/AGENTS.md +4 -2
  5. package/README.MD +11 -5
  6. package/client/components/back-button.tsx +37 -12
  7. package/client/components/body-portal.tsx +25 -0
  8. package/client/components/brand-loading-mark.tsx +5 -2
  9. package/client/components/brand-mark-geometry.ts +3 -3
  10. package/client/components/breadcrumbs.tsx +60 -3
  11. package/client/components/data-table/data-table.tsx +90 -19
  12. package/client/components/data-table/pagination.tsx +17 -2
  13. package/client/components/flow-diagram.tsx +2 -2
  14. package/client/components/list-feedback.tsx +31 -22
  15. package/client/components/loading.tsx +127 -16
  16. package/client/components/pending-button.tsx +111 -0
  17. package/client/components/role-list.tsx +7 -0
  18. package/client/components/route-child-page.tsx +72 -1
  19. package/client/components/route-overlay.tsx +149 -15
  20. package/client/components/task-progress.tsx +155 -0
  21. package/client/components/use-route-overlay.ts +8 -11
  22. package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
  23. package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
  24. package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
  25. package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
  26. package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
  27. package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
  28. package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
  29. package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
  30. package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
  31. package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
  32. package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
  33. package/client/extensions/nocobase-workflow-management/README.md +5 -5
  34. package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
  35. package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
  36. package/client/layouts/app-layout.tsx +8 -8
  37. package/client/layouts/components/sidebar-footer.tsx +18 -25
  38. package/client/lib/navigation-transform.ts +156 -22
  39. package/client/locales/system/en-US.ts +26 -5
  40. package/client/locales/system/settings-en-US.ts +17 -0
  41. package/client/locales/system/settings-zh-CN.ts +15 -0
  42. package/client/locales/system/zh-CN.ts +26 -5
  43. package/client/pages/auth/register.tsx +39 -22
  44. package/client/pages/auth/shared.tsx +1 -3
  45. package/client/pages/settings/default-access.tsx +1 -1
  46. package/client/pages/settings/hooks.tsx +3 -68
  47. package/client/pages/settings/notification-routes.ts +109 -0
  48. package/client/pages/settings/notification-templates.tsx +26 -0
  49. package/client/pages/settings/permission-management-page.tsx +15 -90
  50. package/client/pages/settings/roles.tsx +27 -27
  51. package/client/pages/settings/routes.ts +107 -101
  52. package/client/pages/settings/settings-home.tsx +109 -0
  53. package/client/pages/settings/settings-module-page.tsx +158 -0
  54. package/client/pages/settings/shell-routes.ts +53 -0
  55. package/client/pages/settings/user-detail.tsx +3 -17
  56. package/client/pages/settings/user-form.tsx +9 -15
  57. package/client/pages/settings/users.tsx +11 -27
  58. package/client/routes.ts +17 -1
  59. package/client/routing/app-router.tsx +1 -14
  60. package/client/routing/client-route.tsx +199 -30
  61. package/client/routing/navigation-destination.ts +42 -0
  62. package/client/routing/route-context.tsx +30 -3
  63. package/client/routing/route-leave-boundary.tsx +261 -0
  64. package/client/routing/route-navigation.ts +72 -8
  65. package/client/routing/route-tree.tsx +7 -12
  66. package/client/runtime.ts +2 -2
  67. package/client/styles.css +18 -1
  68. package/config.example.yml +1 -1
  69. package/package.json +13 -13
  70. package/server/config/auth.ts +1 -1
  71. package/server/providers/index.ts +2 -0
  72. package/server/providers/notification-management.ts +27 -0
  73. package/tests/client/account-permissions.test.tsx +1 -1
  74. package/tests/client/auth-i18n.test.tsx +1 -1
  75. package/tests/client/authenticated-client-fixture.test.tsx +37 -0
  76. package/tests/client/back-button.test.tsx +34 -0
  77. package/tests/client/client-auth.test.tsx +10 -1
  78. package/tests/client/client-route-recovery.test.tsx +93 -1
  79. package/tests/client/client-routes.test.ts +137 -4
  80. package/tests/client/client-shell.test.tsx +23 -8
  81. package/tests/client/foundation-integration.test.ts +38 -17
  82. package/tests/client/layout-i18n.test.tsx +9 -4
  83. package/tests/client/list-feedback.test.tsx +5 -5
  84. package/tests/client/list-loading.test.tsx +397 -0
  85. package/tests/client/loading.test.tsx +20 -11
  86. package/tests/client/manual-list-retry.test.tsx +2 -2
  87. package/tests/client/navigation-parent.test.tsx +172 -0
  88. package/tests/client/pending-button.test.tsx +106 -0
  89. package/tests/client/permission-management-tabs.test.tsx +458 -17
  90. package/tests/client/primitive-labels.test.tsx +2 -2
  91. package/tests/client/registration-feedback.test.tsx +143 -0
  92. package/tests/client/role-return.test.tsx +21 -3
  93. package/tests/client/roles.test.tsx +84 -3
  94. package/tests/client/route-leave-recheck.test.tsx +74 -0
  95. package/tests/client/route-presentation.test.tsx +557 -0
  96. package/tests/client/session-preparation.test.tsx +20 -17
  97. package/tests/client/settings-continuity.test.tsx +35 -11
  98. package/tests/client/settings-home.test.tsx +112 -0
  99. package/tests/client/settings-idle-list.test.tsx +124 -0
  100. package/tests/client/settings-navigation.test.ts +230 -0
  101. package/tests/client/user-drawer.test.tsx +3 -4
  102. package/tests/fixtures/dialog-footer.html +12 -0
  103. package/tests/fixtures/dialog-footer.tsx +215 -0
  104. package/tests/helpers/authenticated-client.tsx +70 -22
  105. package/tests/playwright/dialog-footer.test.ts +227 -0
  106. package/tests/server/notification-templates.test.ts +144 -0
  107. package/tests/client/permission-management-routes.test.ts +0 -64
@@ -31,12 +31,12 @@ import {
31
31
  } from 'react';
32
32
  import { Link, Outlet, useLocation, useResolvedPath } from 'react-router';
33
33
  import { PlusIcon } from 'lucide-react';
34
+ import { cn } from 'cn';
34
35
  import { PageContainer } from '#components/page-container';
35
36
  import { PageHeader } from '#components/page-header';
36
37
  import { Button } from '#components/ui/button';
37
38
  import { Input } from '#components/ui/input';
38
39
  import { StatusBadge } from '#components/status-badge';
39
- import { Spinner } from '#components/ui/spinner';
40
40
  import {
41
41
  Select,
42
42
  SelectTrigger,
@@ -46,25 +46,6 @@ import {
46
46
  } from '#components/ui/select';
47
47
  import { ConfirmAction, SettingsError } from './shared.js';
48
48
 
49
- function UsersRefreshing(): ReactElement | null {
50
- const { t } = useTranslation();
51
- const [visible, setVisible] = useState(false);
52
- useEffect(() => {
53
- // Fast background reads should not flash feedback on overlay close.
54
- const timer = setTimeout(() => setVisible(true), 300);
55
- return () => clearTimeout(timer);
56
- }, []);
57
- return visible ? (
58
- <span
59
- role='status'
60
- className='flex items-center gap-2 text-sm text-muted-foreground'
61
- >
62
- <Spinner aria-hidden='true' className='size-4' />
63
- {t('settings.loading')}
64
- </span>
65
- ) : null;
66
- }
67
-
68
49
  function UserName({ user }: { readonly user: ManagedUser }): ReactElement {
69
50
  const canUpdate = useUserPermission('update', user.id);
70
51
  const canAssign = useUserPermission('assign-role', user.id);
@@ -515,11 +496,6 @@ export default function UsersPage(): ReactElement {
515
496
  {t('settings.clear')}
516
497
  </Button>
517
498
  )}
518
- <div className='ml-auto flex h-8 w-28 items-center justify-end'>
519
- {active && hasCurrentResponse && request.loading && request.data ? (
520
- <UsersRefreshing />
521
- ) : null}
522
- </div>
523
499
  </div>
524
500
  {Boolean(roleScope) !== Boolean(role) && (
525
501
  <p role='alert' className='text-sm text-destructive'>
@@ -536,13 +512,21 @@ export default function UsersPage(): ReactElement {
536
512
  {...tableState}
537
513
  manualSorting
538
514
  showSelectedCount={false}
539
- loading={request.loading}
515
+ loading={active && request.loading}
516
+ loadingIdentity={requestPath}
540
517
  listState={listState}
541
518
  onRetry={request.reload}
542
519
  onClearFilters={clearFilters}
543
520
  />
544
521
  {request.data && (
545
- <p className='text-sm text-muted-foreground'>
522
+ // A previous query's total keeps its line but is never shown as the current one.
523
+ <p
524
+ className={cn(
525
+ 'text-sm text-muted-foreground',
526
+ !hasCurrentResponse && 'invisible',
527
+ )}
528
+ aria-hidden={!hasCurrentResponse || undefined}
529
+ >
546
530
  {t('settings.total', { count: request.data.meta.total })}
547
531
  </p>
548
532
  )}
package/client/routes.ts CHANGED
@@ -1,4 +1,8 @@
1
+ import { createAppClientConfig } from '@nocobase/app-client';
2
+ import { readAppClientRuntimeConfig } from '@nocobase/app-client/runtime';
3
+ import { includeNotificationTemplateRoutes } from './pages/settings/notification-routes.js';
1
4
  import { Home } from 'lucide-react';
5
+ import settingsShellRoutes from './pages/settings/shell-routes.js';
2
6
  import {
3
7
  defineAppRoutes,
4
8
  type AppClientRouteContribution,
@@ -64,6 +68,18 @@ const appRoutes: AppClientRouteContribution = defineAppRoutes([
64
68
  },
65
69
  ]);
66
70
 
67
- const routes: readonly AppClientRouteContribution[] = [appRoutes];
71
+ // The System settings shell stays registered: it shows only the modules a viewer may open, and none until the
72
+ // application or its plugins contribute one. Business administration such as `./pages/settings/routes.js` joins it.
73
+ export function createApplicationRoutes(
74
+ rawConfig: unknown = readAppClientRuntimeConfig(),
75
+ ): readonly AppClientRouteContribution[] {
76
+ const config = createAppClientConfig({ rawConfig });
77
+ return includeNotificationTemplateRoutes(
78
+ [appRoutes, settingsShellRoutes],
79
+ config.get<boolean>('notificationTemplates.invitationConfigured') === true,
80
+ );
81
+ }
82
+
83
+ const routes: readonly AppClientRouteContribution[] = createApplicationRoutes();
68
84
 
69
85
  export default routes;
@@ -6,9 +6,8 @@ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
6
6
  import { useMemo, type ReactElement } from 'react';
7
7
  import { Navigate, Outlet, Route, Routes } from 'react-router';
8
8
 
9
- import { EMPTY_ARRAY } from '#lib/constants';
10
-
11
9
  import { AppLayout } from '../layouts/app-layout.js';
10
+ import { filterRouteTree } from './route-navigation.js';
12
11
  import { renderRouteTree } from './route-tree.js';
13
12
  import { StandalonePageLayout } from './standalone-page-layout.js';
14
13
  import { SessionPreparation } from './session-preparation.js';
@@ -66,15 +65,3 @@ export function AppRouter({ clientRoutes }: AppRouterProps): ReactElement {
66
65
  </Routes>
67
66
  );
68
67
  }
69
-
70
- /** Pure groups can span shells; a page and its descendants always share their shell. */
71
- function filterRouteTree(
72
- routes: readonly AppClientRegisteredRoute[],
73
- predicate: (route: AppClientRegisteredRoute) => boolean,
74
- ): AppClientRegisteredRoute[] {
75
- return routes.flatMap((route) => {
76
- if (route.componentLoader) return predicate(route) ? [route] : [];
77
- const children = filterRouteTree(route.children ?? EMPTY_ARRAY, predicate);
78
- return children.length ? [{ ...route, children }] : [];
79
- });
80
- }
@@ -1,22 +1,51 @@
1
1
  import { useTranslation } from '@nocobase/i18n/client';
2
+ import { useNavigationGuard } from '@nocobase/app-client';
2
3
  import type {
3
4
  AppClientRegisteredRoute,
4
5
  AppClientRouteComponentModule,
5
6
  } from '@nocobase/app-client/plugins';
6
7
  import { useCan } from '@nocobase/app-plugin-authorization/client';
7
8
  import { NamespaceScope } from '@nocobase/i18n/client';
8
- import { type ReactElement, useEffect, useState } from 'react';
9
+ import {
10
+ Activity,
11
+ type ReactElement,
12
+ type ReactNode,
13
+ useCallback,
14
+ useEffect,
15
+ useRef,
16
+ useState,
17
+ } from 'react';
9
18
  import { ErrorBoundary } from 'react-error-boundary';
10
19
 
11
20
  import { Loading } from '#components/loading';
21
+ import {
22
+ RouteChildPageHost,
23
+ RouteChildPageHostBoundary,
24
+ } from '#components/route-child-page';
25
+ import {
26
+ RouteOverlayHost,
27
+ RouteOverlayHostBoundary,
28
+ } from '#components/route-overlay';
12
29
  import { Button } from '#components/ui/button';
30
+ import { cn } from 'cn';
13
31
 
14
32
  export interface ClientRouteProps {
15
33
  readonly route: AppClientRegisteredRoute;
16
34
  }
17
35
 
36
+ // Where a route's waiting, denial and failure render: the content area for a page, or the container the application
37
+ // opened for a declared child page, dialog or drawer. A route without a presentation keeps the page-sized placeholder.
38
+ type ClientRouteRegion = 'page' | 'child-page' | 'overlay';
39
+
40
+ const REGION_CLASS_NAMES: Record<ClientRouteRegion, string> = {
41
+ page: 'min-h-[calc(100svh-4rem)]',
42
+ 'child-page': 'min-h-full',
43
+ overlay: 'min-h-40',
44
+ };
45
+
18
46
  export function ClientRoute({ route }: ClientRouteProps): ReactElement {
19
- const { authz, componentLoader } = route;
47
+ const { t } = useTranslation();
48
+ const { authz, componentLoader, presentation } = route;
20
49
  const checkAccess = authz !== 'skip';
21
50
  const { can: allowed, isPending: accessLoading } = useCan(
22
51
  authz === 'skip' ? undefined : authz,
@@ -24,14 +53,33 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
24
53
  const [componentModule, setComponentModule] =
25
54
  useState<AppClientRouteComponentModule>();
26
55
  const [loadError, setLoadError] = useState<unknown>();
56
+ const [attempt, setAttempt] = useState(0);
57
+ const [renderFailed, setRenderFailed] = useState(false);
58
+ const loadedRef = useRef<
59
+ | {
60
+ loader: typeof componentLoader;
61
+ attempt: number;
62
+ }
63
+ | undefined
64
+ >(undefined);
27
65
 
28
66
  useEffect(() => {
29
67
  if (checkAccess && !allowed) return;
68
+ // Activity reconnects Effects after a parent permission recheck. Keep the loaded component's identity:
69
+ // loaders that wrap a module (for example, withNamespace) can return a new component on every call.
70
+ if (
71
+ loadedRef.current &&
72
+ loadedRef.current.loader === componentLoader &&
73
+ loadedRef.current.attempt === attempt
74
+ )
75
+ return;
76
+ loadedRef.current = undefined;
30
77
  let active = true;
31
78
 
32
79
  componentLoader!().then(
33
80
  (module) => {
34
81
  if (active) {
82
+ loadedRef.current = { loader: componentLoader, attempt };
35
83
  setComponentModule(module);
36
84
  setLoadError(undefined);
37
85
  }
@@ -46,37 +94,126 @@ export function ClientRoute({ route }: ClientRouteProps): ReactElement {
46
94
  return () => {
47
95
  active = false;
48
96
  };
49
- }, [allowed, checkAccess, componentLoader]);
50
-
51
- if (loadError) {
52
- return <ClientPageError route={route} />;
53
- }
97
+ }, [allowed, attempt, checkAccess, componentLoader]);
54
98
 
55
- if (checkAccess && accessLoading) return <ClientPageLoading />;
56
- if (checkAccess && !allowed) return <ClientPageDenied route={route} />;
99
+ // Retries only this route's module, so the pages around it keep their drafts and scroll positions.
100
+ const retryLoad = useCallback(() => {
101
+ setLoadError(undefined);
102
+ setAttempt((value) => value + 1);
103
+ }, []);
57
104
 
58
- if (!componentModule) {
59
- return <ClientPageLoading />;
60
- }
105
+ const kind = presentation?.kind;
106
+ const region: ClientRouteRegion =
107
+ kind === 'dialog' || kind === 'drawer'
108
+ ? 'overlay'
109
+ : kind === 'child-page'
110
+ ? 'child-page'
111
+ : 'page';
112
+ const checking = checkAccess && accessLoading;
113
+ const denied = checkAccess && !accessLoading && !allowed;
114
+ const Component = componentModule?.default;
115
+ const [mounted, setMounted] = useState(false);
116
+ const present = Boolean(Component) && !denied && !loadError && !renderFailed;
117
+ if (!checking && mounted !== present) setMounted(present);
118
+ const rechecking = checking && mounted && !loadError && !renderFailed;
119
+ // Activity disconnects form effects, including dirty registrations. This guard stays outside it and refuses
120
+ // route departures until the page's effects reconnect; settled denial still unmounts the protected page.
121
+ useNavigationGuard(() => !rechecking);
122
+ useEffect(() => {
123
+ if (!rechecking) return;
124
+ const warn = (event: BeforeUnloadEvent): void => {
125
+ event.preventDefault();
126
+ event.returnValue = '';
127
+ };
128
+ window.addEventListener('beforeunload', warn);
129
+ return () => window.removeEventListener('beforeunload', warn);
130
+ }, [rechecking]);
61
131
 
62
- const Component = componentModule.default;
132
+ let content: ReactNode;
133
+ // A settled denial wins over an older load failure: the viewer may not see this page at all.
134
+ if (denied) content = <ClientPageDenied route={route} region={region} />;
135
+ else if (loadError)
136
+ content = (
137
+ <ClientPageError
138
+ route={route}
139
+ region={region}
140
+ onRetry={retryLoad}
141
+ retried={attempt > 0}
142
+ />
143
+ );
144
+ else if (!Component) content = <ClientPageLoading region={region} />;
145
+ else
146
+ content = (
147
+ <>
148
+ {checking ? <ClientPageLoading region={region} /> : null}
149
+ {/* Conceal a previously allowed page during rechecks without discarding its draft. A denial unmounts it. */}
150
+ <Activity mode={checking ? 'hidden' : 'visible'}>
151
+ <ErrorBoundary
152
+ onError={() => setRenderFailed(true)}
153
+ onReset={() => setRenderFailed(false)}
154
+ fallbackRender={({ resetErrorBoundary }) => (
155
+ <ClientPageError
156
+ route={route}
157
+ region={region}
158
+ onRetry={resetErrorBoundary}
159
+ />
160
+ )}
161
+ >
162
+ {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
163
+ <NamespaceScope ns={route.packageName}>
164
+ <Component />
165
+ </NamespaceScope>
166
+ </ErrorBoundary>
167
+ </Activity>
168
+ </>
169
+ );
63
170
 
171
+ if (kind === 'dialog' || kind === 'drawer') {
172
+ const title = presentation!.title!;
173
+ return (
174
+ <RouteChildPageHostBoundary>
175
+ <RouteOverlayHost
176
+ drawer={kind === 'drawer'}
177
+ title={t(title, {
178
+ ns: presentation!.namespace ?? route.packageName,
179
+ defaultValue: title,
180
+ })}
181
+ ready={mounted && !denied && !loadError && !renderFailed}
182
+ concealed={checking}
183
+ >
184
+ {content}
185
+ </RouteOverlayHost>
186
+ </RouteChildPageHostBoundary>
187
+ );
188
+ }
189
+ if (kind === 'child-page')
190
+ return (
191
+ <RouteOverlayHostBoundary>
192
+ <RouteChildPageHost>{content}</RouteChildPageHost>
193
+ </RouteOverlayHostBoundary>
194
+ );
195
+ // A page that is not hosted builds its own container, including one rendered through a hosted page's outlet.
64
196
  return (
65
- <ErrorBoundary fallback={<ClientPageError route={route} />}>
66
- {/* The owning package is the page's namespace, so a plugin page translates with a bare useTranslation(). */}
67
- <NamespaceScope ns={route.packageName}>
68
- <Component />
69
- </NamespaceScope>
70
- </ErrorBoundary>
197
+ <RouteOverlayHostBoundary>
198
+ <RouteChildPageHostBoundary>{content}</RouteChildPageHostBoundary>
199
+ </RouteOverlayHostBoundary>
71
200
  );
72
201
  }
73
202
 
74
- function ClientPageDenied(inputProps: ClientRouteProps): ReactElement {
203
+ interface ClientRouteStateProps extends ClientRouteProps {
204
+ readonly region: ClientRouteRegion;
205
+ }
206
+
207
+ function ClientPageDenied({
208
+ route,
209
+ region,
210
+ }: ClientRouteStateProps): ReactElement {
75
211
  const { t } = useTranslation();
76
- const { route } = inputProps;
77
212
 
78
213
  return (
79
- <section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
214
+ <section
215
+ className={cn('grid place-items-center px-6', REGION_CLASS_NAMES[region])}
216
+ >
80
217
  <section className='w-full max-w-lg space-y-3 text-center'>
81
218
  <h1 className='text-xl font-semibold'>
82
219
  {t('status.denied', { defaultValue: 'Access denied' })}
@@ -92,23 +229,40 @@ function ClientPageDenied(inputProps: ClientRouteProps): ReactElement {
92
229
  );
93
230
  }
94
231
 
95
- function ClientPageLoading(): ReactElement {
232
+ function ClientPageLoading({
233
+ region,
234
+ }: {
235
+ readonly region: ClientRouteRegion;
236
+ }): ReactElement {
96
237
  const { t } = useTranslation();
97
238
 
98
239
  return (
99
240
  <Loading
100
- className='min-h-[calc(100svh-4rem)]'
241
+ className={REGION_CLASS_NAMES[region]}
101
242
  label={t('status.loadingPage', { defaultValue: 'Loading page' })}
102
243
  />
103
244
  );
104
245
  }
105
246
 
106
- function ClientPageError(inputProps: ClientRouteProps): ReactElement {
247
+ function ClientPageError({
248
+ route,
249
+ region,
250
+ onRetry,
251
+ retried = false,
252
+ }: ClientRouteStateProps & {
253
+ readonly onRetry: () => void;
254
+ /**
255
+ * Set once a retry of the module failed too. A browser may keep answering a failed module import from its module
256
+ * map without fetching again, so the full reload that does fetch it becomes the second way out.
257
+ */
258
+ readonly retried?: boolean;
259
+ }): ReactElement {
107
260
  const { t } = useTranslation();
108
- const { route } = inputProps;
109
261
 
110
262
  return (
111
- <section className='grid min-h-[calc(100svh-4rem)] place-items-center px-6'>
263
+ <section
264
+ className={cn('grid place-items-center px-6', REGION_CLASS_NAMES[region])}
265
+ >
112
266
  <section className='w-full max-w-lg space-y-4 text-center'>
113
267
  <h1 className='text-xl font-semibold'>
114
268
  {t('status.pageFailed', { defaultValue: 'Unable to load page' })}
@@ -120,9 +274,24 @@ function ClientPageError(inputProps: ClientRouteProps): ReactElement {
120
274
  defaultValue: `Route ${route.name} from ${route.packageName} could not be loaded.`,
121
275
  })}
122
276
  </p>
123
- <Button onClick={() => window.location.reload()}>
124
- {t('status.retry', { defaultValue: 'Retry' })}
125
- </Button>
277
+ {retried && (
278
+ <p className='text-sm text-muted-foreground'>
279
+ {t('status.retryFailedDescription', {
280
+ defaultValue:
281
+ 'It still could not be loaded. Reloading the whole page may help; unsaved changes on this page may be lost.',
282
+ })}
283
+ </p>
284
+ )}
285
+ <div className='flex flex-wrap justify-center gap-2'>
286
+ <Button onClick={onRetry}>
287
+ {t('status.retry', { defaultValue: 'Retry' })}
288
+ </Button>
289
+ {retried && (
290
+ <Button variant='outline' onClick={() => window.location.reload()}>
291
+ {t('status.reloadPage', { defaultValue: 'Reload page' })}
292
+ </Button>
293
+ )}
294
+ </div>
126
295
  </section>
127
296
  </section>
128
297
  );
@@ -0,0 +1,42 @@
1
+ import type { AppClientRegisteredRoute } from '@nocobase/app-client/plugins';
2
+ import { parsePath, type To, type Path } from 'react-router';
3
+
4
+ import type { RouteAccess } from './route-context.js';
5
+ import { matchRouteTree, routeKey } from './route-navigation.js';
6
+
7
+ /** Only application-absolute, registered, authorized pages can be navigation destinations. */
8
+ export function navigationDestination(
9
+ to: To,
10
+ routes: readonly AppClientRegisteredRoute[],
11
+ access: RouteAccess,
12
+ ): Partial<Path> | undefined {
13
+ if (access.loading) return undefined;
14
+ const path = typeof to === 'string' ? parsePath(to) : to;
15
+ const pathname = path.pathname;
16
+ if (
17
+ !pathname ||
18
+ !pathname.startsWith('/') ||
19
+ pathname.startsWith('//') ||
20
+ pathname.includes('\\') ||
21
+ [...pathname].some((character) => character.charCodeAt(0) <= 32)
22
+ )
23
+ return undefined;
24
+ // Do not normalize dot segments or malformed escapes into a different authorized route.
25
+ try {
26
+ if (
27
+ pathname
28
+ .split('/')
29
+ .some((segment) => ['.', '..'].includes(decodeURIComponent(segment)))
30
+ )
31
+ return undefined;
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ const matches = matchRouteTree(routes, pathname);
36
+ if (
37
+ !matches?.at(-1)?.route.componentLoader ||
38
+ matches.some(({ route }) => access.denied.has(routeKey(route)))
39
+ )
40
+ return undefined;
41
+ return { pathname, search: path.search ?? '', hash: path.hash ?? '' };
42
+ }
@@ -37,8 +37,32 @@ export function useRouteSearches(): ReadonlyMap<string, string> {
37
37
  }
38
38
 
39
39
  const RouteTreeContext = createContext<readonly AppClientRegisteredRoute[]>([]);
40
+
41
+ /** The shell's page permission check (`useRouteNavigation`), shared so pages reuse it instead of checking again. */
42
+ export interface RouteAccess {
43
+ /** Route keys the viewer may not open. */
44
+ readonly denied: ReadonlySet<string>;
45
+ /** The check is still running; `denied` is not yet known. */
46
+ readonly loading: boolean;
47
+ }
48
+
49
+ const DEFAULT_ACCESS: RouteAccess = { denied: new Set(), loading: false };
50
+ const RouteAccessContext = createContext<RouteAccess>(DEFAULT_ACCESS);
51
+
40
52
  export interface RouteTreeProviderProps extends PropsWithChildren {
41
53
  readonly routes: readonly AppClientRegisteredRoute[];
54
+ /** The shell's page permission result; without it every route counts as open. */
55
+ readonly access?: RouteAccess;
56
+ }
57
+
58
+ /** The registered business route tree the shell renders. */
59
+ export function useRouteTree(): readonly AppClientRegisteredRoute[] {
60
+ return useContext(RouteTreeContext);
61
+ }
62
+
63
+ /** Which routes the viewer may open, from the shell's single permission check. */
64
+ export function useRouteAccess(): RouteAccess {
65
+ return useContext(RouteAccessContext);
42
66
  }
43
67
 
44
68
  /**
@@ -51,6 +75,7 @@ export interface RouteTreeProviderProps extends PropsWithChildren {
51
75
  export function RouteTreeProvider({
52
76
  children,
53
77
  routes,
78
+ access,
54
79
  }: RouteTreeProviderProps): ReactElement {
55
80
  const location = useLocation();
56
81
  const pathname = location.pathname.replace(/\/+$/u, '') || '/';
@@ -74,9 +99,11 @@ export function RouteTreeProvider({
74
99
  }
75
100
  return (
76
101
  <RouteTreeContext.Provider value={routes}>
77
- <RouteSearchContext.Provider value={queries}>
78
- {children}
79
- </RouteSearchContext.Provider>
102
+ <RouteAccessContext.Provider value={access ?? DEFAULT_ACCESS}>
103
+ <RouteSearchContext.Provider value={queries}>
104
+ {children}
105
+ </RouteSearchContext.Provider>
106
+ </RouteAccessContext.Provider>
80
107
  </RouteTreeContext.Provider>
81
108
  );
82
109
  }