@nocobase/app-template-default 1.0.0-beta.73 → 1.0.0-beta.75

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 (38) hide show
  1. package/AGENTS.md +8 -2
  2. package/README.MD +3 -1
  3. package/cli/plugins.ts +2 -1
  4. package/client/components/default-access-editor.tsx +183 -0
  5. package/client/components/permission-editor.tsx +216 -150
  6. package/client/components/role-list.tsx +85 -0
  7. package/client/lib/navigation-transform.ts +29 -1
  8. package/client/locales/system/settings-en-US.ts +74 -2
  9. package/client/locales/system/settings-zh-CN.ts +66 -2
  10. package/client/pages/settings/default-access-form.tsx +499 -0
  11. package/client/pages/settings/default-access-model.ts +154 -0
  12. package/client/pages/settings/default-access.tsx +407 -0
  13. package/client/pages/settings/permission-filters.tsx +43 -37
  14. package/client/pages/settings/permission-management-page.tsx +105 -0
  15. package/client/pages/settings/permissions-model.ts +37 -1
  16. package/client/pages/settings/role-form.tsx +46 -18
  17. package/client/pages/settings/roles.tsx +26 -43
  18. package/client/pages/settings/routes.ts +78 -19
  19. package/client/pages/settings/shared.tsx +3 -0
  20. package/client/pages/settings/users.tsx +1 -1
  21. package/client/plugins.ts +2 -0
  22. package/client/routing/route-navigation.ts +21 -1
  23. package/package.json +25 -23
  24. package/server/plugins.ts +2 -0
  25. package/tests/components/default-access.test.tsx +554 -0
  26. package/tests/components/page-harness.test.tsx +132 -112
  27. package/tests/components/permission-editor.test.tsx +233 -73
  28. package/tests/components/permission-management-tabs.test.tsx +128 -0
  29. package/tests/components/role-list.test.tsx +136 -0
  30. package/tests/components/roles.test.tsx +85 -0
  31. package/tests/components/user-permissions.test.tsx +12 -0
  32. package/tests/logic/authorization-assignment-safety.test.ts +82 -0
  33. package/tests/logic/client-routes.test.ts +8 -0
  34. package/tests/logic/default-access-model.test.ts +167 -0
  35. package/tests/logic/foundation-integration.test.ts +62 -0
  36. package/tests/logic/permission-management-routes.test.ts +64 -0
  37. package/tests/logic/settings-permissions.test.ts +89 -0
  38. package/vitest.config.ts +2 -2
@@ -2,58 +2,53 @@
2
2
  // `useCan`, formats with `useLocale`, raises a toast and opens a child-route `RouteDialog`. The page under test is
3
3
  // defined in this file so the template has one to run; in an application, import the real page and its child routes
4
4
  // instead and keep the setup below.
5
- import { ApiClientError, useApiClient, useToaster } from '@nocobase/app-client';
6
- import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
7
- import { useCan } from '@nocobase/app-plugin-authorization/client';
8
- import { useLocale, useTranslation } from '@nocobase/i18n/client';
9
5
  import {
10
- TestI18nProvider,
11
- createTestI18nRuntime,
12
- } from '@nocobase/i18n/testing';
13
- import { render, screen, waitFor } from '@testing-library/react';
6
+ apiClientToken,
7
+ ApiClientError,
8
+ useApiClient,
9
+ useToaster,
10
+ } from '@nocobase/app-client';
11
+ import {
12
+ AuthenticationProvider,
13
+ authenticationClientToken,
14
+ useAuthentication,
15
+ } from '@nocobase/app-plugin-authentication/client';
16
+ import {
17
+ AuthorizationClient,
18
+ authorizationClientToken,
19
+ useCan,
20
+ type AuthorizationSnapshot,
21
+ } from '@nocobase/app-plugin-authorization/client';
22
+ import {
23
+ answerApi,
24
+ renderWithApp,
25
+ type ApiCall,
26
+ } from '@nocobase/app-testing/client';
27
+ import { useLocale, useTranslation } from '@nocobase/i18n/client';
28
+ import { act, screen, waitFor } from '@testing-library/react';
14
29
  import userEvent from '@testing-library/user-event';
