@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.
- package/.agents/rules/build-and-deploy.md +1 -1
- package/.agents/rules/shell.md +4 -0
- package/AGENTS.md +8 -2
- package/README.MD +10 -2
- package/client/components/back-button.tsx +3 -5
- package/client/components/data-table/data-table.tsx +86 -32
- package/client/components/default-access-editor.tsx +13 -2
- package/client/components/feedback-form.tsx +474 -0
- package/client/components/lifecycle-diagram.tsx +10 -10
- package/client/components/list-feedback.tsx +174 -0
- package/client/components/permission-editor.tsx +419 -226
- package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
- package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
- package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
- package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
- package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
- package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
- package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
- package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -7
- package/client/extensions/nocobase-workflow-management/execution-list.tsx +61 -48
- package/client/hooks/use-list-data.ts +76 -0
- package/client/hooks/use-list-location.ts +23 -0
- package/client/hooks/use-list-tabs.ts +42 -0
- package/client/hooks/use-table-url-state.ts +143 -30
- package/client/locales/system/en-US.ts +24 -1
- package/client/locales/system/settings-en-US.ts +8 -10
- package/client/locales/system/settings-zh-CN.ts +8 -8
- package/client/locales/system/zh-CN.ts +22 -1
- package/client/pages/settings/default-access-form.tsx +44 -26
- package/client/pages/settings/default-access.tsx +100 -93
- package/client/pages/settings/hooks.tsx +4 -59
- package/client/pages/settings/page-permissions.ts +17 -0
- package/client/pages/settings/permissions-model.ts +54 -59
- package/client/pages/settings/role-form.tsx +98 -56
- package/client/pages/settings/roles.tsx +69 -95
- package/client/pages/settings/shared.tsx +43 -33
- package/client/pages/settings/user-form.tsx +51 -30
- package/client/pages/settings/users.tsx +105 -91
- package/client/routing/session-preparation.tsx +10 -3
- package/client/runtime.ts +0 -4
- package/config.example.yml +24 -0
- package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
- package/package.json +24 -21
- package/server/app.ts +0 -2
- package/server/config/auth.ts +2 -0
- package/server/config/concurrency.ts +3 -0
- package/server/config/index.ts +3 -0
- package/server/config/jobs.ts +1 -13
- package/server/config/notification.ts +1 -1
- package/server/config/queue.ts +1 -7
- package/server/plugins.ts +2 -0
- package/server/providers/index.ts +4 -1
- package/server/providers/submission.ts +61 -0
- package/tests/client/auth-i18n.test.tsx +9 -0
- package/tests/client/back-button.test.tsx +3 -3
- package/tests/client/data-table.test.tsx +9 -4
- package/tests/client/default-access.test.tsx +99 -5
- package/tests/client/form-feedback.test.tsx +589 -0
- package/tests/client/list-feedback.test.tsx +301 -0
- package/tests/client/list-recovery.test.tsx +373 -0
- package/tests/client/list-tabs.test.tsx +121 -0
- package/tests/client/manual-list-retry.test.tsx +190 -0
- package/tests/client/page-permission-navigation.test.ts +8 -0
- package/tests/client/permission-editor.test.tsx +191 -21
- package/tests/client/permission-filter-binding.test.tsx +1 -1
- package/tests/client/permission-workspace.test.ts +13 -0
- package/tests/client/role-return.test.tsx +2 -1
- package/tests/client/session-preparation.test.tsx +24 -1
- package/tests/client/settings-continuity.test.tsx +85 -5
- package/tests/client/settings-feedback.test.tsx +32 -15
- package/tests/client/settings-permissions.test.ts +35 -0
- package/tests/client/settings.test.tsx +2 -1
- package/tests/client/table-url-state.test.tsx +117 -3
- package/tests/client/user-drawer.test.tsx +2 -1
- package/tests/client/user-permissions.test.tsx +30 -5
- package/tests/database/mutation-receipts.test.ts +45 -0
- package/tests/fixtures/form-feedback/README.md +33 -0
- package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
- package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
- package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
- package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
- package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
- package/tests/fixtures/form-feedback/equipment.tsx +369 -0
- package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
- package/tests/fixtures/form-feedback/verify.mjs +373 -0
- package/tests/fixtures/permission-alignment.html +16 -0
- package/tests/fixtures/permission-alignment.tsx +124 -0
- package/tests/fixtures/submission/README.md +35 -0
- package/tests/fixtures/submission/browser.test.ts +478 -0
- package/tests/fixtures/submission/migration.ts +61 -0
- package/tests/fixtures/submission/page.tsx +138 -0
- package/tests/fixtures/submission/playwright.config.ts +11 -0
- package/tests/fixtures/submission/routes.ts +208 -0
- package/tests/helpers/settings-client.tsx +31 -0
- package/tests/playwright/permission-alignment.test.ts +109 -0
- package/tests/server/auth-email-config.test.ts +79 -0
- package/tests/server/concurrency.test.ts +61 -0
- package/tests/server/config.test.ts +34 -14
- package/tests/server/member-role.test.ts +7 -1
- package/tests/server/submission-settings.test.ts +241 -0
- package/client/route-overrides.ts +0 -9
- package/client/source-extensions.ts +0 -19
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useState, type ReactElement } from 'react';
|
|
2
|
+
import { useAuthentication } from '@nocobase/app-plugin-authentication/client';
|
|
3
|
+
import {
|
|
4
|
+
useApiClient,
|
|
5
|
+
useSubmission,
|
|
6
|
+
PendingSubmissionError,
|
|
7
|
+
} from '@nocobase/app-client';
|
|
8
|
+
import { Button } from '#components/ui/button';
|
|
9
|
+
import { Input } from '#components/ui/input';
|
|
10
|
+
|
|
11
|
+
/** A synthetic customized business form, used against generated and upgraded applications. */
|
|
12
|
+
export default function SubmissionFixture(): ReactElement {
|
|
13
|
+
const api = useApiClient();
|
|
14
|
+
const { session } = useAuthentication();
|
|
15
|
+
const submission = useSubmission({
|
|
16
|
+
scope: `${session?.user.id}:synthetic-borrowing-form`,
|
|
17
|
+
});
|
|
18
|
+
const [name, setName] = useState('Camera');
|
|
19
|
+
const [assetTag, setAssetTag] = useState('CUSTOM-001');
|
|
20
|
+
const [id, setId] = useState<string>();
|
|
21
|
+
const [message, setMessage] = useState('Ready');
|
|
22
|
+
const [mode, setMode] = useState<'create' | 'update' | 'approve'>('create');
|
|
23
|
+
const submit = async (): Promise<void> => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await submission.run(
|
|
26
|
+
{ name, assetTag, mode, id },
|
|
27
|
+
async (key, signal) => {
|
|
28
|
+
if (!name.trim() || !assetTag.startsWith('CUSTOM-')) {
|
|
29
|
+
setMessage('Name and CUSTOM- asset tag are required');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (mode === 'approve')
|
|
33
|
+
return api.request({
|
|
34
|
+
path: `submissionFixture/${id}/approve`,
|
|
35
|
+
method: 'POST',
|
|
36
|
+
signal,
|
|
37
|
+
headers: { 'Idempotency-Key': key },
|
|
38
|
+
json: { note: 'Approved synthetic request' },
|
|
39
|
+
});
|
|
40
|
+
return mode === 'update'
|
|
41
|
+
? api
|
|
42
|
+
.repository<{
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
assetTag: string;
|
|
46
|
+
statusChangedAt: string;
|
|
47
|
+
}>('borrowings')
|
|
48
|
+
.updateOne({
|
|
49
|
+
idempotencyKey: key,
|
|
50
|
+
signal,
|
|
51
|
+
filter: { id },
|
|
52
|
+
values: { name, assetTag },
|
|
53
|
+
})
|
|
54
|
+
: api
|
|
55
|
+
.repository<{
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
assetTag: string;
|
|
59
|
+
statusChangedAt: string;
|
|
60
|
+
}>('borrowings')
|
|
61
|
+
.createOne({
|
|
62
|
+
idempotencyKey: key,
|
|
63
|
+
signal,
|
|
64
|
+
values: {
|
|
65
|
+
id: key.slice(14),
|
|
66
|
+
name,
|
|
67
|
+
assetTag,
|
|
68
|
+
statusChangedAt: new Date(
|
|
69
|
+
Number(key.slice(0, 13)),
|
|
70
|
+
).toISOString(),
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
if (result)
|
|
76
|
+
setMessage(mode === 'approve' ? 'Approved once' : 'Saved successfully');
|
|
77
|
+
if (result && typeof result === 'object' && 'record' in result)
|
|
78
|
+
setId((result.record as { id: string }).id);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
setMessage(
|
|
81
|
+
error instanceof PendingSubmissionError
|
|
82
|
+
? 'Retry the original operation before changing input'
|
|
83
|
+
: 'Request failed; input retained. Retry to confirm the outcome.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return (
|
|
88
|
+
<main
|
|
89
|
+
className='mx-auto max-w-xl space-y-5 p-8'
|
|
90
|
+
style={{ borderTop: '6px solid #2563eb' }}
|
|
91
|
+
>
|
|
92
|
+
<h1 className='text-2xl font-semibold'>Equipment borrowing</h1>
|
|
93
|
+
<p>
|
|
94
|
+
Custom business form • Asset tag validation • Existing data retained
|
|
95
|
+
</p>
|
|
96
|
+
<form
|
|
97
|
+
id='borrowing-form'
|
|
98
|
+
onSubmit={(event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
void submit();
|
|
101
|
+
}}
|
|
102
|
+
className='space-y-3'
|
|
103
|
+
>
|
|
104
|
+
<label htmlFor='name'>Equipment name</label>
|
|
105
|
+
<Input
|
|
106
|
+
id='name'
|
|
107
|
+
value={name}
|
|
108
|
+
onChange={(event) => setName(event.target.value)}
|
|
109
|
+
/>
|
|
110
|
+
<label htmlFor='assetTag'>Custom asset tag</label>
|
|
111
|
+
<Input
|
|
112
|
+
id='assetTag'
|
|
113
|
+
value={assetTag}
|
|
114
|
+
onChange={(event) => setAssetTag(event.target.value)}
|
|
115
|
+
/>
|
|
116
|
+
<label htmlFor='mode'>Operation</label>
|
|
117
|
+
<select
|
|
118
|
+
id='mode'
|
|
119
|
+
value={mode}
|
|
120
|
+
onChange={(event) => setMode(event.target.value as typeof mode)}
|
|
121
|
+
>
|
|
122
|
+
<option value='create'>Create</option>
|
|
123
|
+
<option value='update'>Update</option>
|
|
124
|
+
<option value='approve'>Approve</option>
|
|
125
|
+
</select>
|
|
126
|
+
</form>
|
|
127
|
+
<Button
|
|
128
|
+
type='submit'
|
|
129
|
+
form='borrowing-form'
|
|
130
|
+
disabled={submission.submitting}
|
|
131
|
+
>
|
|
132
|
+
{submission.submitting ? 'Submitting…' : 'Save'}
|
|
133
|
+
</Button>
|
|
134
|
+
<p role='status'>{submission.submitting ? 'Submitting…' : message}</p>
|
|
135
|
+
<p data-testid='record-id'>{id}</p>
|
|
136
|
+
</main>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
export default defineConfig({
|
|
3
|
+
testDir: '.',
|
|
4
|
+
testMatch: 'browser.test.ts',
|
|
5
|
+
timeout: 120_000,
|
|
6
|
+
workers: 1,
|
|
7
|
+
retries: 0,
|
|
8
|
+
outputDir: process.env.SUBMISSION_EVIDENCE_DIR ?? './evidence',
|
|
9
|
+
reporter: 'list',
|
|
10
|
+
use: { viewport: { width: 1280, height: 900 }, trace: 'off' },
|
|
11
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizationToken,
|
|
3
|
+
grantBacked,
|
|
4
|
+
type AuthorizationEnv,
|
|
5
|
+
} from '@nocobase/app-plugin-authorization';
|
|
6
|
+
import type { Application } from '@nocobase/app-server/application';
|
|
7
|
+
import {
|
|
8
|
+
authenticationToken,
|
|
9
|
+
type AuthEnv,
|
|
10
|
+
} from '@nocobase/app-plugin-authentication';
|
|
11
|
+
import {
|
|
12
|
+
ApiError,
|
|
13
|
+
defineApiRoutes,
|
|
14
|
+
defineRepositoryApiRoutes,
|
|
15
|
+
executeIdempotentMutation,
|
|
16
|
+
describeRoute,
|
|
17
|
+
apiValidator,
|
|
18
|
+
dataResponse,
|
|
19
|
+
apiErrorResponses,
|
|
20
|
+
} from '@nocobase/app-server/router';
|
|
21
|
+
import { databaseManagerToken } from '@nocobase/db';
|
|
22
|
+
import {
|
|
23
|
+
createRepositoryLifecycleStore,
|
|
24
|
+
defineLifecycle,
|
|
25
|
+
LifecycleRuntime,
|
|
26
|
+
type LifecycleRecord,
|
|
27
|
+
} from '@nocobase/lifecycle';
|
|
28
|
+
import { Hono } from 'hono';
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
import { randomUUID } from 'node:crypto';
|
|
31
|
+
|
|
32
|
+
interface Borrowing extends LifecycleRecord {
|
|
33
|
+
status: 'draft' | 'approved';
|
|
34
|
+
name: string;
|
|
35
|
+
assetTag: string;
|
|
36
|
+
}
|
|
37
|
+
type BorrowingTypes = {
|
|
38
|
+
record: Borrowing;
|
|
39
|
+
state: Borrowing['status'];
|
|
40
|
+
parameters: Record<string, never>;
|
|
41
|
+
services: Record<string, never>;
|
|
42
|
+
};
|
|
43
|
+
/** Isolated synthetic business fixture. Authentication uses this application's native session. */
|
|
44
|
+
export default defineApiRoutes(async (app: Application) => {
|
|
45
|
+
const database = app.container.resolve(databaseManagerToken);
|
|
46
|
+
const connection = database.connection();
|
|
47
|
+
const auth = app.container.resolve(authenticationToken);
|
|
48
|
+
const authorization = app.container.resolve(authorizationToken);
|
|
49
|
+
authorization.resourceTypes.add({
|
|
50
|
+
type: 'submissionBorrowing',
|
|
51
|
+
actions: ['read', 'create', 'update', 'delete', 'approve'],
|
|
52
|
+
authorize: grantBacked(),
|
|
53
|
+
});
|
|
54
|
+
const runtime = new LifecycleRuntime({
|
|
55
|
+
store: createRepositoryLifecycleStore(database),
|
|
56
|
+
});
|
|
57
|
+
runtime.register(
|
|
58
|
+
defineLifecycle<BorrowingTypes>({
|
|
59
|
+
name: 'borrowings',
|
|
60
|
+
initial: 'draft',
|
|
61
|
+
states: ['draft', { name: 'approved', final: true }],
|
|
62
|
+
transitions: {
|
|
63
|
+
approve: {
|
|
64
|
+
from: 'draft',
|
|
65
|
+
to: 'approved',
|
|
66
|
+
onTransition: async ({ record, transactionHandle }) => {
|
|
67
|
+
const tx = transactionHandle as typeof connection;
|
|
68
|
+
await tx.repository('inventoryEntries').createOne({
|
|
69
|
+
values: { id: randomUUID(), borrowingId: record.id },
|
|
70
|
+
});
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
const router = new Hono<AuthEnv & AuthorizationEnv>();
|
|
77
|
+
router.use('*', auth.required(), authorization.middleware());
|
|
78
|
+
router.use('*', async (c, next) => {
|
|
79
|
+
const path = c.req.path;
|
|
80
|
+
const action = path.endsWith('/createOne')
|
|
81
|
+
? 'create'
|
|
82
|
+
: path.endsWith('/updateOne')
|
|
83
|
+
? 'update'
|
|
84
|
+
: path.endsWith('/deleteOne')
|
|
85
|
+
? 'delete'
|
|
86
|
+
: path.endsWith('/approve')
|
|
87
|
+
? 'approve'
|
|
88
|
+
: 'read';
|
|
89
|
+
await c.get('authz').require(
|
|
90
|
+
path.endsWith('/fixtureAccess')
|
|
91
|
+
? {
|
|
92
|
+
resource: {
|
|
93
|
+
type: 'settings',
|
|
94
|
+
id: 'authorization.permission-sets',
|
|
95
|
+
},
|
|
96
|
+
action: 'update',
|
|
97
|
+
}
|
|
98
|
+
: { resource: { type: 'submissionBorrowing', id: '*' }, action },
|
|
99
|
+
);
|
|
100
|
+
await next();
|
|
101
|
+
});
|
|
102
|
+
let revoked = false;
|
|
103
|
+
router.use('*', async (c, next) => {
|
|
104
|
+
if (revoked && !c.req.path.endsWith('/fixtureAccess'))
|
|
105
|
+
throw new ApiError({
|
|
106
|
+
status: 'PERMISSION_DENIED',
|
|
107
|
+
reason: 'FIXTURE_REVOKED',
|
|
108
|
+
domain: 'submissionFixture',
|
|
109
|
+
message: 'The business permission was revoked.',
|
|
110
|
+
});
|
|
111
|
+
await next();
|
|
112
|
+
});
|
|
113
|
+
const routes = defineRepositoryApiRoutes({
|
|
114
|
+
repositories: [
|
|
115
|
+
{
|
|
116
|
+
name: 'borrowings',
|
|
117
|
+
policy: {
|
|
118
|
+
read: true,
|
|
119
|
+
create: {
|
|
120
|
+
scope: true,
|
|
121
|
+
fields: ['id', 'name', 'assetTag', 'statusChangedAt'],
|
|
122
|
+
},
|
|
123
|
+
update: { scope: true, fields: ['name', 'assetTag'] },
|
|
124
|
+
delete: true,
|
|
125
|
+
},
|
|
126
|
+
actions: { findMany: {}, createOne: {}, updateOne: {}, deleteOne: {} },
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
router.route('/', await routes.createRouter(app));
|
|
131
|
+
const info = (operationId: string, summary: string) =>
|
|
132
|
+
describeRoute({
|
|
133
|
+
tags: ['SubmissionFixture'],
|
|
134
|
+
summary,
|
|
135
|
+
operationId,
|
|
136
|
+
responses: { 200: dataResponse(z.unknown()), ...apiErrorResponses },
|
|
137
|
+
});
|
|
138
|
+
router.get(
|
|
139
|
+
'/submissionFixture/evidence',
|
|
140
|
+
info('submissionFixtureEvidence', 'Inspect synthetic business data'),
|
|
141
|
+
async (c) =>
|
|
142
|
+
c.json({
|
|
143
|
+
data: {
|
|
144
|
+
rows: await connection.repository('borrowings').findMany(),
|
|
145
|
+
entries: await connection.repository('inventoryEntries').findMany(),
|
|
146
|
+
history: await connection
|
|
147
|
+
.repository('lifecycleTransitions')
|
|
148
|
+
.findMany(),
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
router.post(
|
|
153
|
+
'/submissionFixture/fixtureAccess',
|
|
154
|
+
info('submissionFixtureAccess', 'Change the synthetic business permission'),
|
|
155
|
+
apiValidator('json', z.strictObject({ revoked: z.boolean() })),
|
|
156
|
+
(c) => {
|
|
157
|
+
revoked = c.req.valid('json').revoked;
|
|
158
|
+
return c.json({ data: { revoked } });
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
router.post(
|
|
162
|
+
'/submissionFixture/:id/approve',
|
|
163
|
+
info('submissionFixtureApprove', 'Approve a synthetic borrowing'),
|
|
164
|
+
apiValidator('param', z.object({ id: z.string() })),
|
|
165
|
+
apiValidator('header', z.object({ 'idempotency-key': z.string() })),
|
|
166
|
+
apiValidator('json', z.strictObject({ note: z.string() })),
|
|
167
|
+
async (c) => {
|
|
168
|
+
const user = c.get('auth') as { user: { id: string } };
|
|
169
|
+
const id = c.req.valid('param').id;
|
|
170
|
+
const result = await executeIdempotentMutation({
|
|
171
|
+
connection,
|
|
172
|
+
key: c.req.valid('header')['idempotency-key'],
|
|
173
|
+
principal: user.user.id,
|
|
174
|
+
operation: `borrowings/${id}/approve`,
|
|
175
|
+
input: c.req.valid('json'),
|
|
176
|
+
authorize: async () => {
|
|
177
|
+
if (revoked)
|
|
178
|
+
throw new ApiError({
|
|
179
|
+
status: 'PERMISSION_DENIED',
|
|
180
|
+
reason: 'FIXTURE_REVOKED',
|
|
181
|
+
domain: 'submissionFixture',
|
|
182
|
+
message: 'Permission revoked.',
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
authorizeReplay: async () => {
|
|
186
|
+
if (
|
|
187
|
+
!(await connection
|
|
188
|
+
.repository('borrowings')
|
|
189
|
+
.findOne({ filter: { id } }))
|
|
190
|
+
)
|
|
191
|
+
throw new Error('Borrowing unavailable.');
|
|
192
|
+
},
|
|
193
|
+
execute: async (transaction) => {
|
|
194
|
+
await runtime.fire('borrowings', id, 'approve', {
|
|
195
|
+
actor: { id: user.user.id },
|
|
196
|
+
requestId: c.req.valid('header')['idempotency-key'],
|
|
197
|
+
input: c.req.valid('json'),
|
|
198
|
+
transaction,
|
|
199
|
+
});
|
|
200
|
+
return { approved: true, id };
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
c.header('Idempotency-Replayed', String(result.replayed));
|
|
204
|
+
return c.json({ data: result.data });
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
return new Hono().route('/', router);
|
|
208
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import { beforeEach } from 'vitest';
|
|
3
|
+
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
4
|
+
import {
|
|
5
|
+
AuthenticationProvider,
|
|
6
|
+
authenticationClientToken,
|
|
7
|
+
type AuthClient,
|
|
8
|
+
} from '@nocobase/app-plugin-authentication/client';
|
|
9
|
+
|
|
10
|
+
// Presentation tests keep their existing API doubles; native authentication is exercised separately against the server.
|
|
11
|
+
beforeEach(() => window.sessionStorage.clear());
|
|
12
|
+
export function renderSettingsClient(
|
|
13
|
+
ui: ReactElement,
|
|
14
|
+
options: Parameters<typeof renderWithApp>[1] = {},
|
|
15
|
+
): ReturnType<typeof renderWithApp> {
|
|
16
|
+
return renderWithApp(<AuthenticationProvider>{ui}</AuthenticationProvider>, {
|
|
17
|
+
...options,
|
|
18
|
+
services: (app) => {
|
|
19
|
+
options.services?.(app);
|
|
20
|
+
app.container.instance(authenticationClientToken, {
|
|
21
|
+
getSession: async () => ({
|
|
22
|
+
data: {
|
|
23
|
+
user: { id: 'settings-test-user' },
|
|
24
|
+
session: { id: 'settings-test-session' },
|
|
25
|
+
},
|
|
26
|
+
error: null,
|
|
27
|
+
}),
|
|
28
|
+
} as unknown as AuthClient);
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect, test } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
const applicationUrl = process.env.APP_URL;
|
|
4
|
+
test.skip(!applicationUrl, 'Set APP_URL to the running application entry.');
|
|
5
|
+
const fixture = `${applicationUrl?.replace(/\/$/, '')}/tests/fixtures/permission-alignment.html`;
|
|
6
|
+
|
|
7
|
+
for (const width of [1440, 1024, 768, 390, 375, 360]) {
|
|
8
|
+
test(`aligns scopes across groups and actions at ${width}px`, async ({
|
|
9
|
+
page,
|
|
10
|
+
}) => {
|
|
11
|
+
await page.setViewportSize({ width, height: 1100 });
|
|
12
|
+
await page.goto(fixture);
|
|
13
|
+
await expect(page.getByRole('combobox')).toHaveCount(18);
|
|
14
|
+
const geometry = await Promise.all(
|
|
15
|
+
(await page.getByRole('combobox').all()).map(async (node) => {
|
|
16
|
+
const rect = (await node.boundingBox())!;
|
|
17
|
+
return { x: Math.round(rect.x), width: Math.round(rect.width) };
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
expect(new Set(geometry.map((item) => item.x)).size).toBe(
|
|
21
|
+
width >= 1024 ? 2 : 1,
|
|
22
|
+
);
|
|
23
|
+
expect(new Set(geometry.map((item) => item.width)).size).toBe(1);
|
|
24
|
+
expect(
|
|
25
|
+
await page.locator('html').evaluate((element) => {
|
|
26
|
+
const root = element as unknown as {
|
|
27
|
+
scrollWidth: number;
|
|
28
|
+
clientWidth: number;
|
|
29
|
+
};
|
|
30
|
+
return root.scrollWidth <= root.clientWidth;
|
|
31
|
+
}),
|
|
32
|
+
).toBe(true);
|
|
33
|
+
for (const row of await page.locator('[data-slot=permission-row]').all()) {
|
|
34
|
+
const checkbox = await row.getByRole('checkbox').boundingBox();
|
|
35
|
+
const controls = row.getByRole('combobox');
|
|
36
|
+
if (await controls.count()) {
|
|
37
|
+
const first = await controls.first().boundingBox();
|
|
38
|
+
expect(first!.y).toBeGreaterThan(checkbox!.y + checkbox!.height);
|
|
39
|
+
expect(Math.round(first!.x)).toBe(
|
|
40
|
+
Math.min(...geometry.map((item) => item.x)),
|
|
41
|
+
);
|
|
42
|
+
if ((await controls.count()) % 2 === 1) {
|
|
43
|
+
const last = await controls.last().boundingBox();
|
|
44
|
+
expect(Math.round(last!.x)).toBe(Math.round(first!.x));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
for (const scope of await page
|
|
49
|
+
.locator('[data-slot=permission-scope]')
|
|
50
|
+
.all()) {
|
|
51
|
+
const trigger = await scope.getByRole('combobox').boundingBox();
|
|
52
|
+
if (width < 416) {
|
|
53
|
+
const label = await scope.locator('span[title]').boundingBox();
|
|
54
|
+
expect(label!.y + label!.height).toBeLessThanOrEqual(trigger!.y);
|
|
55
|
+
expect(Math.round(label!.x)).toBe(Math.round(trigger!.x));
|
|
56
|
+
}
|
|
57
|
+
const conditions = scope.getByRole('button', {
|
|
58
|
+
name: 'Conditions',
|
|
59
|
+
exact: true,
|
|
60
|
+
});
|
|
61
|
+
if (await conditions.count()) {
|
|
62
|
+
const button = await conditions.boundingBox();
|
|
63
|
+
expect(button!.y).toBeGreaterThanOrEqual(trigger!.y + trigger!.height);
|
|
64
|
+
expect(Math.round(button!.x)).toBe(Math.round(trigger!.x));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test('preserves long labels, independent scope changes and ungranted visibility', async ({
|
|
71
|
+
page,
|
|
72
|
+
}) => {
|
|
73
|
+
await page.goto(`${fixture}?chinese`);
|
|
74
|
+
const label = '需要完整显示名称的跨部门监控规则计算数据范围长标签';
|
|
75
|
+
await expect(page.getByTitle(label)).toHaveCount(3);
|
|
76
|
+
const control = page.getByRole('combobox', {
|
|
77
|
+
name: `How far View monitoring rules · ${label} reaches`,
|
|
78
|
+
});
|
|
79
|
+
await control.click();
|
|
80
|
+
await page.getByRole('option', { name: 'All records', exact: true }).click();
|
|
81
|
+
await expect(control.locator('[data-slot=select-value]')).toHaveText(
|
|
82
|
+
'All records',
|
|
83
|
+
);
|
|
84
|
+
await expect(
|
|
85
|
+
page.getByRole('checkbox', { name: 'View monitoring rules', exact: true }),
|
|
86
|
+
).toBeChecked();
|
|
87
|
+
await page
|
|
88
|
+
.getByRole('checkbox', { name: 'Manage responsible units', exact: true })
|
|
89
|
+
.click();
|
|
90
|
+
await expect(page.getByRole('combobox')).toHaveCount(17);
|
|
91
|
+
await page.getByRole('tab', { name: 'Ungranted', exact: true }).click();
|
|
92
|
+
await expect(page.getByRole('combobox')).toHaveCount(0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('keeps readonly scopes visible and disables editing', async ({ page }) => {
|
|
96
|
+
await page.goto(`${fixture}?readonly`);
|
|
97
|
+
await expect(page.getByRole('combobox')).toHaveCount(18);
|
|
98
|
+
for (const control of await page.getByRole('combobox').all()) {
|
|
99
|
+
await expect(control).toBeDisabled();
|
|
100
|
+
}
|
|
101
|
+
for (const button of await page
|
|
102
|
+
.getByRole('button', { name: 'Conditions', exact: true })
|
|
103
|
+
.all()) {
|
|
104
|
+
await expect(button).toBeDisabled();
|
|
105
|
+
}
|
|
106
|
+
await expect(
|
|
107
|
+
page.getByRole('checkbox', { name: 'Manage alerts', exact: true }),
|
|
108
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
109
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { readFile, mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
AppConfig,
|
|
9
|
+
defaultAppConfigs,
|
|
10
|
+
buildVariablesManifest,
|
|
11
|
+
} from '@nocobase/app-server/config';
|
|
12
|
+
import auth from '../../server/config/auth.js';
|
|
13
|
+
|
|
14
|
+
it('inherits private authentication sender mappings without changing other mail consumers', async () => {
|
|
15
|
+
const defaults = defaultAppConfigs({ auth });
|
|
16
|
+
const config = new AppConfig();
|
|
17
|
+
await config.loadAll();
|
|
18
|
+
config.mergeDefaults(defaults({} as never));
|
|
19
|
+
config.defineSections(defaults.sections!);
|
|
20
|
+
await config.loadSectionEnvironment({
|
|
21
|
+
AUTH_EMAIL_PROVIDER: 'smtp',
|
|
22
|
+
AUTH_EMAIL_HOST: 'smtp.example.test',
|
|
23
|
+
AUTH_EMAIL_PORT: '587',
|
|
24
|
+
AUTH_EMAIL_SECURE: 'false',
|
|
25
|
+
AUTH_EMAIL_FROM: 'sender@example.test',
|
|
26
|
+
AUTH_EMAIL_PASSWORD: 'synthetic-password',
|
|
27
|
+
AUTH_EMAIL_USER: 'synthetic',
|
|
28
|
+
MAIL_HOST: 'unrelated.example.test',
|
|
29
|
+
});
|
|
30
|
+
expect(await config.validate()).toEqual([]);
|
|
31
|
+
expect(config.get('auth.email.host')).toBe('smtp.example.test');
|
|
32
|
+
expect(config.get('auth.email.port')).toBe(587);
|
|
33
|
+
expect(config.get('auth.email.secure')).toBe(false);
|
|
34
|
+
expect(JSON.stringify(config.publicValues())).not.toContain(
|
|
35
|
+
'synthetic-password',
|
|
36
|
+
);
|
|
37
|
+
expect(config.get('auth.passwordReset.enabled')).toBeUndefined();
|
|
38
|
+
const variables = buildVariablesManifest({
|
|
39
|
+
app: { name: 'synthetic' },
|
|
40
|
+
variables: config.environmentVariableMappings(),
|
|
41
|
+
defaults: config.layers().defaults,
|
|
42
|
+
}).variables;
|
|
43
|
+
expect(
|
|
44
|
+
variables.filter((variable) => variable.name.startsWith('AUTH_EMAIL_')),
|
|
45
|
+
).toHaveLength(9);
|
|
46
|
+
expect(
|
|
47
|
+
variables
|
|
48
|
+
.filter((variable) => variable.name.startsWith('AUTH_EMAIL_'))
|
|
49
|
+
.every((variable) => variable.required === false),
|
|
50
|
+
).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('ships separate, valid SMTP and Resend authentication examples', async () => {
|
|
54
|
+
const example = await readFile(
|
|
55
|
+
new URL('../../config.example.yml', import.meta.url),
|
|
56
|
+
'utf8',
|
|
57
|
+
);
|
|
58
|
+
const blocks = [...example.matchAll(/^ {2}# email:\n(?: {2}# {3}.*\n)+/gm)];
|
|
59
|
+
expect(blocks).toHaveLength(2);
|
|
60
|
+
const directory = await mkdtemp(
|
|
61
|
+
path.join(os.tmpdir(), 'auth-email-example-'),
|
|
62
|
+
);
|
|
63
|
+
try {
|
|
64
|
+
for (const block of blocks) {
|
|
65
|
+
const text = 'auth:\n' + block[0].replace(/^ {2}# /gm, ' ');
|
|
66
|
+
const file = path.join(directory, 'config.yml');
|
|
67
|
+
await writeFile(file, text);
|
|
68
|
+
const config = new AppConfig().loadFile(file);
|
|
69
|
+
await config.loadAll();
|
|
70
|
+
const defaults = defaultAppConfigs({ auth });
|
|
71
|
+
config.mergeDefaults(defaults({} as never));
|
|
72
|
+
config.defineSections(defaults.sections!);
|
|
73
|
+
expect(await config.validate()).toEqual([]);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
await rm(directory, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
expect(example).not.toContain('emailChannel:');
|
|
79
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { createAppTest } from '@nocobase/app-testing/server';
|
|
3
|
+
import {
|
|
4
|
+
concurrencyServiceToken,
|
|
5
|
+
type ConcurrencyService,
|
|
6
|
+
} from '@nocobase/app-plugin-concurrency/server';
|
|
7
|
+
import { createStandaloneServer } from '../../server/standalone.js';
|
|
8
|
+
import concurrency from '../../server/config/concurrency.js';
|
|
9
|
+
|
|
10
|
+
class BusinessConsumer {
|
|
11
|
+
public constructor(private readonly service: ConcurrencyService) {}
|
|
12
|
+
public manager() {
|
|
13
|
+
return this.service.getManager('memory');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const test = createAppTest({
|
|
18
|
+
createServer: createStandaloneServer,
|
|
19
|
+
connections: ['main'],
|
|
20
|
+
config: {
|
|
21
|
+
app: { publicOrigin: 'http://localhost' },
|
|
22
|
+
auth: { secret: 'test-auth-secret-at-least-32-characters' },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('the real default composition supplies one memory lock domain to two business consumers', async ({
|
|
27
|
+
app,
|
|
28
|
+
}) => {
|
|
29
|
+
const service = app.container.resolve(concurrencyServiceToken);
|
|
30
|
+
const first = new BusinessConsumer(service);
|
|
31
|
+
const second = new BusinessConsumer(service);
|
|
32
|
+
expect(app.config.get('concurrency')).toEqual({
|
|
33
|
+
default: 'memory',
|
|
34
|
+
memory: { adapter: 'mutex' },
|
|
35
|
+
});
|
|
36
|
+
const [left, right] = await Promise.all([first.manager(), second.manager()]);
|
|
37
|
+
expect(left).toBe(right);
|
|
38
|
+
expect(left).toBe(await service.getManager());
|
|
39
|
+
const lock = await left.createLock('concurrency-test:business');
|
|
40
|
+
const executor = await right.createSynchronous('concurrency-test:business');
|
|
41
|
+
const lease = await lock.lock();
|
|
42
|
+
try {
|
|
43
|
+
expect(await executor.tryRun(() => 'should-not-run')).toEqual({
|
|
44
|
+
acquired: false,
|
|
45
|
+
});
|
|
46
|
+
} finally {
|
|
47
|
+
await lease.release();
|
|
48
|
+
}
|
|
49
|
+
expect(await executor.tryRun(() => 'done')).toEqual({
|
|
50
|
+
acquired: true,
|
|
51
|
+
value: 'done',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('declares only the default selector environment variable and no public configuration', () => {
|
|
56
|
+
expect(concurrency.rules?.public).toEqual([]);
|
|
57
|
+
expect(Object.keys(concurrency.rules?.env ?? {})).toEqual([
|
|
58
|
+
'CONCURRENCY_DEFAULT',
|
|
59
|
+
]);
|
|
60
|
+
expect(concurrency.rules?.validators).toHaveLength(1);
|
|
61
|
+
});
|