@nocobase/app-template-default 1.0.0-beta.83 → 1.0.0-beta.85

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 (104) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +8 -2
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/data-table/data-table.tsx +86 -32
  7. package/client/components/default-access-editor.tsx +13 -2
  8. package/client/components/feedback-form.tsx +474 -0
  9. package/client/components/lifecycle-diagram.tsx +10 -10
  10. package/client/components/list-feedback.tsx +174 -0
  11. package/client/components/permission-editor.tsx +419 -226
  12. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  13. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  14. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  15. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  16. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  17. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  18. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  19. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  20. package/client/extensions/nocobase-workflow-management/README.md +3 -1
  21. package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
  22. package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
  23. package/client/hooks/use-list-data.ts +76 -0
  24. package/client/hooks/use-list-location.ts +23 -0
  25. package/client/hooks/use-list-tabs.ts +42 -0
  26. package/client/hooks/use-table-url-state.ts +143 -30
  27. package/client/locales/system/en-US.ts +24 -1
  28. package/client/locales/system/settings-en-US.ts +8 -10
  29. package/client/locales/system/settings-zh-CN.ts +8 -8
  30. package/client/locales/system/zh-CN.ts +22 -1
  31. package/client/pages/settings/default-access-form.tsx +44 -26
  32. package/client/pages/settings/default-access.tsx +100 -93
  33. package/client/pages/settings/hooks.tsx +4 -59
  34. package/client/pages/settings/page-permissions.ts +17 -0
  35. package/client/pages/settings/permissions-model.ts +54 -59
  36. package/client/pages/settings/role-form.tsx +98 -56
  37. package/client/pages/settings/roles.tsx +69 -95
  38. package/client/pages/settings/shared.tsx +43 -33
  39. package/client/pages/settings/user-form.tsx +51 -30
  40. package/client/pages/settings/users.tsx +105 -91
  41. package/client/routing/session-preparation.tsx +10 -3
  42. package/client/runtime.ts +0 -4
  43. package/config.example.yml +24 -0
  44. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  45. package/package.json +24 -21
  46. package/server/app.ts +0 -2
  47. package/server/config/auth.ts +2 -0
  48. package/server/config/concurrency.ts +3 -0
  49. package/server/config/index.ts +3 -0
  50. package/server/config/jobs.ts +1 -13
  51. package/server/config/notification.ts +1 -1
  52. package/server/config/queue.ts +1 -7
  53. package/server/plugins.ts +2 -0
  54. package/server/providers/index.ts +4 -1
  55. package/server/providers/submission.ts +61 -0
  56. package/tests/client/auth-i18n.test.tsx +9 -0
  57. package/tests/client/back-button.test.tsx +3 -3
  58. package/tests/client/data-table.test.tsx +9 -4
  59. package/tests/client/default-access.test.tsx +99 -5
  60. package/tests/client/form-feedback.test.tsx +589 -0
  61. package/tests/client/list-feedback.test.tsx +301 -0
  62. package/tests/client/list-recovery.test.tsx +373 -0
  63. package/tests/client/list-tabs.test.tsx +121 -0
  64. package/tests/client/manual-list-retry.test.tsx +190 -0
  65. package/tests/client/page-permission-navigation.test.ts +8 -0
  66. package/tests/client/permission-editor.test.tsx +191 -21
  67. package/tests/client/permission-filter-binding.test.tsx +1 -1
  68. package/tests/client/permission-workspace.test.ts +13 -0
  69. package/tests/client/role-return.test.tsx +2 -1
  70. package/tests/client/session-preparation.test.tsx +24 -1
  71. package/tests/client/settings-continuity.test.tsx +85 -5
  72. package/tests/client/settings-feedback.test.tsx +32 -15
  73. package/tests/client/settings-permissions.test.ts +35 -0
  74. package/tests/client/settings.test.tsx +2 -1
  75. package/tests/client/table-url-state.test.tsx +117 -3
  76. package/tests/client/user-drawer.test.tsx +2 -1
  77. package/tests/client/user-permissions.test.tsx +30 -5
  78. package/tests/database/mutation-receipts.test.ts +45 -0
  79. package/tests/fixtures/form-feedback/README.md +33 -0
  80. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  81. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  82. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  83. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  84. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  85. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  86. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  87. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  88. package/tests/fixtures/permission-alignment.html +16 -0
  89. package/tests/fixtures/permission-alignment.tsx +124 -0
  90. package/tests/fixtures/submission/README.md +35 -0
  91. package/tests/fixtures/submission/browser.test.ts +478 -0
  92. package/tests/fixtures/submission/migration.ts +61 -0
  93. package/tests/fixtures/submission/page.tsx +138 -0
  94. package/tests/fixtures/submission/playwright.config.ts +11 -0
  95. package/tests/fixtures/submission/routes.ts +208 -0
  96. package/tests/helpers/settings-client.tsx +31 -0
  97. package/tests/playwright/permission-alignment.test.ts +109 -0
  98. package/tests/server/auth-email-config.test.ts +79 -0
  99. package/tests/server/concurrency.test.ts +61 -0
  100. package/tests/server/config.test.ts +34 -14
  101. package/tests/server/member-role.test.ts +7 -1
  102. package/tests/server/submission-settings.test.ts +241 -0
  103. package/client/route-overrides.ts +0 -9
  104. package/client/source-extensions.ts +0 -19
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from 'node:url';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
@@ -33,6 +33,37 @@ describe('application config', () => {
33
33
  await rm(configRoot, { recursive: true, force: true });
34
34
  });
