@nocobase/app-template-default 1.0.0-beta.80 → 1.0.0-beta.81

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.
@@ -148,6 +148,9 @@ const settings = {
148
148
  'Contact an administrator to add users or grant the permissions needed to create users and assign roles.',
149
149
  rolesReadOnlyEmpty:
150
150
  'Contact an administrator to create a role or grant you permission to create roles.',
151
+ noRoleAccess:
152
+ 'This user can still sign in and use account basics. No business access is granted by roles.',
153
+ noAssignedRoles: 'No assigned roles',
151
154
  noRoles: 'No roles yet',
152
155
  create: 'Create',
153
156
  edit: 'Edit',
@@ -136,6 +136,8 @@ const settings = {
136
136
  usersReadOnlyEmpty:
137
137
  '请联系管理员添加用户,或授予创建用户和分配角色所需的权限。',
138
138
  rolesReadOnlyEmpty: '请联系管理员创建角色,或授予你创建角色的权限。',
139
+ noRoleAccess: '此用户仍可登录并使用账号基础功能,角色未授予业务访问。',
140
+ noAssignedRoles: '未分配角色',
139
141
  noRoles: '暂无角色',
140
142
  create: '创建',
141
143
  edit: '编辑',
@@ -174,6 +174,11 @@ export function RoleSelection({
174
174
  </ComboboxList>
175
175
  </ComboboxContent>
176
176
  </Combobox>
177
+ {scope.key === 'app' && selected.length === 0 && (
178
+ <p className='text-sm text-muted-foreground'>
179
+ {t('settings.noRoleAccess')}
180
+ </p>
181
+ )}
177
182
  </div>
178
183
  );
179
184
  }
@@ -223,7 +228,14 @@ function UserEditor(): ReactElement {
223
228
  password: '',
224
229
  };
225
230
  const [roleDraft, setRoleDraft] = useState<Record<string, UserRoleValue>>({});
226
- const roles = { ...(initial?.roleScopes ?? {}), ...roleDraft };
231
+ const defaults = creating
232
+ ? Object.fromEntries(
233
+ (options.data?.data.roleScopes ?? [])
234
+ .filter((scope) => scope.defaultValue !== undefined)
235
+ .map((scope) => [scope.key, scope.defaultValue!]),
236
+ )
237
+ : {};
238
+ const roles = { ...defaults, ...(initial?.roleScopes ?? {}), ...roleDraft };
227
239
  const changedRoles = Object.fromEntries(
228
240
  Object.entries(roleDraft).filter(
229
241
  ([key, value]) =>
@@ -360,7 +360,7 @@ export default function UsersPage(): ReactElement {
360
360
  },
361
361
  );
362
362
  })
363
- .join(', ') || '—',
363
+ .join(', ') || t('settings.noAssignedRoles'),
364
364
  },