15
- import { type ReactElement, type ReactNode, useEffect, useState } from 'react';
16
- import { createMemoryRouter, Link, Outlet, RouterProvider } from 'react-router';
30
+ import { type ReactElement, useEffect, useState } from 'react';
31
+ import { Link, Outlet, Route, Routes, useLocation } from 'react-router';
17
32
  import { beforeEach, describe, expect, it, vi } from 'vitest';
18
33
 
19
34
  import { RouteDialog } from '#components/route-dialog';
20
35
  import { Button } from '#components/ui/button';
21
36
  import { useRouteOverlay } from '#components/use-route-overlay';
22
37
 
38
+ import packageMetadata from '../../package.json' with { type: 'json' };
23
39
  import enUS from '../../client/locales/en-US.js';
24
40
 
25
- // `vi.mock` factories run before this file's imports and code, so everything a factory uses is created here.
26
- const { api, toaster, permission, refresh } = vi.hoisted(() => ({
27
- // One object for the whole file: a new one per call would restart every effect that depends on `api`.
28
- api: { request: vi.fn() },
29
- // One toaster too, so a test asserts what `show` was called with rather than how Base UI renders it.
30
- toaster: { show: vi.fn(), close: vi.fn() },
31
- // What `useCan` returns; a test sets `can`, `isPending` or `error` before rendering.
32
- permission: {
33
- can: true,
34
- isPending: false,
35
- error: undefined as unknown,
36
- },
37
- refresh: vi.fn(),
38
- }));
39
-
40
- vi.mock('@nocobase/app-client', async (original) => ({
41
- // Keep the real module, so `ApiClientError` stays the class the page checks with `instanceof`.
42
- ...(await original<typeof import('@nocobase/app-client')>()),
43
- useApiClient: () => api,
44
- useToaster: () => toaster,
45
- }));
46
- vi.mock('@nocobase/app-plugin-authentication/client', () => ({
47
- useAuthentication: () => ({ refresh }),
48
- }));
49
- vi.mock('@nocobase/app-plugin-authorization/client', () => ({
50
- useCan: () => ({
51
- can: permission.can,
52
- isPending: permission.isPending,
53
- error: permission.error,
54
- retry: vi.fn(),
55
- }),
56
- }));
41
+ // Substitute the authentication service at its token, keeping the real provider and hooks.
42
+ // Authentication itself is covered by server tests that sign in through the application's real routes.
43
+ const getSession = vi.fn(async () => ({ data: null }));
44
+ let permissions: AuthorizationSnapshot;
45
+ let itemsResponse: unknown;
46
+ const api = vi.fn(({ method, path }: ApiCall) => {
47
+ if (method === 'GET' && path === 'authorization/permissions')
48
+ return { data: permissions };
49
+ if (method === 'GET' && path === 'items') return itemsResponse;
50
+ return new Response(null, { status: 404 });
51
+ });
57
52
 
58
53
  // The inline page's own copy. A real page finds its keys in `client/locales/`; this one brings them, merged over the
59
54
  // application's so the dialog's close button and the shared `status.*` keys resolve as they would there.
@@ -74,18 +69,6 @@ const copy = {
74
69
  },
75
70
  };
76
71
 
