@nocobase/app-template-default 1.0.0-beta.87 → 1.0.0-beta.88
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/notification-templates.md +5 -0
- package/.agents/rules/shell.md +2 -2
- package/AGENTS.md +3 -1
- package/README.MD +11 -5
- package/client/components/body-portal.tsx +25 -0
- package/client/components/breadcrumbs.tsx +13 -0
- package/client/components/flow-diagram.tsx +2 -2
- package/client/layouts/app-layout.tsx +8 -4
- package/client/layouts/components/sidebar-footer.tsx +18 -25
- package/client/lib/navigation-transform.ts +156 -22
- package/client/locales/system/en-US.ts +7 -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 +7 -5
- package/client/pages/auth/register.tsx +39 -22
- package/client/pages/auth/shared.tsx +1 -3
- 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/routes.ts +96 -98
- 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/routes.ts +17 -1
- package/client/routing/app-router.tsx +1 -14
- package/client/routing/client-route.tsx +16 -9
- package/client/routing/route-context.tsx +30 -3
- package/client/routing/route-leave-boundary.tsx +249 -0
- package/client/routing/route-navigation.ts +72 -8
- package/client/routing/route-tree.tsx +7 -12
- package/client/runtime.ts +2 -2
- package/config.example.yml +1 -1
- package/package.json +12 -12
- 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/client-auth.test.tsx +10 -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/permission-management-tabs.test.tsx +421 -16
- package/tests/client/registration-feedback.test.tsx +143 -0
- package/tests/client/settings-home.test.tsx +110 -0
- package/tests/client/settings-navigation.test.ts +230 -0
- package/tests/server/notification-templates.test.ts +144 -0
- package/tests/client/permission-management-routes.test.ts +0 -64
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import packageMetadata from '../../package.json' with { type: 'json' };
|
|
2
|
+
import { renderAuthenticatedClient } from '../helpers/authenticated-client.js';
|
|
3
|
+
import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
|
|
4
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import type { ReactNode } from 'react';
|
|
7
|
+
import { Route, Routes, useLocation } from 'react-router';
|
|
8
|
+
import { expect, it } from 'vitest';
|
|
9
|
+
import { Breadcrumbs } from '../../client/components/breadcrumbs.js';
|
|
10
|
+
import SettingsHomePage from '../../client/pages/settings/settings-home.js';
|
|
11
|
+
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
12
|
+
import shellRoutes from '../../client/pages/settings/shell-routes.js';
|
|
13
|
+
import { RouteTreeProvider } from '../../client/routing/route-context.js';
|
|
14
|
+
import { useRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
15
|
+
|
|
16
|
+
const { routes } = resolveAppClientContributions([
|
|
17
|
+
{
|
|
18
|
+
packageName: packageMetadata.name,
|
|
19
|
+
source: 'application',
|
|
20
|
+
routes: [shellRoutes, settingsRoutes],
|
|
21
|
+
},
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function Shell({ children }: { children: ReactNode }) {
|
|
25
|
+
const { denied, loading } = useRouteNavigation(routes);
|
|
26
|
+
return (
|
|
27
|
+
<RouteTreeProvider routes={routes} access={{ denied, loading }}>
|
|
28
|
+
<Breadcrumbs denied={denied} />
|
|
29
|
+
{children}
|
|
30
|
+
</RouteTreeProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Where() {
|
|
35
|
+
return <output aria-label='Route'>{useLocation().pathname}</output>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function mount(
|
|
39
|
+
options: Parameters<typeof renderAuthenticatedClient>[1] = {},
|
|
40
|
+
route = '/settings',
|
|
41
|
+
) {
|
|
42
|
+
return renderAuthenticatedClient(
|
|
43
|
+
<Shell>
|
|
44
|
+
<Routes>
|
|
45
|
+
<Route path='/settings' element={<SettingsHomePage />} />
|
|
46
|
+
<Route path='*' element={<Where />} />
|
|
47
|
+
</Routes>
|
|
48
|
+
</Shell>,
|
|
49
|
+
{ route, ...options },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('groups the modules an administrator may open into categories of cards that open by keyboard', async () => {
|
|
54
|
+
const user = userEvent.setup();
|
|
55
|
+
await mount();
|
|
56
|
+
const account = await screen.findByRole('region', {
|
|
57
|
+
name: 'Accounts and permissions',
|
|
58
|
+
});
|
|
59
|
+
const cards = within(account).getAllByRole('link');
|
|
60
|
+
expect(cards.map((card) => card.textContent)).toEqual([
|
|
61
|
+
'UsersManage accounts, access and active sessions.',
|
|
62
|
+
'Permission managementSet what each role can open and do, and the data it can see.',
|
|
63
|
+
]);
|
|
64
|
+
expect(cards[0]).toHaveAttribute('href', '/main/settings/users');
|
|
65
|
+
expect(cards[1]).toHaveAttribute('href', '/main/settings/roles');
|
|
66
|
+
// Categories without a module this viewer may open are not shown.
|
|
67
|
+
expect(screen.getAllByRole('region')).toHaveLength(1);
|
|
68
|
+
cards[1].focus();
|
|
69
|
+
await user.keyboard('{Enter}');
|
|
70
|
+
await waitFor(() =>
|
|
71
|
+
expect(screen.getByRole('status', { name: 'Route' })).toHaveTextContent(
|
|
72
|
+
'/settings/roles',
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shows a restricted viewer only what they may open, opening a tabbed module on its readable view', async () => {
|
|
78
|
+
await mount({
|
|
79
|
+
member: true,
|
|
80
|
+
grants: [
|
|
81
|
+
{
|
|
82
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
83
|
+
actions: [{ action: 'read' }],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
const account = await screen.findByRole('region', {
|
|
88
|
+
name: 'Accounts and permissions',
|
|
89
|
+
});
|
|
90
|
+
// Categories without a module this viewer may open are not shown.
|
|
91
|
+
expect(screen.getAllByRole('region')).toHaveLength(1);
|
|
92
|
+
const cards = within(account).getAllByRole('link');
|
|
93
|
+
expect(cards).toHaveLength(1);
|
|
94
|
+
expect(cards[0]).toHaveAttribute('href', '/main/settings/default-access');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('explains when there is nothing to manage', async () => {
|
|
98
|
+
await mount({ member: true, locale: 'zh-CN' });
|
|
99
|
+
expect(await screen.findByText('暂时没有你可以管理的设置。')).toBeVisible();
|
|
100
|
+
expect(screen.getByRole('heading', { name: '系统设置' })).toBeVisible();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('links the System settings breadcrumb back to the home from a module view', async () => {
|
|
104
|
+
await mount({}, '/settings/roles');
|
|
105
|
+
const trail = await screen.findByRole('navigation', { name: 'Breadcrumb' });
|
|
106
|
+
expect(
|
|
107
|
+
within(trail).getByRole('link', { name: 'System settings' }),
|
|
108
|
+
).toHaveAttribute('href', '/main/settings');
|
|
109
|
+
expect(within(trail).getByText('Roles and permissions')).toBeVisible();
|
|
110
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineAppRoutes,
|
|
3
|
+
resolveAppClientContributions,
|
|
4
|
+
type AppClientRegisteredRoute,
|
|
5
|
+
} from '@nocobase/app-client/plugins';
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import foundation from '@nocobase/app-plugin-foundation/client/routes';
|
|
8
|
+
import settings from '../../client/pages/settings/routes.js';
|
|
9
|
+
import shell from '../../client/pages/settings/shell-routes.js';
|
|
10
|
+
import { createApplicationRoutes } from '../../client/routes.js';
|
|
11
|
+
import {
|
|
12
|
+
buildRouteNavigation,
|
|
13
|
+
buildSettingsCatalog,
|
|
14
|
+
filterRouteTree,
|
|
15
|
+
selectedNavigationId,
|
|
16
|
+
routeKey,
|
|
17
|
+
} from '../../client/routing/route-navigation.js';
|
|
18
|
+
|
|
19
|
+
const componentLoader = async () => ({ default: () => null });
|
|
20
|
+
const app = '@nocobase/app-template-default';
|
|
21
|
+
|
|
22
|
+
function resolve(...extra: ReturnType<typeof defineAppRoutes>[]) {
|
|
23
|
+
return resolveAppClientContributions([
|
|
24
|
+
{
|
|
25
|
+
packageName: app,
|
|
26
|
+
source: 'application',
|
|
27
|
+
routes: [shell, settings, ...extra],
|
|
28
|
+
},
|
|
29
|
+
]).routes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function find(
|
|
33
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
34
|
+
name: string,
|
|
35
|
+
): AppClientRegisteredRoute {
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
if (route.name === name) return route;
|
|
38
|
+
const nested = route.children && find(route.children, name);
|
|
39
|
+
if (nested) return nested;
|
|
40
|
+
}
|
|
41
|
+
return undefined as unknown as AppClientRegisteredRoute;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const catalog = (
|
|
45
|
+
routes: readonly AppClientRegisteredRoute[],
|
|
46
|
+
denied = new Set<string>(),
|
|
47
|
+
) =>
|
|
48
|
+
buildSettingsCatalog(routes, denied).map((category) => [
|
|
49
|
+
category.route.name,
|
|
50
|
+
category.modules.map((module) => [module.route.name, module.target.path]),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
describe('System settings navigation', () => {
|
|
54
|
+
it('keeps the existing URLs and authorization of the settings pages', () => {
|
|
55
|
+
const routes = resolve();
|
|
56
|
+
expect(
|
|
57
|
+
[
|
|
58
|
+
'settingsHome',
|
|
59
|
+
'settingsUsers',
|
|
60
|
+
'settingsRoles',
|
|
61
|
+
'settingsDefaultAccess',
|
|
62
|
+
].map((name) => {
|
|
63
|
+
const route = find(routes, name);
|
|
64
|
+
return [name, route.path, route.authz];
|
|
65
|
+
}),
|
|
66
|
+
).toEqual([
|
|
67
|
+
['settingsHome', '/settings', 'skip'],
|
|
68
|
+
[
|
|
69
|
+
'settingsUsers',
|
|
70
|
+
'/settings/users',
|
|
71
|
+
{ resource: { type: 'user', id: '*' }, action: 'read' },
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'settingsRoles',
|
|
75
|
+
'/settings/roles',
|
|
76
|
+
{
|
|
77
|
+
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
78
|
+
action: 'read',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'settingsDefaultAccess',
|
|
83
|
+
'/settings/default-access',
|
|
84
|
+
{
|
|
85
|
+
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
86
|
+
action: 'read',
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
]);
|
|
90
|
+
expect(find(routes, 'settingsUsers').id).toBe(`${app}:settingsUsers`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('shows one sidebar entry that opens the home and stays selected on every settings page', () => {
|
|
94
|
+
const routes = resolve();
|
|
95
|
+
const menu = buildRouteNavigation(routes, new Set());
|
|
96
|
+
expect(menu.map(({ route }) => [route.name, route.path])).toEqual([
|
|
97
|
+
['settingsHome', '/settings'],
|
|
98
|
+
]);
|
|
99
|
+
expect(menu[0].children).toEqual([]);
|
|
100
|
+
expect(menu[0].route.navigation?.title).toBe('settings.title');
|
|
101
|
+
const entry = routeKey(menu[0].route);
|
|
102
|
+
for (const path of [
|
|
103
|
+
'/settings',
|
|
104
|
+
'/settings/users',
|
|
105
|
+
'/settings/users/create',
|
|
106
|
+
'/settings/roles/member/edit',
|
|
107
|
+
'/settings/default-access',
|
|
108
|
+
])
|
|
109
|
+
expect(selectedNavigationId(routes, path)).toBe(entry);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('groups modules into categories, opens a tabbed module on its first readable tab and hides empty categories', () => {
|
|
113
|
+
const routes = resolve();
|
|
114
|
+
expect(catalog(routes)).toEqual([
|
|
115
|
+
[
|
|
116
|
+
'settingsAccount',
|
|
117
|
+
[
|
|
118
|
+
['settingsUsers', '/settings/users'],
|
|
119
|
+
['permissionManagement', '/settings/roles'],
|
|
120
|
+
],
|
|
121
|
+
],
|
|
122
|
+
]);
|
|
123
|
+
const roles = find(routes, 'settingsRoles');
|
|
124
|
+
expect(catalog(routes, new Set([routeKey(roles)]))).toEqual([
|
|
125
|
+
[
|
|
126
|
+
'settingsAccount',
|
|
127
|
+
[
|
|
128
|
+
['settingsUsers', '/settings/users'],
|
|
129
|
+
['permissionManagement', '/settings/default-access'],
|
|
130
|
+
],
|
|
131
|
+
],
|
|
132
|
+
]);
|
|
133
|
+
const denied = new Set(
|
|
134
|
+
['settingsUsers', 'settingsRoles', 'settingsDefaultAccess'].map((name) =>
|
|
135
|
+
routeKey(find(routes, name)),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(catalog(routes, denied)).toEqual([]);
|
|
139
|
+
// Without a module to open, the sidebar entry disappears too.
|
|
140
|
+
expect(buildRouteNavigation(routes, denied)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('adds a later module with several views to its category without another sidebar entry', () => {
|
|
144
|
+
const routes = resolve(
|
|
145
|
+
defineAppRoutes([
|
|
146
|
+
{
|
|
147
|
+
parent: 'settingsSystem',
|
|
148
|
+
name: 'notificationManagement',
|
|
149
|
+
navigation: {
|
|
150
|
+
title: 'Notifications',
|
|
151
|
+
description: 'Channels and delivery records.',
|
|
152
|
+
},
|
|
153
|
+
breadcrumb: { title: 'Notifications' },
|
|
154
|
+
children: [
|
|
155
|
+
{
|
|
156
|
+
name: 'notificationLogs',
|
|
157
|
+
path: '/notifications/logs',
|
|
158
|
+
authz: {
|
|
159
|
+
resource: { type: 'page', id: 'notification.logs' },
|
|
160
|
+
action: 'access',
|
|
161
|
+
},
|
|
162
|
+
navigation: { title: 'Delivery logs' },
|
|
163
|
+
componentLoader,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
expect(find(routes, 'notificationLogs').path).toBe(
|
|
170
|
+
'/settings/notifications/logs',
|
|
171
|
+
);
|
|
172
|
+
expect(catalog(routes).at(-1)).toEqual([
|
|
173
|
+
'settingsSystem',
|
|
174
|
+
[['notificationManagement', '/settings/notifications/logs']],
|
|
175
|
+
]);
|
|
176
|
+
expect(buildRouteNavigation(routes, new Set())).toHaveLength(1);
|
|
177
|
+
expect(selectedNavigationId(routes, '/settings/notifications/logs')).toBe(
|
|
178
|
+
routeKey(buildRouteNavigation(routes, new Set())[0].route),
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('places a plugin module registered outside the categories under System', () => {
|
|
183
|
+
const routes = resolveAppClientContributions([
|
|
184
|
+
{ packageName: app, source: 'application', routes: [shell] },
|
|
185
|
+
{
|
|
186
|
+
packageName: '@nocobase/app-plugin-foundation',
|
|
187
|
+
source: 'plugin',
|
|
188
|
+
routes: foundation,
|
|
189
|
+
},
|
|
190
|
+
]).routes;
|
|
191
|
+
// The bare shell: only App Foundation, which only an unrestricted administrator may open.
|
|
192
|
+
const menu = buildRouteNavigation(routes, new Set());
|
|
193
|
+
expect(menu.map(({ route }) => route.name)).toEqual(['settingsHome']);
|
|
194
|
+
expect(catalog(routes)).toEqual([
|
|
195
|
+
['settingsSystem', [['foundation', '/foundation']]],
|
|
196
|
+
]);
|
|
197
|
+
// The signed-in shell receives the tree filtered to its pages; empty categories keep their titles there.
|
|
198
|
+
const signedIn = filterRouteTree(
|
|
199
|
+
routes,
|
|
200
|
+
(route) => route.auth === 'required',
|
|
201
|
+
);
|
|
202
|
+
expect(catalog(signedIn)).toEqual([
|
|
203
|
+
['settingsSystem', [['foundation', '/foundation']]],
|
|
204
|
+
]);
|
|
205
|
+
const page = find(routes, 'foundation');
|
|
206
|
+
expect(page.navigation?.description).toBe('nav.foundationDescription');
|
|
207
|
+
expect(buildRouteNavigation(routes, new Set([routeKey(page)]))).toEqual([]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('shows the notification template view as a System card once an invitation mailer is configured', () => {
|
|
211
|
+
const routes = resolveAppClientContributions([
|
|
212
|
+
{
|
|
213
|
+
packageName: app,
|
|
214
|
+
source: 'application',
|
|
215
|
+
routes: createApplicationRoutes({
|
|
216
|
+
notificationTemplates: { invitationConfigured: true },
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
]).routes;
|
|
220
|
+
expect(catalog(routes)).toEqual([
|
|
221
|
+
[
|
|
222
|
+
'settingsSystem',
|
|
223
|
+
[['notificationManagement', '/settings/notifications/templates']],
|
|
224
|
+
],
|
|
225
|
+
]);
|
|
226
|
+
expect(
|
|
227
|
+
buildRouteNavigation(routes, new Set()).map(({ route }) => route.name),
|
|
228
|
+
).toEqual(['home', 'settingsHome']);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import {
|
|
4
|
+
signIn,
|
|
5
|
+
DEFAULT_ADMIN_CREDENTIALS,
|
|
6
|
+
} from '@nocobase/app-plugin-authentication/testing';
|
|
7
|
+
import { createAppTest } from '@nocobase/app-testing/server';
|
|
8
|
+
import { describe, expect } from 'vitest';
|
|
9
|
+
import { createStandaloneServer } from '../../server/standalone.js';
|
|
10
|
+
|
|
11
|
+
const common = {
|
|
12
|
+
createServer: createStandaloneServer,
|
|
13
|
+
scope: 'test' as const,
|
|
14
|
+
config: {
|
|
15
|
+
app: { publicOrigin: 'http://localhost' },
|
|
16
|
+
spa: {
|
|
17
|
+
indexPath: fileURLToPath(new URL('../../index.html', import.meta.url)),
|
|
18
|
+
},
|
|
19
|
+
auth: {
|
|
20
|
+
secret: 'notification-template-test-secret-at-least-32-characters',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
describe('bare Default', () => {
|
|
25
|
+
const test = createAppTest(common);
|
|
26
|
+
test('keeps the bare Default navigation disabled without an invitation transport', async ({
|
|
27
|
+
app,
|
|
28
|
+
}) => {
|
|
29
|
+
expect(
|
|
30
|
+
app.config.get('client.notificationTemplates.invitationConfigured'),
|
|
31
|
+
).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('configured Default', () => {
|
|
35
|
+
const test = createAppTest({
|
|
36
|
+
...common,
|
|
37
|
+
config: {
|
|
38
|
+
...common.config,
|
|
39
|
+
mailers: {
|
|
40
|
+
default: 'system',
|
|
41
|
+
system: {
|
|
42
|
+
provider: 'smtp',
|
|
43
|
+
host: '127.0.0.1',
|
|
44
|
+
port: 9,
|
|
45
|
+
auth: { user: 'synthetic', pass: 'synthetic-private-password' },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('boots the real Default invitation directory and protects all three endpoints', async ({
|
|
52
|
+
testApp,
|
|
53
|
+
app,
|
|
54
|
+
request,
|
|
55
|
+
}) => {
|
|
56
|
+
expect(
|
|
57
|
+
app.config.get('client.notificationTemplates.invitationConfigured'),
|
|
58
|
+
).toBe(true);
|
|
59
|
+
const page = await testApp.fetch(
|
|
60
|
+
new Request(`http://localhost${testApp.publicBasePath}/`),
|
|
61
|
+
);
|
|
62
|
+
expect(page.status).toBe(200);
|
|
63
|
+
const html = await page.text();
|
|
64
|
+
expect(html).toContain('"invitationConfigured":true');
|
|
65
|
+
expect(html).not.toContain('synthetic-private-password');
|
|
66
|
+
const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
|
|
67
|
+
expect((await request('/notifications/templates')).status).toBe(401);
|
|
68
|
+
const list = await admin.fetch('/notifications/templates');
|
|
69
|
+
expect(list.status).toBe(200);
|
|
70
|
+
const body = await list.json();
|
|
71
|
+
expect(JSON.stringify(body)).not.toContain('synthetic-private-password');
|
|
72
|
+
expect(body.data).toEqual(
|
|
73
|
+
expect.arrayContaining([
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
id: 'users.invitation',
|
|
76
|
+
channels: [
|
|
77
|
+
{ name: 'system', type: 'email', configured: true, enabled: true },
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
const detail = await admin.fetch(
|
|
83
|
+
'/notifications/templates/users.invitation',
|
|
84
|
+
);
|
|
85
|
+
expect(detail.status).toBe(200);
|
|
86
|
+
const logsBefore = await (await admin.fetch('/notifications/logs')).json();
|
|
87
|
+
const preview = await admin.fetch(
|
|
88
|
+
'/notifications/templates/users.invitation/preview',
|
|
89
|
+
{
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
origin: 'http://localhost',
|
|
93
|
+
'content-type': 'application/json',
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ locale: 'en-US' }),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
expect(preview.status).toBe(200);
|
|
99
|
+
expect(JSON.stringify(await preview.json())).toContain('example.invalid');
|
|
100
|
+
expect(await (await admin.fetch('/notifications/logs')).json()).toEqual(
|
|
101
|
+
logsBefore,
|
|
102
|
+
);
|
|
103
|
+
const member = {
|
|
104
|
+
username: 'templateviewer',
|
|
105
|
+
email: 'templateviewer@example.invalid',
|
|
106
|
+
password: 'Synthetic-template-viewer-943!',
|
|
107
|
+
};
|
|
108
|
+
const created = await admin.fetch('/users', {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
origin: 'http://localhost',
|
|
112
|
+
'content-type': 'application/json',
|
|
113
|
+
'Idempotency-Key': `${Date.now()}.${randomUUID()}`,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
...member,
|
|
117
|
+
name: 'Synthetic restricted viewer',
|
|
118
|
+
roleScopes: {},
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
expect(created.status).toBe(201);
|
|
122
|
+
const restricted = await signIn(testApp, member);
|
|
123
|
+
for (const endpoint of [
|
|
124
|
+
'',
|
|
125
|
+
'/users.invitation',
|
|
126
|
+
'/users.invitation/preview',
|
|
127
|
+
]) {
|
|
128
|
+
const response = await restricted.fetch(
|
|
129
|
+
`/notifications/templates${endpoint}`,
|
|
130
|
+
endpoint.endsWith('/preview')
|
|
131
|
+
? {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: {
|
|
134
|
+
origin: 'http://localhost',
|
|
135
|
+
'content-type': 'application/json',
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({ locale: 'en-US' }),
|
|
138
|
+
}
|
|
139
|
+
: undefined,
|
|
140
|
+
);
|
|
141
|
+
expect(response.status).toBe(403);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { resolveAppClientContributions } from '@nocobase/app-client/plugins';
|
|
2
|
-
import { expect, it } from 'vitest';
|
|
3
|
-
import settings from '../../client/pages/settings/routes.js';
|
|
4
|
-
import {
|
|
5
|
-
buildRouteNavigation,
|
|
6
|
-
selectedNavigationId,
|
|
7
|
-
routeKey,
|
|
8
|
-
} from '../../client/routing/route-navigation.js';
|
|
9
|
-
it('groups existing permission pages without changing their URLs or authorization and hides inaccessible children', () => {
|
|
10
|
-
const { routes } = resolveAppClientContributions([
|
|
11
|
-
{ packageName: 'test', source: 'application', routes: [settings] },
|
|
12
|
-
]);
|
|
13
|
-
const group = routes[0].children!.find(
|
|
14
|
-
(route) => route.name === 'permissionManagement',
|
|
15
|
-
)!;
|
|
16
|
-
expect(group.componentLoader).toBeUndefined();
|
|
17
|
-
expect(
|
|
18
|
-
group.children!.map((route) => [route.name, route.path, route.authz]),
|
|
19
|
-
).toEqual([
|
|
20
|
-
[
|
|
21
|
-
'settingsRoles',
|
|
22
|
-
'/settings/roles',
|
|
23
|
-
{
|
|
24
|
-
resource: { type: 'settings', id: 'authorization.permission-sets' },
|
|
25
|
-
action: 'read',
|
|
26
|
-
},
|
|
27
|
-
],
|
|
28
|
-
[
|
|
29
|
-
'settingsDefaultAccess',
|
|
30
|
-
'/settings/default-access',
|
|
31
|
-
{
|
|
32
|
-
resource: { type: 'settings', id: 'authorization.default-access' },
|
|
33
|
-
action: 'read',
|
|
34
|
-
},
|
|
35
|
-
],
|
|
36
|
-
]);
|
|
37
|
-
const visible = buildRouteNavigation(routes, new Set())[0].children.find(
|
|
38
|
-
({ route }) => route.name === 'permissionManagement',
|
|
39
|
-
)!;
|
|
40
|
-
expect(visible.children).toEqual([]);
|
|
41
|
-
expect(visible.route.path).toBe('/settings/roles');
|
|
42
|
-
expect(visible.route.navigation?.title).toBe('settings.permissionManagement');
|
|
43
|
-
expect(selectedNavigationId(routes, '/settings/roles/member/edit')).toBe(
|
|
44
|
-
routeKey(visible.route),
|
|
45
|
-
);
|
|
46
|
-
expect(selectedNavigationId(routes, '/settings/default-access')).toBe(
|
|
47
|
-
routeKey(visible.route),
|
|
48
|
-
);
|
|
49
|
-
const denied = new Set([routeKey(group.children![0])]);
|
|
50
|
-
const onlyDefault = buildRouteNavigation(routes, denied)[0].children.find(
|
|
51
|
-
({ route }) => route.name === 'permissionManagement',
|
|
52
|
-
)!;
|
|
53
|
-
expect(onlyDefault.children).toEqual([]);
|
|
54
|
-
expect(onlyDefault.route.path).toBe('/settings/default-access');
|
|
55
|
-
expect(selectedNavigationId(routes, '/settings/default-access', denied)).toBe(
|
|
56
|
-
routeKey(onlyDefault.route),
|
|
57
|
-
);
|
|
58
|
-
denied.add(routeKey(group.children![1]));
|
|
59
|
-
expect(
|
|
60
|
-
buildRouteNavigation(routes, denied)[0].children.map(
|
|
61
|
-
({ route }) => route.name,
|
|
62
|
-
),
|
|
63
|
-
).toEqual(['settingsUsers']);
|
|
64
|
-
});
|