35
35
 
36
+ it('merges the Redis YAML example with the real template defaults', async () => {
37
+ const source = await readFile(
38
+ new URL('../../config.example.yml', import.meta.url),
39
+ 'utf8',
40
+ );
41
+ const example = source.match(/^# jobs:\n(?:#(?: {2,}.*)?\n)*/m)?.[0];
42
+ expect(example).toBeDefined();
43
+ const examplePath = path.join(configRoot, 'redis-example.yml');
44
+ await writeFile(
45
+ examplePath,
46
+ source.replace(example!, example!.replace(/^# ?/gm, '')),
47
+ );
48
+ const runtime = await resolveStandaloneAppRuntime(appRuntime, {
49
+ rootDir: templateRootDir,
50
+ configPath: examplePath,
51
+ env: { AUTH_SECRET: 'test-auth-secret-at-least-32-characters' },
52
+ });
53
+
54
+ expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
55
+ memory: {
56
+ adapter: 'memory',
57
+ persistence: { path: runtime.paths.storage('jobs') },
58
+ },
59
+ default: 'redis',
60
+ redis: {
61
+ adapter: 'redis',
62
+ connection: { host: '127.0.0.1', port: 6379 },
63
+ },
64
+ });
65
+ });
66
+
36
67
  it('assembles module defaults in the runtime', async () => {
37
68
  const runtime = await resolveStandaloneAppRuntime(appRuntime, {
38
69
  rootDir: templateRootDir,
@@ -76,24 +107,12 @@ describe('application config', () => {
76
107
  adapter: 'inMemory',
77
108
  persistence: { path: runtime.paths.storage('queue') },
78
109
  },
79
- redis: {
80
- adapter: 'redis',
81
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
82
- removeOnComplete: { count: 1000 },
83
- removeOnFail: { age: 604_800 },
84
- },
85
110
  });
86
111
  expect(runtime.config.get<AppJobsConfig>('jobs')).toEqual({
87
112
  memory: {
88
113
  adapter: 'memory',
89
114
  persistence: { path: runtime.paths.storage('jobs') },
90
115
  },
91
- redis: {
92
- adapter: 'redis',
93
- connection: { host: '127.0.0.1', port: 6379, db: 0 },
94
- removeOnComplete: { count: 1000 },
95
- removeOnFail: { age: 604_800 },
96
- },
97
116
  });
98
117
  expect(runtime.config.get<AppSessionConfigInput>('session')!.default).toBe(
99
118
  'memory',
@@ -183,7 +202,8 @@ describe('application config', () => {
183
202
  });
184
203
  expect(runtime.config.get('server.port')).toBe(14001);
185
204
  expect(runtime.config.get('server.startLog')).toBe(false);
186
- expect(runtime.config.get('queue.redis.connection.host')).toBe('127.0.0.1');
205
+ expect(runtime.config.get('queue.redis')).toBeUndefined();
206
+ expect(runtime.config.get('queue.memory.adapter')).toBe('inMemory');
187
207
  expect(runtime.config.get('session.stores.redis.host')).toBe('127.0.0.1');
188
208
  expect(runtime.config.get('logging.console.pretty')).toBe(false);
189
209
  expect(runtime.config.get('session.cookie.secure')).toBe(true);
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ const createOperationKey = () => `${Date.now()}.${randomUUID()}`;
1
3
  // @vitest-environment node
2
4
  import { expect } from 'vitest';
3
5
  import { createAppTest } from '@nocobase/app-testing/server';
@@ -22,7 +24,11 @@ const test = createAppTest({
22
24
  });
23
25
  const json = (body: unknown, method = 'POST') => ({
24
26
  method,
25
- headers: { 'content-type': 'application/json', origin: 'http://localhost' },
27
+ headers: {
28
+ 'content-type': 'application/json',
29
+ origin: 'http://localhost',
30
+ 'Idempotency-Key': createOperationKey(),
31
+ },
26
32
  body: JSON.stringify(body),
27
33
  });
28
34
 
@@ -0,0 +1,241 @@
1
+ import { expect } from 'vitest';
2
+ import { createAppTest } from '@nocobase/app-testing/server';
3
+ import {
4
+ signIn,
5
+ DEFAULT_ADMIN_CREDENTIALS,
6
+ } from '@nocobase/app-plugin-authentication/testing';
7
+ import { randomUUID } from 'node:crypto';
8
+ const createOperationKey = () => `${Date.now()}.${randomUUID()}`;
9
+ import { authorizationToken } from '@nocobase/app-plugin-authorization';
10
+ import { createStandaloneServer } from '../../server/standalone.js';
11
+
12
+ const test = createAppTest({
13
+ createServer: createStandaloneServer,
14
+ config: {
15
+ app: { publicOrigin: 'http://localhost' },
16
+ auth: { secret: 'test-auth-secret-at-least-32-characters' },
17
+ },
18
+ });
19
+ const request = (
20
+ input: unknown,
21
+ key = createOperationKey(),
22
+ method = 'POST',
23
+ ): RequestInit => ({
24
+ method,
25
+ headers: {
26
+ 'content-type': 'application/json',
27
+ origin: 'http://localhost',
28
+ 'Idempotency-Key': key,
29
+ },
30
+ body: JSON.stringify(input),
31
+ });
32
+
33
+ test('native standard settings recover committed writes and enforce current permissions', async ({
34
+ testApp,
35
+ app,
36
+ connection,
37
+ }) => {
38
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
39
+ const role = {
40
+ key: 'submission-reader',
41
+ title: 'Submission reader',
42
+ grants: [
43
+ { resource: { type: 'user', id: '*' }, actions: [{ action: 'read' }] },
44
+ ],
45
+ };
46
+ const roleRequest = request(role);
47
+ const firstRole = await admin.fetch(
48
+ '/authorization/permissionSets',
49
+ roleRequest,
50
+ );
51
+ expect(firstRole.status).toBe(201);
52
+ const savedRole = await firstRole.json();
53
+ const replayRole = await admin.fetch(
54
+ '/authorization/permissionSets',
55
+ roleRequest,
56
+ );
57
+ expect(replayRole.status).toBe(201);
58
+ expect(await replayRole.json()).toEqual(savedRole);
59
+ expect(replayRole.headers.get('Idempotency-Replayed')).toBe('true');
60
+ const account = {
61
+ name: 'Synthetic reader',
62
+ username: 'submissionreader',
63
+ email: 'submission-reader@example.test',
64
+ password: 'Submission-test-123!',
65
+ roleScopes: { app: [role.key] },
66
+ };
67
+ const accountRequest = request(account);
68
+ const firstUser = await admin.fetch('/users', accountRequest);
69
+ expect(firstUser.status).toBe(201);
70
+ const savedUser = await firstUser.json();
71
+ const replayUser = await admin.fetch('/users', accountRequest);
72
+ expect(replayUser.status).toBe(201);
73
+ expect(await replayUser.json()).toEqual(savedUser);
74
+ expect(replayUser.headers.get('Idempotency-Replayed')).toBe('true');
75
+ expect(
76
+ await connection
77
+ .repository('user')
78
+ .count({ filter: { username: account.username } }),
79
+ ).toBe(1);
80
+ const reader = await signIn(testApp, {
81
+ username: account.username,
82
+ password: account.password,
83
+ });
84
+ expect((await reader.fetch('/users')).status).toBe(200);
85
+ expect(
86
+ (
87
+ await reader.fetch(
88
+ '/users',
89
+ request({
90
+ ...account,
91
+ username: 'denied',
92
+ email: 'denied@example.test',
93
+ }),
94
+ )
95
+ ).status,
96
+ ).toBe(403);
97
+ const updateRequest = request(
98
+ {
99
+ title: 'Renamed reader',
100
+ grants: role.grants,
101
+ expectedRevision: savedRole.data.revision,
102
+ },
103
+ createOperationKey(),
104
+ 'PATCH',
105
+ );
106
+ const firstUpdate = await admin.fetch(
107
+ `/authorization/permissionSets/${role.key}`,
108
+ updateRequest,
109
+ );
110
+ expect(firstUpdate.status).toBe(200);
111
+ const updateResult = await firstUpdate.json();
112
+ const replayUpdate = await admin.fetch(
113
+ `/authorization/permissionSets/${role.key}`,
114
+ updateRequest,
115
+ );
116
+ expect(replayUpdate.status).toBe(200);
117
+ expect(await replayUpdate.json()).toEqual(updateResult);
118
+ const userUpdate = request(
119
+ { name: 'Updated synthetic reader', roleScopes: { app: [role.key] } },
120
+ createOperationKey(),
121
+ 'PATCH',
122
+ );
123
+ const firstEdit = await admin.fetch(
124
+ `/users/${savedUser.data.id}`,
125
+ userUpdate,
126
+ );
127
+ expect(firstEdit.status).toBe(200);
128
+ const replayEdit = await admin.fetch(
129
+ `/users/${savedUser.data.id}`,
130
+ userUpdate,
131
+ );
132
+ expect(replayEdit.status).toBe(200);
133
+ expect(await replayEdit.json()).toEqual(await firstEdit.json());
134
+ const missingKey = request({
135
+ ...account,
136
+ username: 'missing-key',
137
+ email: 'missing-key@example.test',
138
+ });
139
+ delete (missingKey.headers as Record<string, string>)['Idempotency-Key'];
140
+ expect((await admin.fetch('/users', missingKey)).status).toBe(400);
141
+ // A settings editor loses its grant after committing: possession of the old operation key cannot restore it.
142
+ const authz = app.container.resolve(authorizationToken);
143
+ await authz.permissionSets.create({
144
+ key: 'submission-editor',
145
+ grants: [
146
+ {
147
+ resource: { type: 'settings', id: 'authorization.permission-sets' },
148
+ actions: [{ action: 'read' }, { action: 'create' }],
149
+ },
150
+ ],
151
+ });
152
+ await authz.permissionSets.replaceSubjectAssignments({
153
+ subject: { type: 'user', id: savedUser.data.id },
154
+ managedPermissionSets: ['submission-editor'],
155
+ permissionSets: ['submission-editor'],
156
+ });
157
+ const editorRequest = request({ key: 'created-by-editor', grants: [] });
158
+ expect(
159
+ (await reader.fetch('/authorization/permissionSets', editorRequest)).status,
160
+ ).toBe(201);
161
+ await authz.permissionSets.replaceSubjectAssignments({
162
+ subject: { type: 'user', id: savedUser.data.id },
163
+ managedPermissionSets: ['submission-editor'],
164
+ permissionSets: [],
165
+ });
166
+ expect(
167
+ (await reader.fetch('/authorization/permissionSets', editorRequest)).status,
168
+ ).toBe(403);
169
+ for (const [action, body, expected] of [
170
+ ['resetPassword', { password: 'Changed-test-123!' }, 204],
171
+ ['disable', {}, 200],
172
+ ['enable', {}, 200],
173
+ ['revokeSessions', {}, 204],
174
+ ] as const) {
175
+ const init = request(body);
176
+ const path = `/users/${savedUser.data.id}/${action}`;
177
+ const first = await admin.fetch(path, init);
178
+ expect(first.status).toBe(expected);
179
+ const second = await admin.fetch(path, init);
180
+ expect(second.status).toBe(expected);
181
+ expect(await second.text()).toEqual(await first.text());
182
+ expect(second.headers.get('Idempotency-Replayed')).toBe('true');
183
+ }
184
+ const deleteRole = {
185
+ ...request(null, createOperationKey(), 'DELETE'),
186
+ body: undefined,
187
+ };
188
+ expect(
189
+ (
190
+ await admin.fetch(
191
+ '/authorization/permissionSets/created-by-editor',
192
+ deleteRole,
193
+ )
194
+ ).status,
195
+ ).toBe(204);
196
+ const deletedRoleReplay = await admin.fetch(
197
+ '/authorization/permissionSets/created-by-editor',
198
+ deleteRole,
199
+ );
200
+ expect(deletedRoleReplay.status).toBe(204);
201
+ expect(deletedRoleReplay.headers.get('Idempotency-Replayed')).toBe('true');
202
+ });
203
+
204
+ test('default-access create update and delete replay without repeating preconditions', async ({
205
+ testApp,
206
+ app,
207
+ connection,
208
+ }) => {
209
+ const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
210
+ await connection.builder.createCollection('receiptExamples', (c) => {
211
+ c.string('id').primary();
212
+ });
213
+ app.container
214
+ .resolve(authorizationToken)
215
+ .database.collections.add({ name: 'receiptExamples' });
216
+ const rule = {
217
+ key: 'receipt-default',
218
+ resource: { type: 'database.collection', id: 'receiptExamples' },
219
+ actions: [{ action: 'read', selection: { type: 'all' } }],
220
+ };
221
+ for (const [path, init, expected] of [
222
+ ['/authorization/defaultAccess', request(rule), 201],
223
+ [
224
+ '/authorization/defaultAccess/receipt-default',
225
+ request({ actions: [] }, createOperationKey(), 'PATCH'),
226
+ 200,
227
+ ],
228
+ [
229
+ '/authorization/defaultAccess/receipt-default',
230
+ { ...request(null, createOperationKey(), 'DELETE'), body: undefined },
231
+ 204,
232
+ ],
233
+ ] as const) {
234
+ const first = await admin.fetch(path, init);
235
+ expect(first.status).toBe(expected);
236
+ const replay = await admin.fetch(path, init);
237
+ expect(replay.status).toBe(expected);
238
+ expect(await replay.text()).toEqual(await first.text());
239
+ expect(replay.headers.get('Idempotency-Replayed')).toBe('true');
240
+ }
241
+ });
@@ -1,9 +0,0 @@
1
- import {
2
- defineClientRouteComponentOverrides,
3
- type AppClientRouteComponentOverrideDefinition,
4
- } from '@nocobase/app-client/plugins';
5
-
6
- export const routeComponentOverrides: readonly AppClientRouteComponentOverrideDefinition[] =
7
- defineClientRouteComponentOverrides([]);
8
-
9
- export default routeComponentOverrides;
@@ -1,19 +0,0 @@
1
- import type { AppClientSourceExtension } from '@nocobase/app-client/plugins';
2
-
3
- interface AppClientSourceExtensionModule {
4
- readonly default: AppClientSourceExtension;
5
- }
6
-
7
- const extensionModules = import.meta.glob<AppClientSourceExtensionModule>(
8
- './extensions/*/extension.ts',
9
- { eager: true },
10
- );
11
-
12
- export const sourceExtensions: readonly AppClientSourceExtension[] =
13
- Object.freeze(
14
- Object.entries(extensionModules)
15
- .sort(([left], [right]) => left.localeCompare(right))
16
- .map(([, module]) => module.default),
17
- );
18
-
19
- export default sourceExtensions;