77
- // The real runtime, strict: a key the resources lack fails the test instead of rendering as text.
78
- const runtime = await createTestI18nRuntime({
79
- application: {
80
- namespace: '@nocobase/app-template-default',
81
- resources: { ...enUS, ...copy },
82
- },
83
- });
84
-
85
- function I18n({ children }: { readonly children: ReactNode }): ReactElement {
86
- return <TestI18nProvider runtime={runtime}>{children}</TestI18nProvider>;
87
- }
88
-
89
72
  interface Item {
90
73
  readonly id: number;
91
74
  readonly name: string;
@@ -130,7 +113,7 @@ function ItemsPage(): ReactElement {
130
113
  content = (
131
114
  <p role='alert'>
132
115
  {t('status.sessionExpired')}
133
- <Button onClick={() => void refreshSession()}>
116
+ <Button onClick={() => refreshSession()}>
134
117
  {t('actions.signInAgain')}
135
118
  </Button>
136
119
  </p>
@@ -178,7 +161,7 @@ function NewItemFooter(): ReactElement {
178
161
  <Button
179
162
  onClick={() => {
180
163
  toaster.show({ type: 'success', title: t('items.create.success') });
181
- void close();
164
+ close();
182
165
  }}
183
166
  >
184
167
  {t('actions.create')}
@@ -195,56 +178,88 @@ function NewItemPage(): ReactElement {
195
178
  );
196
179
  }
197
180
 
198
- /** The page as the application routes it: the list at /items with the create dialog as its child route. */
199
- function renderAt(url: string) {
200
- const router = createMemoryRouter(
201
- [
202
- {
203
- path: '/items',
204
- element: <ItemsPage />,
205
- children: [{ path: 'new', element: <NewItemPage /> }],
181
+ /** renderWithApp owns the router; declare the page and its child routes inside it. */
182
+ function LocationProbe(): ReactElement {
183
+ const location = useLocation();
184
+ return <span data-testid='location'>{location.pathname}</span>;
185
+ }
186
+
187
+ async function renderAt(route: string) {
188
+ return renderWithApp(
189
+ <AuthenticationProvider>
190
+ <LocationProbe />
191
+ <Routes>
192
+ <Route path='/items' element={<ItemsPage />}>
193
+ <Route path='new' element={<NewItemPage />} />
194
+ </Route>
195
+ </Routes>
196
+ </AuthenticationProvider>,
197
+ {
198
+ route,
199
+ namespace: packageMetadata.name,
200
+ namespaces: { [packageMetadata.name]: { ...enUS, ...copy } },
201
+ fetch: answerApi(api),
202
+ // These services replace the corresponding plugins for this focused page test. Do not register both.
203
+ services: (app) => {
204
+ app.container.instance(authenticationClientToken, { getSession });
205
+ app.container.singleton(
206
+ authorizationClientToken,
207
+ (resolver) =>
208
+ new AuthorizationClient(resolver.resolve(apiClientToken)),
209
+ );
206
210
  },
207
- ],
208
- { initialEntries: [url] },
211
+ },
209
212
  );
210
- render(<RouterProvider router={router} />, { wrapper: I18n });
211
- return router;
212
213
  }
213
214
 
214
215
  describe('page test harness', () => {
215
216
  beforeEach(() => {
216
- api.request.mockReset();
217
- toaster.show.mockReset();
218
- permission.can = true;
219
- permission.isPending = false;
220
- permission.error = undefined;
221
- refresh.mockReset();
217
+ api.mockClear();
218
+ getSession.mockClear();
219
+ permissions = {
220
+ unrestricted: false,
221
+ permissions: [
222
+ { resource: { type: 'composite', id: 'items' }, actions: ['create'] },
223
+ ],
224
+ };
225
+ itemsResponse = { data: [] };
222
226
  });
223
227
 
224
228
  it('loads the list with the request the page is expected to send', async () => {
225
- api.request.mockResolvedValue({
229
+ itemsResponse = {
226
230
  data: [{ id: 1, name: 'Alpha', updatedAt: '2026-01-02T00:00:00Z' }],
227
- });
228
- renderAt('/items');
231
+ };
232
+ await renderAt('/items');
229
233
 
230
- expect(screen.getByRole('status')).toHaveTextContent(
231
- enUS['status.loading'],
232
- );
233
234
  expect(await screen.findByText(/Alpha/)).toBeInTheDocument();
234
- expect(api.request).toHaveBeenCalledWith(
235
- expect.objectContaining({ path: 'items' }),
236
- );
235
+ expect(api).toHaveBeenCalledWith({ method: 'GET', path: 'items' });
236
+ });
237
+
238
+ it('shows loading until the API answers', async () => {
239
+ const response = Promise.withResolvers<unknown>();
240
+ itemsResponse = response.promise;
241
+ await renderAt('/items');
242
+
243
+ try {
244
+ expect(screen.getByRole('status')).toHaveTextContent(
245
+ enUS['status.loading'],
246
+ );
247
+ } finally {
248
+ await act(() => {
249
+ response.resolve({
250
+ data: [{ id: 1, name: 'Alpha', updatedAt: '2026-01-02T00:00:00Z' }],
251
+ });
252
+ });
253
+ }
254
+ expect(await screen.findByText(/Alpha/)).toBeInTheDocument();
237
255
  });
238
256
 
239
257
  it('explains a 403 without offering a retry', async () => {
240
- api.request.mockRejectedValue(
241
- new ApiClientError('Forbidden', {
242
- status: 403,
243
- method: 'GET',
244
- url: '/api/items',
245
- }),
258
+ itemsResponse = Response.json(
259
+ { error: { message: 'Forbidden' } },
260
+ { status: 403 },
246
261
  );
247
- renderAt('/items');
262
+ await renderAt('/items');
248
263
 
249
264
  expect(await screen.findByRole('alert')).toHaveTextContent(
250
265
  copy.items.error.forbidden,
@@ -255,14 +270,11 @@ describe('page test harness', () => {
255
270
  });
256
271
 
257
272
  it('treats a 401 as an ended session, not as a failure to retry', async () => {
258
- api.request.mockRejectedValue(
259
- new ApiClientError('Unauthorized', {
260
- status: 401,
261
- method: 'GET',
262
- url: '/api/items',
263
- }),
273
+ itemsResponse = Response.json(
274
+ { error: { message: 'Unauthorized' } },
275
+ { status: 401 },
264
276
  );
265
- renderAt('/items');
277
+ await renderAt('/items');
266
278
 
267
279
  expect(await screen.findByRole('alert')).toHaveTextContent(
268
280
  copy['status.sessionExpired'],
@@ -270,36 +282,40 @@ describe('page test harness', () => {
270
282
  expect(
271
283
  screen.queryByRole('button', { name: enUS['status.retry'] }),
272
284
  ).not.toBeInTheDocument();
285
+ await waitFor(() => expect(getSession).toHaveBeenCalledTimes(1));
273
286
  await userEvent.click(
274
287
  screen.getByRole('button', { name: copy.actions.signInAgain }),
275
288
  );
276
- expect(refresh).toHaveBeenCalledTimes(1);
289
+ expect(getSession).toHaveBeenCalledTimes(2);
277
290
  });
278
291
 
279
292
  it('shows the create action only with permission', async () => {
280
- api.request.mockResolvedValue({ data: [] });
281
- renderAt('/items');
293
+ await renderAt('/items');
282
294
 
283
- // A Button rendered as a Link (nativeButton={false}) is announced as a button, so query it by that role.
295
+ // A Button rendered as a Link is announced as a button.
284
296
  expect(
285
297
  await screen.findByRole('button', { name: copy.items.create.action }),
286
298
  ).toHaveAttribute('href', '/items/new');
299
+ expect(api).toHaveBeenCalledWith({
300
+ method: 'GET',
301
+ path: 'authorization/permissions',
302
+ });
287
303
  });
288
304
 
289
305
  it('hides the create action without permission', async () => {
290
- permission.can = false;
291
- api.request.mockResolvedValue({ data: [] });
292
- renderAt('/items');
306
+ permissions = { unrestricted: false, permissions: [] };
307
+ const { app } = await renderAt('/items');
293
308
 
294
- await waitFor(() => expect(screen.queryByRole('status')).toBeNull());
309
+ await act(async () => {
310
+ await app.container.resolve(authorizationClientToken).snapshot();
311
+ });
295
312
  expect(
296
313
  screen.queryByRole('button', { name: copy.items.create.action }),
297
314
  ).not.toBeInTheDocument();
298
315
  });
299
316
 
300
317
  it('opens the create dialog from its URL and closes it back to the list', async () => {
301
- api.request.mockResolvedValue({ data: [] });
302
- const router = renderAt('/items/new');
318
+ const view = await renderAt('/items/new');
303
319
 
304
320
  expect(
305
321
  await screen.findByRole('dialog', { name: copy.items.create.title }),
@@ -308,11 +324,15 @@ describe('page test harness', () => {
308
324
  screen.getByRole('button', { name: copy.actions.create }),
309
325
  );
310
326
 
311
- expect(toaster.show).toHaveBeenCalledWith({
312
- type: 'success',
313
- title: copy.items.create.success,
314
- });
315
- await waitFor(() => expect(router.state.location.pathname).toBe('/items'));
327
+ expect(view.toasts()).toEqual([
328
+ expect.objectContaining({
329
+ type: 'success',
330
+ title: copy.items.create.success,
331
+ }),
332
+ ]);
333
+ await waitFor(() =>
334
+ expect(screen.getByTestId('location')).toHaveTextContent(/^\/items$/),
335
+ );
316
336
  await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
317
337
  });
318
338
  });