@nocobase/app-template-default 1.0.0-beta.80 → 1.0.0-beta.82
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/shell.md +2 -0
- package/.agents/rules/workspace.md +9 -0
- package/.prettierignore +3 -0
- package/AGENTS.md +2 -1
- package/README.MD +4 -0
- package/client/components/attachment-list.tsx +589 -0
- package/client/components/back-button.tsx +15 -4
- package/client/components/breadcrumbs.tsx +12 -1
- package/client/extensions/nocobase-file-component-ui/components/file-list.tsx +68 -71
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +26 -12
- package/client/extensions/nocobase-file-component-ui/components/file-preview-field.tsx +6 -6
- package/client/extensions/nocobase-file-component-ui/components/file-thumbnail.tsx +3 -3
- package/client/extensions/nocobase-file-component-ui/components/file-upload-field.tsx +19 -19
- package/client/extensions/nocobase-file-component-ui/components/previewers/file-preview-content.tsx +27 -19
- package/client/extensions/nocobase-file-component-ui/components/previewers/office-open-xml-preview.tsx +12 -8
- package/client/extensions/nocobase-file-component-ui/index.ts +6 -6
- package/client/extensions/nocobase-file-component-ui/lib/message-key.ts +14 -11
- package/client/extensions/nocobase-file-component-ui/locales/de-DE.ts +49 -0
- package/client/extensions/nocobase-file-component-ui/locales/en-US.ts +47 -0
- package/client/extensions/nocobase-file-component-ui/locales/es-ES.ts +48 -0
- package/client/extensions/nocobase-file-component-ui/locales/fr-FR.ts +49 -0
- package/client/extensions/nocobase-file-component-ui/locales/id-ID.ts +46 -0
- package/client/extensions/nocobase-file-component-ui/locales/ja-JP.ts +51 -0
- package/client/extensions/nocobase-file-component-ui/locales/pt-BR.ts +49 -0
- package/client/extensions/nocobase-file-component-ui/locales/vi-VN.ts +47 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-CN.ts +43 -0
- package/client/extensions/nocobase-file-component-ui/locales/zh-TW.ts +43 -0
- package/client/extensions/nocobase-file-component-ui/types.ts +3 -1
- package/client/locales/en-US.ts +2 -0
- package/client/locales/system/settings-en-US.ts +3 -0
- package/client/locales/system/settings-zh-CN.ts +2 -0
- package/client/locales/zh-CN.ts +2 -0
- package/client/pages/settings/role-form.tsx +5 -1
- package/client/pages/settings/user-form.tsx +13 -1
- package/client/pages/settings/users.tsx +1 -1
- package/client/routing/route-context.tsx +33 -1
- package/eslint.config.js +2 -0
- package/package.json +23 -21
- package/scripts/check-packages.mjs +46 -0
- package/tailwind.config.mjs +3 -9
- package/tests/client/list-return-state.test.tsx +202 -0
- package/tests/client/role-return.test.tsx +104 -0
- package/tests/client/user-drawer.test.tsx +48 -0
- package/tests/project/internal-packages.test.ts +98 -0
- package/tests/project/tailwind-sources.test.ts +2 -2
- package/tests/server/authorization-assignment-safety.test.ts +10 -9
- package/tests/server/member-role.test.ts +299 -0
- package/client/extensions/nocobase-file-component-ui/README.md +0 -13
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { expect } from 'vitest';
|
|
3
|
+
import { createAppTest } from '@nocobase/app-testing/server';
|
|
4
|
+
import {
|
|
5
|
+
signIn,
|
|
6
|
+
DEFAULT_ADMIN_CREDENTIALS,
|
|
7
|
+
} from '@nocobase/app-plugin-authentication/testing';
|
|
8
|
+
import { userManagementServiceToken } from '@nocobase/app-plugin-users/server/tokens';
|
|
9
|
+
import {
|
|
10
|
+
authenticationToken,
|
|
11
|
+
userAdministrationServiceToken,
|
|
12
|
+
} from '@nocobase/app-plugin-authentication';
|
|
13
|
+
import { authorizationToken } from '@nocobase/app-plugin-authorization';
|
|
14
|
+
import { createStandaloneServer } from '../../server/standalone.js';
|
|
15
|
+
|
|
16
|
+
const test = createAppTest({
|
|
17
|
+
createServer: createStandaloneServer,
|
|
18
|
+
config: {
|
|
19
|
+
app: { publicOrigin: 'http://localhost' },
|
|
20
|
+
auth: { secret: 'test-auth-secret-at-least-32-characters' },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
const json = (body: unknown, method = 'POST') => ({
|
|
24
|
+
method,
|
|
25
|
+
headers: { 'content-type': 'application/json', origin: 'http://localhost' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('saved roles control direct API access while account basics remain usable', async ({
|
|
30
|
+
testApp,
|
|
31
|
+
app,
|
|
32
|
+
}) => {
|
|
33
|
+
const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
|
|
34
|
+
const sets = app.container.resolve(authorizationToken).permissionSets;
|
|
35
|
+
const grants = [
|
|
36
|
+
{ resource: { type: 'user', id: '*' }, actions: [{ action: 'read' }] },
|
|
37
|
+
];
|
|
38
|
+
await sets.update('member', { key: 'member', grants });
|
|
39
|
+
await sets.create({ key: 'reader', grants });
|
|
40
|
+
const create = async (
|
|
41
|
+
name: string,
|
|
42
|
+
roleScopes?: Record<string, string[]>,
|
|
43
|
+
) => {
|
|
44
|
+
const response = await admin.fetch(
|
|
45
|
+
'/users',
|
|
46
|
+
json({
|
|
47
|
+
name,
|
|
48
|
+
username: name,
|
|
49
|
+
email: `${name}@example.test`,
|
|
50
|
+
password: 'Member-test-123!',
|
|
51
|
+
...(roleScopes === undefined ? {} : { roleScopes }),
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
expect(response.status).toBe(201);
|
|
55
|
+
return (await response.json()).data;
|
|
56
|
+
};
|
|
57
|
+
const member = await create('membercase');
|
|
58
|
+
expect(member.roleScopes.app).toEqual(['member']);
|
|
59
|
+
expect((await create('emptycase', { app: [] })).roleScopes.app).toEqual([]);
|
|
60
|
+
expect(
|
|
61
|
+
(await create('replacecase', { app: ['reader'] })).roleScopes.app,
|
|
62
|
+
).toEqual(['reader']);
|
|
63
|
+
expect(
|
|
64
|
+
(
|
|
65
|
+
await create('appendcase', { app: ['member', 'reader'] })
|
|
66
|
+
).roleScopes.app.sort(),
|
|
67
|
+
).toEqual(['member', 'reader']);
|
|
68
|
+
expect((await create('omittedscope', {})).roleScopes.app).toEqual(['member']);
|
|
69
|
+
let session = await signIn(testApp, {
|
|
70
|
+
username: 'membercase',
|
|
71
|
+
password: 'Member-test-123!',
|
|
72
|
+
});
|
|
73
|
+
expect((await session.fetch('/users')).status).toBe(200);
|
|
74
|
+
expect(
|
|
75
|
+
(
|
|
76
|
+
await admin.fetch(
|
|
77
|
+
`/users/${member.id}`,
|
|
78
|
+
json({ roleScopes: { app: [] } }, 'PATCH'),
|
|
79
|
+
)
|
|
80
|
+
).status,
|
|
81
|
+
).toBe(200);
|
|
82
|
+
expect((await session.fetch('/users')).status).toBe(403);
|
|
83
|
+
expect(
|
|
84
|
+
(
|
|
85
|
+
await admin.fetch(
|
|
86
|
+
`/users/${member.id}`,
|
|
87
|
+
json({ name: 'Updated member' }, 'PATCH'),
|
|
88
|
+
)
|
|
89
|
+
).status,
|
|
90
|
+
).toBe(200);
|
|
91
|
+
expect(
|
|
92
|
+
(await (await admin.fetch(`/users/${member.id}`)).json()).data.roleScopes
|
|
93
|
+
.app,
|
|
94
|
+
).toEqual([]);
|
|
95
|
+
session = await signIn(testApp, {
|
|
96
|
+
username: 'membercase',
|
|
97
|
+
password: 'Member-test-123!',
|
|
98
|
+
});
|
|
99
|
+
expect((await session.fetch('/users')).status).toBe(403);
|
|
100
|
+
expect((await session.fetch('/users/me/preferences')).status).toBe(200);
|
|
101
|
+
expect(
|
|
102
|
+
(
|
|
103
|
+
await session.fetch(
|
|
104
|
+
`/users/${member.id}`,
|
|
105
|
+
json({ roleScopes: { app: ['root'] } }, 'PATCH'),
|
|
106
|
+
)
|
|
107
|
+
).status,
|
|
108
|
+
).toBe(403);
|
|
109
|
+
expect(
|
|
110
|
+
(
|
|
111
|
+
await session.fetch(
|
|
112
|
+
`/users/${admin.user.id}`,
|
|
113
|
+
json({ name: 'Forbidden' }, 'PATCH'),
|
|
114
|
+
)
|
|
115
|
+
).status,
|
|
116
|
+
).toBe(403);
|
|
117
|
+
expect(
|
|
118
|
+
(
|
|
119
|
+
await session.fetch(
|
|
120
|
+
'/auth/change-password',
|
|
121
|
+
json({
|
|
122
|
+
currentPassword: 'Member-test-123!',
|
|
123
|
+
newPassword: 'Changed-test-123!',
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
).status,
|
|
127
|
+
).toBe(200);
|
|
128
|
+
expect((await session.fetch('/auth/sign-out', json({}))).status).toBe(200);
|
|
129
|
+
session = await signIn(testApp, {
|
|
130
|
+
username: 'membercase',
|
|
131
|
+
password: 'Changed-test-123!',
|
|
132
|
+
});
|
|
133
|
+
expect((await session.fetch('/users')).status).toBe(403);
|
|
134
|
+
expect(
|
|
135
|
+
(
|
|
136
|
+
await admin.fetch(
|
|
137
|
+
`/users/${member.id}`,
|
|
138
|
+
json({ roleScopes: { app: ['reader'] } }, 'PATCH'),
|
|
139
|
+
)
|
|
140
|
+
).status,
|
|
141
|
+
).toBe(200);
|
|
142
|
+
expect((await session.fetch('/users')).status).toBe(200);
|
|
143
|
+
const protectedRole = await admin.fetch(
|
|
144
|
+
`/users/${member.id}`,
|
|
145
|
+
json({ roleScopes: { app: ['root'] } }, 'PATCH'),
|
|
146
|
+
);
|
|
147
|
+
expect(protectedRole.status).toBe(400);
|
|
148
|
+
expect((await protectedRole.json()).error.reason).toBe(
|
|
149
|
+
'PROTECTED_ROLE_ASSIGNMENT',
|
|
150
|
+
);
|
|
151
|
+
await expect(
|
|
152
|
+
sets.assign({
|
|
153
|
+
subject: { type: 'authenticated', id: '*' },
|
|
154
|
+
permissionSet: 'member',
|
|
155
|
+
}),
|
|
156
|
+
).rejects.toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('registration and trusted external identity creation receive defaults atomically', async ({
|
|
160
|
+
testApp,
|
|
161
|
+
app,
|
|
162
|
+
connection,
|
|
163
|
+
}) => {
|
|
164
|
+
const response = await testApp.request(
|
|
165
|
+
'/auth/sign-up/email',
|
|
166
|
+
json({
|
|
167
|
+
name: 'Signup',
|
|
168
|
+
email: 'signup@example.test',
|
|
169
|
+
password: 'Signup-test-123!',
|
|
170
|
+
roleScopes: { app: ['root'] },
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
expect(response.status).toBe(200);
|
|
174
|
+
const registered = (await response.json()).user;
|
|
175
|
+
const roles = async (id: string) =>
|
|
176
|
+
(
|
|
177
|
+
await connection.query
|
|
178
|
+
.selectFrom('authorizationPermissionSetAssignments')
|
|
179
|
+
.select('permissionSetKey')
|
|
180
|
+
.where('subjectType', '=', 'user')
|
|
181
|
+
.where('subjectId', '=', id)
|
|
182
|
+
.execute()
|
|
183
|
+
).map((r) => r.permissionSetKey);
|
|
184
|
+
expect(await roles(registered.id)).toEqual(['member']);
|
|
185
|
+
const auth = app.container.resolve(authenticationToken);
|
|
186
|
+
const context = await auth.administrationContext();
|
|
187
|
+
const external = await context.internalAdapter.createUser({
|
|
188
|
+
name: 'External',
|
|
189
|
+
email: 'external@example.test',
|
|
190
|
+
emailVerified: true,
|
|
191
|
+
});
|
|
192
|
+
expect(await roles(external.id)).toEqual(['member']);
|
|
193
|
+
const service = await app.container
|
|
194
|
+
.resolve(userAdministrationServiceToken)
|
|
195
|
+
.createServiceAccount({ name: 'Service verification' });
|
|
196
|
+
expect(await roles(service.id)).toEqual([]);
|
|
197
|
+
const remove = auth.addUserInitializer(async () => {
|
|
198
|
+
throw new Error('Synthetic initialization failure');
|
|
199
|
+
});
|
|
200
|
+
try {
|
|
201
|
+
await expect(
|
|
202
|
+
context.internalAdapter.createUser({
|
|
203
|
+
name: 'Failed',
|
|
204
|
+
email: 'failed@example.test',
|
|
205
|
+
emailVerified: true,
|
|
206
|
+
}),
|
|
207
|
+
).rejects.toThrow('Synthetic initialization failure');
|
|
208
|
+
expect(
|
|
209
|
+
await connection.query
|
|
210
|
+
.selectFrom('user')
|
|
211
|
+
.select('id')
|
|
212
|
+
.where('email', '=', 'failed@example.test')
|
|
213
|
+
.execute(),
|
|
214
|
+
).toEqual([]);
|
|
215
|
+
} finally {
|
|
216
|
+
remove();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('invitations preserve explicit empty roles and existing account decisions', async ({
|
|
221
|
+
testApp,
|
|
222
|
+
app,
|
|
223
|
+
}) => {
|
|
224
|
+
const admin = await signIn(testApp, DEFAULT_ADMIN_CREDENTIALS);
|
|
225
|
+
const users = app.container.resolve(userManagementServiceToken);
|
|
226
|
+
await app.container.resolve(authorizationToken).permissionSets.create({
|
|
227
|
+
key: 'invitation-reader',
|
|
228
|
+
grants: [],
|
|
229
|
+
});
|
|
230
|
+
for (const [name, scopes, expected] of [
|
|
231
|
+
['invite-default', undefined, ['member']],
|
|
232
|
+
['invite-empty', { app: [] }, []],
|
|
233
|
+
['invite-replace', { app: ['invitation-reader'] }, ['invitation-reader']],
|
|
234
|
+
] as const) {
|
|
235
|
+
const [invitation] = await users.invite({
|
|
236
|
+
emails: [`${name}@example.test`],
|
|
237
|
+
invitedBy: admin.user.id,
|
|
238
|
+
origin: 'http://localhost',
|
|
239
|
+
sendEmail: false,
|
|
240
|
+
...(scopes ? { roleScopes: scopes } : {}),
|
|
241
|
+
});
|
|
242
|
+
const token = invitation.inviteUrl!.split('/').at(-1)!;
|
|
243
|
+
const accepted = await users.acceptInvitation({
|
|
244
|
+
token,
|
|
245
|
+
name,
|
|
246
|
+
password: 'Invite-test-123!',
|
|
247
|
+
});
|
|
248
|
+
expect((await users.get(accepted.userId)).roleScopes.app).toEqual(expected);
|
|
249
|
+
await expect(
|
|
250
|
+
users.acceptInvitation({ token, name, password: 'Invite-test-123!' }),
|
|
251
|
+
).rejects.toThrow();
|
|
252
|
+
await users.update(accepted.userId, { roleScopes: { app: [] } });
|
|
253
|
+
const [again] = await users.invite({
|
|
254
|
+
emails: [`${name}@example.test`],
|
|
255
|
+
invitedBy: admin.user.id,
|
|
256
|
+
origin: 'http://localhost',
|
|
257
|
+
sendEmail: false,
|
|
258
|
+
});
|
|
259
|
+
expect(again.outcome).toBe('existingUser');
|
|
260
|
+
expect((await users.get(accepted.userId)).roleScopes.app).toEqual([]);
|
|
261
|
+
}
|
|
262
|
+
const [pending] = await users.invite({
|
|
263
|
+
emails: ['preaccepted@example.test'],
|
|
264
|
+
invitedBy: admin.user.id,
|
|
265
|
+
origin: 'http://localhost',
|
|
266
|
+
sendEmail: false,
|
|
267
|
+
});
|
|
268
|
+
const existing = await users.create({
|
|
269
|
+
name: 'Existing',
|
|
270
|
+
email: 'preaccepted@example.test',
|
|
271
|
+
password: 'Existing-test-123!',
|
|
272
|
+
roleScopes: { app: [] },
|
|
273
|
+
});
|
|
274
|
+
await users.acceptInvitation(
|
|
275
|
+
{
|
|
276
|
+
token: pending.inviteUrl!.split('/').at(-1)!,
|
|
277
|
+
name: 'Existing',
|
|
278
|
+
password: 'Existing-test-123!',
|
|
279
|
+
},
|
|
280
|
+
existing.id,
|
|
281
|
+
);
|
|
282
|
+
expect((await users.get(existing.id)).roleScopes.app).toEqual([]);
|
|
283
|
+
const created = await Promise.all(
|
|
284
|
+
Array.from({ length: 4 }, (_, index) =>
|
|
285
|
+
users.create({
|
|
286
|
+
name: `Concurrent ${index}`,
|
|
287
|
+
email: `concurrent-${index}@example.test`,
|
|
288
|
+
password: 'Concurrent-test-123!',
|
|
289
|
+
...(index % 2 ? { roleScopes: { app: [] } } : {}),
|
|
290
|
+
}),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
expect(created.map((user) => user.roleScopes.app)).toEqual([
|
|
294
|
+
['member'],
|
|
295
|
+
[],
|
|
296
|
+
['member'],
|
|
297
|
+
[],
|
|
298
|
+
]);
|
|
299
|
+
});
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# File Component UI
|
|
2
|
-
|
|
3
|
-
This Registry item installs editable App source for upload, list, thumbnail, and preview controls. The default destination is client/extensions/nocobase-file-component-ui. Import components and UI types from its index.ts.
|
|
4
|
-
|
|
5
|
-
Pass the public ClientFileRepository returned by the App's clientFileRepositoryManagerToken to FileUploadField as repository. Read-only components consume FileRecord arrays and their contentUrl directly; they require no repository prop. See the Registry components section in the plugin's SKILL.md for installation, dependencies, and a complete React example.
|
|
6
|
-
|
|
7
|
-
Upload success is controlled by value/onChange. Temporary progress and error items stay internal. Use onStatusChange to block form submission while uploading or after a failure. Upload cancellation forwards AbortSignal; it does not guarantee rollback after a server commit. removeOnDelete removes metadata only. App-owned business relations and physical cleanup remain the App's responsibility.
|
|
8
|
-
|
|
9
|
-
Preview handles safe raster images, PDF, audio/video and text/Markdown. The content route serves attachments, so PDF is fetched and embedded as a local blob retyped to `application/pdf`; a response carrying an active markup type is reported as a preview failure rather than embedded, because a blob URL is same-origin. DOCX/XLSX/PPTX render locally with lazily loaded `@silurus/ooxml`; legacy Office and OpenDocument formats retain Office Online, which requires an internet-accessible absolute URL and cannot use the App session. Active HTML/SVG/XML and unsafe URL schemes are rejected. Markdown does not execute raw HTML.
|
|
10
|
-
|
|
11
|
-
Follow the plugin Skill's Registry installation and preview sections for the exact viewer dependency, Vite dependency-prebundling exclusion, authentication adapter boundaries and acceptance checks. Same-origin content fetches use session cookies; external fetches omit credentials and require CORS. Bearer-only access requires changes to the App-owned source; setting `contentUrl` to an arbitrary blob URL is not supported. OOXML failures show an error and an optional download action; `download={false}` suppresses that action.
|
|
12
|
-
|
|
13
|
-
There is no public/private record flag or access-token endpoint. The item contains no extension.ts, route, collection, or Server implementation. A private disk does not authorize a content request. Installed copies belong to the App; review upgrades with a three-way merge and preserve customizations. Supply translated labels and adapt additional copy in installed source.
|