365
365
  {
366
366
  id: 'actions',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nocobase/app-template-default",
3
3
  "displayName": "Default Template",
4
- "version": "1.0.0-beta.80",
4
+ "version": "1.0.0-beta.81",
5
5
  "type": "module",
6
6
  "prettier": "@nocobase/dev-config/prettier",
7
7
  "engines": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "nocobase": {
11
11
  "templateKind": "app",
12
- "defaultTemplateVersion": "1.0.0-beta.80"
12
+ "defaultTemplateVersion": "1.0.0-beta.81"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@ai-sdk/react": "4.0.131",
@@ -75,7 +75,7 @@
75
75
  "@types/jsdom": "^30.0.0",
76
76
  "@nocobase/app-client": "^4.0.0-beta.3",
77
77
  "@nocobase/app-plugin-template-print": "0.0.2-beta.2",
78
- "@nocobase/app-skills": "0.1.0-beta.50",
78
+ "@nocobase/app-skills": "0.1.0-beta.51",
79
79
  "@nocobase/app-testing": "0.1.0-beta.6",
80
80
  "@nocobase/app-websocket": "^0.1.0-beta.1",
81
81
  "@nocobase/dev-config": "0.1.0-beta.25",
@@ -142,35 +142,35 @@
142
142
  "hono": "^4.13.2",
143
143
  "@nocobase/app-plugin-agents": "^1.0.0-beta.25",
144
144
  "@nocobase/app-plugin-api-keys": "^1.0.0-beta.18",
145
- "@nocobase/app-plugin-authentication": "^2.0.0-beta.14",
146
- "@nocobase/app-plugin-authorization": "^1.0.0-beta.31",
145
+ "@nocobase/app-plugin-authentication": "^2.0.0-beta.15",
146
+ "@nocobase/app-plugin-authorization": "^1.0.0-beta.32",
147
147
  "@nocobase/app-plugin-database-explorer": "^1.0.0-beta.16",
148
+ "@nocobase/app-plugin-file": "^1.0.0-beta.26",
148
149
  "@nocobase/app-plugin-foundation": "^0.1.0-beta.7",
149
150
  "@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
150
- "@nocobase/app-plugin-file": "^1.0.0-beta.26",
151
151
  "@nocobase/app-plugin-knowledge": "^0.1.0-beta.12",
152
152
  "@nocobase/app-plugin-mail": "^1.0.0-beta.17",
153
153
  "@nocobase/app-plugin-notification": "^2.0.0-beta.0",
154
154
  "@nocobase/app-plugin-notification-in-app": "^2.0.0-beta.0",
155
155
  "@nocobase/app-plugin-notification-providers": "^1.0.0-beta.15",
156
156
  "@nocobase/app-plugin-scheduler": "^1.0.0-beta.20",
157
- "@nocobase/app-plugin-users": "^2.0.0-beta.9",
157
+ "@nocobase/app-plugin-users": "^2.0.0-beta.10",
158
158
  "@nocobase/app-server": "^3.0.0-beta.3",
159
159
  "@nocobase/config": "^0.1.0-beta.3",
160
160
  "@nocobase/db": "^1.0.0-beta.22",
161
161
  "@nocobase/db-sqlite": "^0.1.0-beta.6",
162
162
  "@nocobase/service-provider": "^0.0.2-beta.2",
163
163
  "@nocobase/snowflake": "^1.0.0-beta.4",
164
- "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.15",
165
164
  "@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.15",
165
+ "@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.15",
166
166
  "@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.14",
167
167
  "@nocobase/authorization": "^1.0.0-beta.15",
168
168
  "@nocobase/queue": "^0.1.0-beta.9",
169
169
  "@nocobase/jobs": "^0.1.0-beta.4",
170
170
  "@nocobase/lifecycle": "^0.1.0-beta.3",
171
171
  "@nocobase/i18n": "^2.0.0-beta.1",
172
- "@nocobase/repository-input": "^0.1.0-beta.3",
173
172
  "@nocobase/caching": "^0.1.0-beta.3",
173
+ "@nocobase/repository-input": "^0.1.0-beta.3",
174
174
  "@nocobase/app-cli": "^1.0.0-beta.27",
175
175
  "@nocobase/realtime": "^0.1.0-beta.3"
176
176
  },
@@ -68,6 +68,7 @@ async function renderUsers(
68
68
  actions: string[] = [],
69
69
  initialRoles: Record<string, string[]> = {},
70
70
  requiredRole = false,
71
+ defaultRole = false,
71
72
  ) {
72
73
  let current = { ...original, roleScopes: initialRoles };
73
74
  let roleFailure = false;
@@ -105,7 +106,9 @@ async function renderUsers(
105
106
  label: 'Application',
106
107
  selection: 'multiple',
107
108
  requiredOnCreate: requiredRole,
109
+ ...(defaultRole ? { defaultValue: ['member'] } : {}),
108
110
  options: [
111
+ { value: 'member', label: 'Member' },
109
112
  { value: 'sales', label: 'Sales team' },
110
113
  { value: 'finance', label: 'Finance team' },
111
114
  {
@@ -535,3 +538,48 @@ it('focuses and describes the required role dropdown when creating without a rol
535
538
  view.calls.mock.calls.filter(([call]) => call.method === 'POST'),
536
539
  ).toHaveLength(0);
537
540
  });
541
+
542
+ it('uses Member only as a creation default and respects explicitly cleared selection', async () => {
543
+ const user = userEvent.setup();
544
+ const view = await renderUsers(
545
+ '/users',
546
+ '/create',
547
+ true,
548
+ true,
549
+ [],
550
+ {},
551
+ false,
552
+ true,
553
+ );
554
+ const roles = await screen.findByRole('combobox', {
555
+ name: 'Application',
556
+ exact: true,
557
+ });
558
+ expect(roles).toHaveTextContent('Member');
559
+ await user.click(roles);
560
+ await user.click(
561
+ await screen.findByRole('option', { name: 'Member', exact: true }),
562
+ );
563
+ await user.keyboard('{Escape}');
564
+ expect(roles).not.toHaveTextContent('Member');
565
+ expect(screen.getByText(enUS.settings.noRoleAccess)).toBeVisible();
566
+ await user.type(screen.getByRole('textbox', { name: 'Name *' }), 'Roleless');
567
+ await user.type(
568
+ screen.getByRole('textbox', { name: 'Email *' }),
569
+ 'roleless@example.test',
570
+ );
571
+ await user.type(
572
+ screen.getByLabelText(`${enUS.settings.password} *`),
573
+ 'Roleless-test-123!',
574
+ );
575
+ await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
576
+ await waitFor(() =>
577
+ expect(view.calls).toHaveBeenCalledWith(
578
+ expect.objectContaining({
579
+ method: 'POST',
580
+ path: 'users',
581
+ json: expect.objectContaining({ roleScopes: { app: [] } }),
582
+ }),
583
+ ),
584
+ );
585
+ });
@@ -11,14 +11,7 @@ type Assignment = {
11
11
  function unexpectedGlobalAssignments(
12
12
  assignments: readonly Assignment[],
13
13
  ): Assignment[] {
14
- return assignments.filter(
15
- (assignment) =>
16
- assignment.subjectId === '*' &&
17
- !(
18
- assignment.subjectType === 'authenticated' &&
19
- assignment.permissionSetKey === 'member'
20
- ),
21
- );
14
+ return assignments.filter((assignment) => assignment.subjectId === '*');
22
15
  }
23
16
 
24
17
  const test = createAppTest({
@@ -76,5 +69,13 @@ it('detects wildcard recipients regardless of the role name, while preserving ex
76
69
  global,
77
70
  { ...global, subjectType: 'everyone' },
78
71
  ]),
79
- ).toEqual([global, { ...global, subjectType: 'everyone' }]);
72
+ ).toEqual([
73
+ {
74
+ subjectType: 'authenticated',
75
+ subjectId: '*',
76
+ permissionSetKey: 'member',
77
+ },
78
+ global,
79
+ { ...global, subjectType: 'everyone' },
80
+ ]);
80
81
  });
@@ -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
+ });