@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.
- package/.agents/rules/components.md +3 -1
- package/.agents/rules/notification-templates.md +5 -0
- package/.agents/rules/shell.md +9 -3
- package/AGENTS.md +4 -2
- package/README.MD +11 -5
- package/client/components/back-button.tsx +37 -12
- package/client/components/body-portal.tsx +25 -0
- package/client/components/brand-loading-mark.tsx +5 -2
- package/client/components/brand-mark-geometry.ts +3 -3
- package/client/components/breadcrumbs.tsx +60 -3
- package/client/components/data-table/data-table.tsx +90 -19
- package/client/components/data-table/pagination.tsx +17 -2
- package/client/components/flow-diagram.tsx +2 -2
- package/client/components/list-feedback.tsx +31 -22
- package/client/components/loading.tsx +127 -16
- package/client/components/pending-button.tsx +111 -0
- package/client/components/role-list.tsx +7 -0
- package/client/components/route-child-page.tsx +72 -1
- package/client/components/route-overlay.tsx +149 -15
- package/client/components/task-progress.tsx +155 -0
- package/client/components/use-route-overlay.ts +8 -11
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +62 -32
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +2 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +1 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +1 -0
- package/client/extensions/nocobase-workflow-management/README.md +5 -5
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +2 -9
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +2 -4
- package/client/layouts/app-layout.tsx +8 -8
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +26 -5
- package/client/locales/system/settings-en-US.ts +17 -0
- package/client/locales/system/settings-zh-CN.ts +15 -0
- package/client/locales/system/zh-CN.ts +26 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- package/client/pages/settings/default-access.tsx +1 -1
- package/client/pages/settings/hooks.tsx +3 -68
- package/client/pages/settings/notification-routes.ts +109 -0
- package/client/pages/settings/notification-templates.tsx +26 -0
- package/client/pages/settings/permission-management-page.tsx +15 -90
- package/client/pages/settings/roles.tsx +27 -27
- package/client/pages/settings/routes.ts +107 -101
- package/client/pages/settings/settings-home.tsx +109 -0
- package/client/pages/settings/settings-module-page.tsx +158 -0
- package/client/pages/settings/shell-routes.ts +53 -0
- package/client/pages/settings/user-detail.tsx +3 -17
- package/client/pages/settings/user-form.tsx +9 -15
- package/client/pages/settings/users.tsx +11 -27
- package/client/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +199 -30
- package/client/routing/navigation-destination.ts +42 -0
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +261 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/client/styles.css +18 -1
- package/config.example.yml +1 -1
- package/package.json +13 -13
- package/server/config/auth.ts +1 -1
- package/server/providers/index.ts +2 -0
- package/server/providers/notification-management.ts +27 -0
- package/tests/client/account-permissions.test.tsx +1 -1
- package/tests/client/auth-i18n.test.tsx +1 -1
- package/tests/client/authenticated-client-fixture.test.tsx +37 -0
- package/tests/client/back-button.test.tsx +34 -0
- package/tests/client/client-auth.test.tsx +10 -1
- package/tests/client/client-route-recovery.test.tsx +93 -1
- package/tests/client/client-routes.test.ts +137 -4
- package/tests/client/client-shell.test.tsx +23 -8
- package/tests/client/foundation-integration.test.ts +38 -17
- package/tests/client/layout-i18n.test.tsx +9 -4
- package/tests/client/list-feedback.test.tsx +5 -5
- package/tests/client/list-loading.test.tsx +397 -0
- package/tests/client/loading.test.tsx +20 -11
- package/tests/client/manual-list-retry.test.tsx +2 -2
- package/tests/client/navigation-parent.test.tsx +172 -0
- package/tests/client/pending-button.test.tsx +106 -0
- package/tests/client/permission-management-tabs.test.tsx +458 -17
- package/tests/client/primitive-labels.test.tsx +2 -2
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/role-return.test.tsx +21 -3
- package/tests/client/roles.test.tsx +84 -3
- package/tests/client/route-leave-recheck.test.tsx +74 -0
- package/tests/client/route-presentation.test.tsx +557 -0
- package/tests/client/session-preparation.test.tsx +20 -17
- package/tests/client/settings-continuity.test.tsx +35 -11
- package/tests/client/settings-home.test.tsx +112 -0
- package/tests/client/settings-idle-list.test.tsx +124 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/client/user-drawer.test.tsx +3 -4
- package/tests/fixtures/dialog-footer.html +12 -0
- package/tests/fixtures/dialog-footer.tsx +215 -0
- package/tests/helpers/authenticated-client.tsx +70 -22
- package/tests/playwright/dialog-footer.test.ts +227 -0
- package/tests/server/notification-templates.test.ts +144 -0
- 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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
66
|
-
{
|
|
67
|
-
|
|
68
|
-
<Component />
|
|
69
|
-
</NamespaceScope>
|
|
70
|
-
</ErrorBoundary>
|
|
197
|
+
<RouteOverlayHostBoundary>
|
|
198
|
+
<RouteChildPageHostBoundary>{content}</RouteChildPageHostBoundary>
|
|
199
|
+
</RouteOverlayHostBoundary>
|
|
71
200
|
);
|
|
72
201
|
}
|
|
73
202
|
|
|
74
|
-
|
|
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
|
|
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(
|
|
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=
|
|
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(
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
<
|
|
78
|
-
{
|
|
79
|
-
|
|
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
|
}
|