@nocobase/app-template-default 1.0.0-beta.74 → 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.
- package/AGENTS.md +7 -1
- package/README.MD +1 -1
- package/cli/plugins.ts +2 -1
- package/client/components/role-list.tsx +52 -28
- package/client/lib/navigation-transform.ts +2 -1
- package/client/locales/system/settings-en-US.ts +1 -0
- package/client/locales/system/settings-zh-CN.ts +1 -0
- package/client/pages/settings/roles.tsx +3 -0
- package/client/plugins.ts +2 -0
- package/package.json +14 -12
- package/server/plugins.ts +2 -0
- package/tests/components/role-list.test.tsx +73 -8
- package/tests/components/roles.test.tsx +14 -7
- package/tests/logic/authorization-assignment-safety.test.ts +82 -0
- package/tests/logic/foundation-integration.test.ts +62 -0
package/AGENTS.md
CHANGED
|
@@ -53,7 +53,7 @@ A feature with a page and an API touches five places: a migration for the table,
|
|
|
53
53
|
|
|
54
54
|
## System settings are part of feature delivery
|
|
55
55
|
|
|
56
|
-
A bare create-app
|
|
56
|
+
A bare create-app shows Home to signed-in users and System settings → App foundation only to unrestricted administrators. The Foundation plugin owns this initial settings group; the navigation transform merges its entry into the application settings group when business administration is registered. As the application develops business capabilities, inspect its current state on every iteration and automatically register the prepared Users and Roles and permissions contribution from `client/pages/settings/routes.ts` once in `client/routes.ts`; preserve it and reconcile missing management surfaces on every iteration, without a first-build flag or one-time setup stage. Whenever an application adds lifecycles, workflows, approvals, notifications, files or other configurable capabilities, automatically include their real administration surfaces without requiring a second user request. Follow [system settings delivery](.agents/skills/nocobase-app-development/references/system-settings.md); in particular, every used process includes a definitions directory, standard diagram, supported parameters and authorized execution history. Do not add demo plugins or empty settings for unused capabilities. The template preinstalls `#components/lifecycle-diagram` (React Flow + ELK) and `client/extensions/nocobase-workflow-management/`: use them by default, preserve their visuals unless the user explicitly requests another style, and connect authorized parameter bindings through `LifecycleDiagram.configuration` to the real editor. These are UI Registry sources; an npm package lookup is not a component availability check.
|
|
57
57
|
|
|
58
58
|
## Rules that apply everywhere
|
|
59
59
|
|
|
@@ -123,3 +123,9 @@ When the built-in mechanism genuinely cannot express what is being asked, changi
|
|
|
123
123
|
| [.agents/rules/components.md](.agents/rules/components.md) | Adding or changing a component, a shadcn primitive, a UI Library item, or the visual design |
|
|
124
124
|
| [.agents/rules/shell.md](.agents/rules/shell.md) | Changing layouts, the header, sidebar, breadcrumbs, the account menu or loading indicators |
|
|
125
125
|
| [.agents/rules/build-and-deploy.md](.agents/rules/build-and-deploy.md) | Changing the build, packing a deployment, the Docker image, `CDN_BASE_URL`, or building for another platform |
|
|
126
|
+
|
|
127
|
+
## Visible and manageable role assignments
|
|
128
|
+
|
|
129
|
+
Assign application business roles to explicitly identified users through the prepared User management role-assignment UI. Never seed a business permission set onto `authenticated:*`, broaden the inherited platform `member` role, or write startup reconciliation that grants access to everyone merely to make a feature usable. Unchecked actions in one role do not revoke grants inherited from another. If the user explicitly requests a global default role, first deliver a permission-protected administration control that shows its recipients and allows changing or removing the assignment; show inherited roles and their source in user access details. Without that UI, use explicit user assignments and explain the limitation. Keep page access independent from business operations.
|
|
130
|
+
|
|
131
|
+
Run `pnpm exec vitest run tests/logic/authorization-assignment-safety.test.ts` after permission initialization changes and before delivery. It boots the real application on an isolated database and inspects initialized wildcard assignments and the inherited member role. Keep this test in generated applications. An intentional global default requires explicit user authorization, a working administration surface and corresponding acceptance tests before deliberately updating the expected baseline; do not delete, skip or loosen the guard to make tests pass. Test an ordinary user with only the configured role: inspect all effective grant sources and verify both hidden buttons and rejected direct API calls.
|
package/README.MD
CHANGED
|
@@ -6,7 +6,7 @@ You own everything in this directory. Add pages, API routes, database tables, an
|
|
|
6
6
|
|
|
7
7
|
## Default and Examples
|
|
8
8
|
|
|
9
|
-
Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned business API routes start empty, and new databases receive no article sample data. A bare create-app shows Home
|
|
9
|
+
Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned business API routes start empty, and new databases receive no article sample data. A bare create-app shows Home and, for unrestricted administrators only, System settings → App foundation. Foundation is installed and registered on both Client and Server; its read-only overview is available at `/foundation` under the application base path. As business capabilities are developed, the app development skill reconciles missing core administration on every iteration and registers the prepared Users and Roles and permissions contribution (`client/pages/settings/routes.ts`); each actually used configurable capability includes its administration as part of feature delivery. Its built-in Users integration lists direct Authorization Permission Sets as application roles, while access assigned to all authenticated users remains separate. Use `app-template-examples` for article management and runnable plugin demonstrations.
|
|
10
10
|
|
|
11
11
|
Existing Default installations retain their data when example plugins are unregistered. If an installation already ran the article migration, retain its original migration sources as described in the [upgrade migration rules](.agents/skills/nocobase-app-upgrade/references/edge-cases.md#migrations) before upgrading. Do not erase migration history or delete existing data to make an upgrade start.
|
|
12
12
|
|
package/cli/plugins.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineCliPlugins, type AppCliPlugins } from '@nocobase/app-cli';
|
|
2
|
+
import foundation from '@nocobase/app-plugin-foundation/cli';
|
|
2
3
|
import scheduler from '@nocobase/app-plugin-scheduler/cli';
|
|
3
4
|
|
|
4
5
|
// Array order is command registration order. A plugin contributes its commands
|
|
5
6
|
// by appearing in this list; removing its entry and its import removes them.
|
|
6
|
-
const cliPlugins: AppCliPlugins = defineCliPlugins([scheduler]);
|
|
7
|
+
const cliPlugins: AppCliPlugins = defineCliPlugins([scheduler, foundation]);
|
|
7
8
|
|
|
8
9
|
export default cliPlugins;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useMemo, type ReactElement } from 'react';
|
|
2
|
+
import type { ColumnDef } from '@tanstack/react-table';
|
|
2
3
|
import { Link, type To } from 'react-router';
|
|
4
|
+
import { DataTable, DataTableColumnHeader } from '#components/data-table';
|
|
3
5
|
import { Badge } from '#components/ui/badge';
|
|
4
6
|
|
|
5
7
|
export interface RoleListRole {
|
|
@@ -12,50 +14,72 @@ export interface RoleListRole {
|
|
|
12
14
|
export interface RoleListProps {
|
|
13
15
|
readonly roles: readonly RoleListRole[];
|
|
14
16
|
readonly labels: {
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly key: string;
|
|
19
|
+
readonly type: string;
|
|
15
20
|
readonly protected: string;
|
|
16
21
|
readonly custom: string;
|
|
17
22
|
};
|
|
18
23
|
readonly ariaLabel?: string;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
/**
|
|
26
|
+
/** Standard role table; the host owns authorization, loading and filtering. */
|
|
22
27
|
export function RoleList({
|
|
23
28
|
roles,
|
|
24
29
|
labels,
|
|
25
30
|
ariaLabel,
|
|
26
31
|
}: RoleListProps): ReactElement {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
className='group/role grid min-h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-0.5 px-3 py-2 transition-colors hover:bg-muted/50 @xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_6rem]'
|
|
36
|
-
>
|
|
32
|
+
const columns = useMemo<ColumnDef<RoleListRole>[]>(
|
|
33
|
+
() => [
|
|
34
|
+
{
|
|
35
|
+
accessorKey: 'title',
|
|
36
|
+
header: ({ column }) => (
|
|
37
|
+
<DataTableColumnHeader column={column} title={labels.name} />
|
|
38
|
+
),
|
|
39
|
+
cell: ({ row }) => (
|
|
37
40
|
<Link
|
|
38
|
-
to={
|
|
39
|
-
className='-
|
|
40
|
-
title={
|
|
41
|
+
to={row.original.to}
|
|
42
|
+
className='inline-block max-w-72 truncate align-middle font-medium text-foreground'
|
|
43
|
+
title={row.original.title}
|
|
41
44
|
>
|
|
42
|
-
{
|
|
45
|
+
{row.original.title}
|
|
43
46
|
</Link>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
accessorKey: 'key',
|
|
51
|
+
header: ({ column }) => (
|
|
52
|
+
<DataTableColumnHeader column={column} title={labels.key} />
|
|
53
|
+
),
|
|
54
|
+
cell: ({ row }) => (
|
|
44
55
|
<span
|
|
45
|
-
className='
|
|
46
|
-
title={
|
|
56
|
+
className='inline-block max-w-64 truncate align-middle text-muted-foreground'
|
|
57
|
+
title={row.original.key}
|
|
47
58
|
>
|
|
48
|
-
{
|
|
59
|
+
{row.original.key}
|
|
49
60
|
</span>
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
accessorKey: 'protected',
|
|
65
|
+
header: labels.type,
|
|
66
|
+
cell: ({ row }) => (
|
|
67
|
+
<Badge variant='secondary'>
|
|
68
|
+
{row.original.protected ? labels.protected : labels.custom}
|
|
56
69
|
</Badge>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
),
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
[labels],
|
|
74
|
+
);
|
|
75
|
+
return (
|
|
76
|
+
<div role='region' aria-label={ariaLabel}>
|
|
77
|
+
<DataTable
|
|
78
|
+
columns={columns}
|
|
79
|
+
data={roles}
|
|
80
|
+
getRowId={(role) => role.key}
|
|
81
|
+
showSelectedCount={false}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
60
84
|
);
|
|
61
85
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { groupFoundationNavigation } from '@nocobase/app-plugin-foundation/client';
|
|
1
2
|
import type { RouteNavigationItem } from '../routing/route-navigation.js';
|
|
2
3
|
|
|
3
4
|
/** Application-owned navigation customization, applied after permission filtering. */
|
|
4
5
|
export function transformNavigation(
|
|
5
6
|
items: RouteNavigationItem[],
|
|
6
7
|
): RouteNavigationItem[] {
|
|
7
|
-
return collapsePermissionNavigation(items);
|
|
8
|
+
return groupFoundationNavigation(collapsePermissionNavigation(items));
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
/** One sidebar entry opens the first readable tab; original routes and guards remain unchanged. */
|
|
@@ -104,6 +104,9 @@ export default function RolesPage(): ReactElement {
|
|
|
104
104
|
protected: Boolean(role.protection),
|
|
105
105
|
}))}
|
|
106
106
|
labels={{
|
|
107
|
+
name: t('settings.roleName'),
|
|
108
|
+
key: t('settings.key'),
|
|
109
|
+
type: t('settings.roleType'),
|
|
107
110
|
protected: t('settings.protected'),
|
|
108
111
|
custom: t('settings.custom'),
|
|
109
112
|
}}
|
package/client/plugins.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import foundation from '@nocobase/app-plugin-foundation/client';
|
|
1
2
|
import defaultAccess from '@nocobase/app-plugin-authz-default-access/client';
|
|
2
3
|
import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/client';
|
|
3
4
|
import restrictionRules from '@nocobase/app-plugin-authz-restriction-rules/client';
|
|
@@ -34,6 +35,7 @@ const clientPlugins: AppClientPlugins = defineClientPlugins([
|
|
|
34
35
|
file(),
|
|
35
36
|
scheduler(),
|
|
36
37
|
mail(),
|
|
38
|
+
foundation({ settingsGroup: true }),
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
export default clientPlugins;
|
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.
|
|
4
|
+
"version": "1.0.0-beta.75",
|
|
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.
|
|
12
|
+
"defaultTemplateVersion": "1.0.0-beta.75"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@ai-sdk/react": "4.0.131",
|
|
@@ -72,10 +72,10 @@
|
|
|
72
72
|
"tar": "^7.5.22",
|
|
73
73
|
"@xyflow/react": "^12.11.5",
|
|
74
74
|
"elkjs": "^0.10.2",
|
|
75
|
-
"@nocobase/app-client": "^4.0.0-beta.
|
|
75
|
+
"@nocobase/app-client": "^4.0.0-beta.1",
|
|
76
76
|
"@nocobase/app-plugin-template-print": "0.0.2-beta.2",
|
|
77
|
-
"@nocobase/app-skills": "0.1.0-beta.
|
|
78
|
-
"@nocobase/app-testing": "0.1.0-beta.
|
|
77
|
+
"@nocobase/app-skills": "0.1.0-beta.43",
|
|
78
|
+
"@nocobase/app-testing": "0.1.0-beta.6",
|
|
79
79
|
"@nocobase/app-websocket": "^0.1.0-beta.1",
|
|
80
80
|
"@nocobase/dev-config": "0.1.0-beta.24",
|
|
81
81
|
"@nocobase/drive": "^0.1.0-beta.7",
|
|
@@ -139,28 +139,29 @@
|
|
|
139
139
|
"@oclif/core": "^4.14.0",
|
|
140
140
|
"better-auth": "1.7.5",
|
|
141
141
|
"hono": "^4.13.2",
|
|
142
|
-
"@nocobase/app-plugin-agents": "^1.0.0-beta.
|
|
142
|
+
"@nocobase/app-plugin-agents": "^1.0.0-beta.17",
|
|
143
143
|
"@nocobase/app-plugin-api-keys": "^1.0.0-beta.18",
|
|
144
|
+
"@nocobase/app-plugin-authentication": "^2.0.0-beta.12",
|
|
144
145
|
"@nocobase/app-plugin-authorization": "^1.0.0-beta.30",
|
|
145
146
|
"@nocobase/app-plugin-database-explorer": "^1.0.0-beta.16",
|
|
146
|
-
"@nocobase/app-plugin-authentication": "^2.0.0-beta.12",
|
|
147
147
|
"@nocobase/app-plugin-file": "^1.0.0-beta.26",
|
|
148
|
+
"@nocobase/app-plugin-foundation": "^0.1.0-beta.0",
|
|
148
149
|
"@nocobase/app-plugin-i18n": "^1.0.0-beta.18",
|
|
149
|
-
"@nocobase/app-plugin-
|
|
150
|
+
"@nocobase/app-plugin-knowledge": "^0.1.0-beta.9",
|
|
150
151
|
"@nocobase/app-plugin-notification": "^1.0.0-beta.29",
|
|
151
152
|
"@nocobase/app-plugin-notification-in-app": "^1.0.0-beta.27",
|
|
152
153
|
"@nocobase/app-plugin-notification-providers": "^0.2.0-beta.14",
|
|
153
154
|
"@nocobase/app-plugin-scheduler": "^1.0.0-beta.19",
|
|
154
|
-
"@nocobase/app-plugin-users": "^2.0.0-beta.
|
|
155
|
-
"@nocobase/app-server": "^3.0.0-beta.
|
|
155
|
+
"@nocobase/app-plugin-users": "^2.0.0-beta.8",
|
|
156
|
+
"@nocobase/app-server": "^3.0.0-beta.1",
|
|
156
157
|
"@nocobase/config": "^0.1.0-beta.3",
|
|
157
158
|
"@nocobase/db": "^1.0.0-beta.21",
|
|
158
159
|
"@nocobase/db-sqlite": "^0.1.0-beta.6",
|
|
159
160
|
"@nocobase/service-provider": "^0.0.2-beta.2",
|
|
160
161
|
"@nocobase/snowflake": "^1.0.0-beta.4",
|
|
162
|
+
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.15",
|
|
161
163
|
"@nocobase/app-plugin-authz-sharing-rules": "^1.0.0-beta.15",
|
|
162
164
|
"@nocobase/app-plugin-authz-restriction-rules": "^1.0.0-beta.14",
|
|
163
|
-
"@nocobase/app-plugin-authz-default-access": "^1.0.0-beta.15",
|
|
164
165
|
"@nocobase/authorization": "^1.0.0-beta.15",
|
|
165
166
|
"@nocobase/queue": "^0.1.0-beta.9",
|
|
166
167
|
"@nocobase/jobs": "^0.1.0-beta.3",
|
|
@@ -168,7 +169,8 @@
|
|
|
168
169
|
"@nocobase/i18n": "^2.0.0-beta.1",
|
|
169
170
|
"@nocobase/caching": "^0.1.0-beta.3",
|
|
170
171
|
"@nocobase/repository-input": "^0.1.0-beta.3",
|
|
171
|
-
"@nocobase/app-cli": "^1.0.0-beta.
|
|
172
|
+
"@nocobase/app-cli": "^1.0.0-beta.26",
|
|
173
|
+
"@nocobase/app-plugin-mail": "^1.0.0-beta.16",
|
|
172
174
|
"@nocobase/realtime": "^0.1.0-beta.3"
|
|
173
175
|
},
|
|
174
176
|
"description": "NocoBase 3 application template with the standard product capabilities.",
|
package/server/plugins.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import foundation from '@nocobase/app-plugin-foundation/server';
|
|
1
2
|
import defaultAccess from '@nocobase/app-plugin-authz-default-access/server';
|
|
2
3
|
import sharingRules from '@nocobase/app-plugin-authz-sharing-rules/server';
|
|
3
4
|
import restrictionRules from '@nocobase/app-plugin-authz-restriction-rules/server';
|
|
@@ -34,6 +35,7 @@ const serverPlugins: AppServerPlugins = defineServerPlugins([
|
|
|
34
35
|
file,
|
|
35
36
|
scheduler,
|
|
36
37
|
mail,
|
|
38
|
+
foundation,
|
|
37
39
|
]);
|
|
38
40
|
|
|
39
41
|
export default serverPlugins;
|
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
TestI18nProvider,
|
|
3
|
+
createTestI18nRuntime,
|
|
4
|
+
} from '@nocobase/i18n/testing';
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
import { render as renderView, screen, within } from '@testing-library/react';
|
|
2
7
|
import userEvent from '@testing-library/user-event';
|
|
3
8
|
import { MemoryRouter, Route, Routes } from 'react-router';
|
|
4
9
|
import { describe, expect, it } from 'vitest';
|
|
5
10
|
import { RoleList } from '../../client/components/role-list.js';
|
|
6
11
|
|
|
12
|
+
import enUS from '../../client/locales/en-US.js';
|
|
13
|
+
|
|
14
|
+
const runtime = await createTestI18nRuntime({
|
|
15
|
+
application: { namespace: 'role-table-test', resources: enUS },
|
|
16
|
+
});
|
|
17
|
+
function render(view: ReactElement) {
|
|
18
|
+
return renderView(
|
|
19
|
+
<TestI18nProvider runtime={runtime}>{view}</TestI18nProvider>,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
const roles = [
|
|
8
24
|
{
|
|
9
25
|
key: 'member',
|
|
@@ -18,19 +34,30 @@ const roles = [
|
|
|
18
34
|
protected: false,
|
|
19
35
|
},
|
|
20
36
|
];
|
|
21
|
-
describe('
|
|
22
|
-
it('renders
|
|
37
|
+
describe('standard role table', () => {
|
|
38
|
+
it('renders standard table headers, full names, identifiers and translated state labels', () => {
|
|
23
39
|
render(
|
|
24
40
|
<MemoryRouter>
|
|
25
41
|
<RoleList
|
|
26
42
|
roles={roles}
|
|
27
43
|
ariaLabel='角色与权限'
|
|
28
|
-
labels={{
|
|
44
|
+
labels={{
|
|
45
|
+
name: '角色名称',
|
|
46
|
+
key: '标识',
|
|
47
|
+
type: '类型',
|
|
48
|
+
protected: '受保护',
|
|
49
|
+
custom: '自定义',
|
|
50
|
+
}}
|
|
29
51
|
/>
|
|
30
52
|
</MemoryRouter>,
|
|
31
53
|
);
|
|
32
|
-
expect(
|
|
33
|
-
|
|
54
|
+
expect(
|
|
55
|
+
within(screen.getByRole('region', { name: '角色与权限' })).getByRole(
|
|
56
|
+
'table',
|
|
57
|
+
),
|
|
58
|
+
).toBeVisible();
|
|
59
|
+
expect(screen.getAllByRole('columnheader')).toHaveLength(3);
|
|
60
|
+
expect(screen.getAllByRole('row')).toHaveLength(3);
|
|
34
61
|
expect(
|
|
35
62
|
screen.queryByRole('heading', { name: 'Member' }),
|
|
36
63
|
).not.toBeInTheDocument();
|
|
@@ -51,7 +78,13 @@ describe('compact role list', () => {
|
|
|
51
78
|
element={
|
|
52
79
|
<RoleList
|
|
53
80
|
roles={roles}
|
|
54
|
-
labels={{
|
|
81
|
+
labels={{
|
|
82
|
+
name: 'Role name',
|
|
83
|
+
key: 'Key',
|
|
84
|
+
type: 'Type',
|
|
85
|
+
protected: 'Protected',
|
|
86
|
+
custom: 'Custom',
|
|
87
|
+
}}
|
|
55
88
|
/>
|
|
56
89
|
}
|
|
57
90
|
/>
|
|
@@ -63,9 +96,41 @@ describe('compact role list', () => {
|
|
|
63
96
|
name: 'Member',
|
|
64
97
|
});
|
|
65
98
|
expect(link).toHaveAttribute('href', '/roles/member/edit?q=team');
|
|
66
|
-
|
|
99
|
+
for (let i = 0; i < 8 && document.activeElement !== link; i++)
|
|
100
|
+
await user.tab();
|
|
67
101
|
expect(link).toHaveFocus();
|
|
68
102
|
await user.keyboard('{Enter}');
|
|
69
103
|
expect(screen.getByText('Edit member')).toBeVisible();
|
|
70
104
|
});
|
|
105
|
+
it('uses standard pagination without losing edit links on later pages', async () => {
|
|
106
|
+
const user = userEvent.setup();
|
|
107
|
+
render(
|
|
108
|
+
<MemoryRouter>
|
|
109
|
+
<RoleList
|
|
110
|
+
roles={Array.from({ length: 12 }, (_, i) => ({
|
|
111
|
+
key: `role-${i}`,
|
|
112
|
+
title: `Role ${i}`,
|
|
113
|
+
to: `/roles/role-${i}/edit`,
|
|
114
|
+
protected: false,
|
|
115
|
+
}))}
|
|
116
|
+
labels={{
|
|
117
|
+
name: 'Role name',
|
|
118
|
+
key: 'Key',
|
|
119
|
+
type: 'Type',
|
|
120
|
+
protected: 'Protected',
|
|
121
|
+
custom: 'Custom',
|
|
122
|
+
}}
|
|
123
|
+
/>
|
|
124
|
+
</MemoryRouter>,
|
|
125
|
+
);
|
|
126
|
+
expect(screen.getAllByRole('row')).toHaveLength(11);
|
|
127
|
+
expect(
|
|
128
|
+
screen.queryByRole('link', { name: 'Role 11', exact: true }),
|
|
129
|
+
).not.toBeInTheDocument();
|
|
130
|
+
await user.click(screen.getByRole('button', { name: 'Go to next page' }));
|
|
131
|
+
expect(screen.getAllByRole('row')).toHaveLength(3);
|
|
132
|
+
expect(
|
|
133
|
+
screen.getByRole('link', { name: 'Role 11', exact: true }),
|
|
134
|
+
).toHaveAttribute('href', '/roles/role-11/edit');
|
|
135
|
+
});
|
|
71
136
|
});
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
AuthorizationClient,
|
|
5
5
|
authorizationClientToken,
|
|
6
6
|
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
-
import { screen, waitFor } from '@testing-library/react';
|
|
7
|
+
import { screen, waitFor, within } from '@testing-library/react';
|
|
8
8
|
import userEvent from '@testing-library/user-event';
|
|
9
9
|
import { Routes, Route } from 'react-router';
|
|
10
10
|
import { expect, it } from 'vitest';
|
|
@@ -24,7 +24,7 @@ const namespaces = {
|
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
it('keeps the
|
|
27
|
+
it('keeps the standard role table sorted, searchable and linked to the current query', async () => {
|
|
28
28
|
const user = userEvent.setup();
|
|
29
29
|
await renderWithApp(
|
|
30
30
|
<Routes>
|
|
@@ -54,11 +54,15 @@ it('keeps the compact role list sorted, searchable and linked to the current que
|
|
|
54
54
|
},
|
|
55
55
|
);
|
|
56
56
|
await screen.findByTitle('Customer');
|
|
57
|
-
const
|
|
57
|
+
const region = screen.getByRole('region', {
|
|
58
|
+
name: 'Roles and permissions',
|
|
59
|
+
});
|
|
60
|
+
const table = within(region).getByRole('table');
|
|
61
|
+
expect(within(table).getAllByRole('columnheader')).toHaveLength(3);
|
|
58
62
|
expect(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
within(table)
|
|
64
|
+
.getAllByRole('link')
|
|
65
|
+
.map((link) => link.textContent),
|
|
62
66
|
).toEqual(['Customer', 'Member', 'NocoBase team']);
|
|
63
67
|
expect(
|
|
64
68
|
screen.queryByRole('heading', { name: 'Customer' }),
|
|
@@ -70,7 +74,10 @@ it('keeps the compact role list sorted, searchable and linked to the current que
|
|
|
70
74
|
screen.getByRole('textbox', { name: 'Search role names' }),
|
|
71
75
|
'Customer',
|
|
72
76
|
);
|
|
73
|
-
await waitFor(() =>
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
expect(within(table).getAllByRole('row')).toHaveLength(2);
|
|
79
|
+
expect(within(table).getAllByRole('link')).toHaveLength(1);
|
|
80
|
+
});
|
|
74
81
|
expect(screen.getByTitle('Customer').closest('a')).toHaveAttribute(
|
|
75
82
|
'href',
|
|
76
83
|
'/settings/roles/cp-customer/edit?q=Customer',
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { expect, it } from 'vitest';
|
|
4
|
+
import { createAppTest } from '@nocobase/app-testing/server';
|
|
5
|
+
import { createStandaloneServer } from '../../server/standalone.js';
|
|
6
|
+
|
|
7
|
+
type Assignment = {
|
|
8
|
+
subjectType: unknown;
|
|
9
|
+
subjectId: unknown;
|
|
10
|
+
permissionSetKey: unknown;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function unexpectedGlobalAssignments(
|
|
14
|
+
assignments: readonly Assignment[],
|
|
15
|
+
): Assignment[] {
|
|
16
|
+
return assignments.filter(
|
|
17
|
+
(assignment) =>
|
|
18
|
+
assignment.subjectId === '*' &&
|
|
19
|
+
!(
|
|
20
|
+
assignment.subjectType === 'authenticated' &&
|
|
21
|
+
assignment.permissionSetKey === 'member'
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const test = createAppTest({
|
|
27
|
+
createServer: createStandaloneServer,
|
|
28
|
+
connections: ['main'],
|
|
29
|
+
config: {
|
|
30
|
+
app: { publicOrigin: 'http://localhost' },
|
|
31
|
+
auth: { secret: 'test-auth-secret-at-least-32-characters' },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('initialization does not silently grant business roles to every signed-in user', async ({
|
|
36
|
+
connection,
|
|
37
|
+
}) => {
|
|
38
|
+
const assignments = await connection.query
|
|
39
|
+
.selectFrom('authorizationPermissionSetAssignments')
|
|
40
|
+
.select(['subjectType', 'subjectId', 'permissionSetKey'])
|
|
41
|
+
.execute();
|
|
42
|
+
expect(
|
|
43
|
+
unexpectedGlobalAssignments(assignments),
|
|
44
|
+
'Global role assignments must be explicitly requested and editable in administration. Use per-user role assignments by default; do not remove this guard to make a feature accessible.',
|
|
45
|
+
).toEqual([]);
|
|
46
|
+
const member = await connection.query
|
|
47
|
+
.selectFrom('authorizationPermissionSets')
|
|
48
|
+
.select(['grants'])
|
|
49
|
+
.where('key', '=', 'member')
|
|
50
|
+
.executeTakeFirstOrThrow();
|
|
51
|
+
let grants: unknown = member.grants;
|
|
52
|
+
// Database JSON codecs may preserve the serialized seed value as a string.
|
|
53
|
+
while (typeof grants === 'string') grants = JSON.parse(grants) as unknown;
|
|
54
|
+
expect(
|
|
55
|
+
grants,
|
|
56
|
+
'The inherited platform member role must not conceal application business grants. Assign a separate business role to explicit users.',
|
|
57
|
+
).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('detects wildcard recipients regardless of the role name, while preserving explicit user assignments', () => {
|
|
61
|
+
const global = {
|
|
62
|
+
subjectType: 'authenticated',
|
|
63
|
+
subjectId: '*',
|
|
64
|
+
permissionSetKey: 'task-member',
|
|
65
|
+
};
|
|
66
|
+
expect(
|
|
67
|
+
unexpectedGlobalAssignments([
|
|
68
|
+
{
|
|
69
|
+
subjectType: 'authenticated',
|
|
70
|
+
subjectId: '*',
|
|
71
|
+
permissionSetKey: 'member',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
subjectType: 'user',
|
|
75
|
+
subjectId: 'user-1',
|
|
76
|
+
permissionSetKey: 'task-member',
|
|
77
|
+
},
|
|
78
|
+
global,
|
|
79
|
+
{ ...global, subjectType: 'everyone' },
|
|
80
|
+
]),
|
|
81
|
+
).toEqual([global, { ...global, subjectType: 'everyone' }]);
|
|
82
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { resolveAppRuntime } from '@nocobase/app-client/runtime';
|
|
3
|
+
import { defineAppRuntime } from '@nocobase/app-client/runtime';
|
|
4
|
+
import appRuntime from '../../client/runtime.js';
|
|
5
|
+
import cliPlugins from '../../cli/plugins.js';
|
|
6
|
+
import serverPlugins from '../../server/plugins.js';
|
|
7
|
+
import settingsRoutes from '../../client/pages/settings/routes.js';
|
|
8
|
+
import { buildRouteNavigation } from '../../client/routing/route-navigation.js';
|
|
9
|
+
|
|
10
|
+
describe('default Foundation integration', () => {
|
|
11
|
+
it('registers the Foundation deployment evidence build hook', () => {
|
|
12
|
+
expect(
|
|
13
|
+
cliPlugins.plugins.filter(
|
|
14
|
+
(plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
|
|
15
|
+
),
|
|
16
|
+
).toHaveLength(1);
|
|
17
|
+
});
|
|
18
|
+
it('exposes Foundation in settings for an unrestricted administrator and removes the empty group when denied', async () => {
|
|
19
|
+
const runtime = await resolveAppRuntime(appRuntime);
|
|
20
|
+
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
21
|
+
const group = menu.find(({ route }) => route.name === 'foundationSettings');
|
|
22
|
+
const page = group?.children.find(
|
|
23
|
+
({ route }) => route.name === 'foundation',
|
|
24
|
+
)?.route;
|
|
25
|
+
expect(page).toMatchObject({
|
|
26
|
+
path: '/foundation',
|
|
27
|
+
auth: 'required',
|
|
28
|
+
authz: 'unrestricted',
|
|
29
|
+
});
|
|
30
|
+
if (!page) throw new Error('Foundation route missing');
|
|
31
|
+
expect(
|
|
32
|
+
buildRouteNavigation(
|
|
33
|
+
runtime.routes,
|
|
34
|
+
new Set([`${page.packageName}:${page.path}:${page.id}`]),
|
|
35
|
+
).some(({ route }) => route.name === 'foundationSettings'),
|
|
36
|
+
).toBe(false);
|
|
37
|
+
expect(
|
|
38
|
+
serverPlugins.plugins.filter(
|
|
39
|
+
(plugin) => plugin.packageName === '@nocobase/app-plugin-foundation',
|
|
40
|
+
),
|
|
41
|
+
).toHaveLength(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('merges Foundation into business settings without changing its protected URL', async () => {
|
|
45
|
+
const runtime = await resolveAppRuntime(
|
|
46
|
+
defineAppRuntime({
|
|
47
|
+
...appRuntime,
|
|
48
|
+
routes: [...appRuntime.routes, settingsRoutes],
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
const menu = buildRouteNavigation(runtime.routes, new Set());
|
|
52
|
+
expect(menu.some(({ route }) => route.name === 'foundationSettings')).toBe(
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
const settings = menu.find(({ route }) => route.name === 'settings');
|
|
56
|
+
expect(settings?.children.at(-1)?.route).toMatchObject({
|
|
57
|
+
name: 'foundation',
|
|
58
|
+
path: '/foundation',
|
|
59
|
+
authz: 'unrestricted',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|