@nocobase/app-template-default 1.0.0-beta.75 → 1.0.0-beta.76
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 +4 -0
- package/.agents/rules/components.md +3 -1
- package/README.MD +2 -2
- package/client/components/data-table/data-table.tsx +2 -0
- package/client/components/data-table/pagination.tsx +6 -6
- package/client/components/flow-diagram.tsx +645 -83
- package/client/components/flow-layout.ts +117 -7
- package/client/components/lifecycle-diagram.tsx +175 -37
- package/client/components/record-link.tsx +20 -0
- package/client/components/required-role-setup.tsx +93 -0
- package/client/components/role-list.tsx +21 -5
- package/client/components/setup-readiness-state.ts +135 -0
- package/client/components/setup-readiness.tsx +91 -0
- package/client/components/ui/combobox.tsx +300 -0
- package/client/extensions/nocobase-workflow-management/README.md +3 -1
- package/client/extensions/nocobase-workflow-management/definition-list.tsx +19 -13
- package/client/extensions/nocobase-workflow-management/layout.tsx +1 -1
- package/client/hooks/use-table-url-state.ts +93 -0
- package/client/lib/required-role-setup.ts +98 -0
- package/client/lib/return-to.ts +19 -0
- package/client/locales/system/en-US.ts +10 -0
- package/client/locales/system/settings-en-US.ts +37 -1
- package/client/locales/system/settings-zh-CN.ts +33 -2
- package/client/locales/system/zh-CN.ts +10 -0
- package/client/pages/settings/default-access-form.tsx +4 -1
- package/client/pages/settings/default-access.tsx +40 -8
- package/client/pages/settings/role-form.tsx +1 -1
- package/client/pages/settings/roles.tsx +44 -6
- package/client/pages/settings/shared.tsx +15 -13
- package/client/pages/settings/user-detail.tsx +15 -259
- package/client/pages/settings/user-form.tsx +299 -147
- package/client/pages/settings/users.tsx +211 -123
- package/config.example.yml +6 -5
- package/package.json +11 -11
- package/tests/components/default-access.test.tsx +141 -2
- package/tests/components/settings-continuity.test.tsx +426 -0
- package/tests/components/settings-feedback.test.tsx +304 -0
- package/tests/components/settings.test.tsx +1 -1
- package/tests/components/setup-readiness.test.tsx +370 -0
- package/tests/components/table-url-state.test.tsx +95 -0
- package/tests/components/user-drawer.test.tsx +526 -0
- package/tests/logic/return-to.test.ts +19 -0
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useLocation, useNavigate } from 'react-router';
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import { useTableUrlState } from '../../client/hooks/use-table-url-state.js';
|
|
8
|
+
|
|
9
|
+
function Probe() {
|
|
10
|
+
const [count, setCount] = useState<number>();
|
|
11
|
+
const state = useTableUrlState({ sortColumns: ['name'], rowCount: count });
|
|
12
|
+
const location = useLocation();
|
|
13
|
+
const navigate = useNavigate();
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<button onClick={() => void navigate(-1)}>Back in history</button>
|
|
17
|
+
<output data-testid='state'>
|
|
18
|
+
{JSON.stringify(state.paginationState)}|
|
|
19
|
+
{JSON.stringify(state.sortingState)}
|
|
20
|
+
</output>
|
|
21
|
+
<output data-testid='url'>{location.search}</output>
|
|
22
|
+
<button onClick={() => setCount(25)}>Loaded</button>
|
|
23
|
+
<button onClick={() => setCount(10)}>Removed</button>
|
|
24
|
+
<button
|
|
25
|
+
onClick={() => state.onSortingChange([{ id: 'name', desc: true }])}
|
|
26
|
+
>
|
|
27
|
+
Sort
|
|
28
|
+
</button>
|
|
29
|
+
<button
|
|
30
|
+
onClick={() =>
|
|
31
|
+
state.onPaginationChange((old) => ({ ...old, pageIndex: 1 }))
|
|
32
|
+
}
|
|
33
|
+
>
|
|
34
|
+
Next
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
onClick={() =>
|
|
38
|
+
void navigate('?page=2&pageSize=20&sort=name&order=asc&q=historic')
|
|
39
|
+
}
|
|
40
|
+
>
|
|
41
|
+
History
|
|
42
|
+
</button>
|
|
43
|
+
</>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
it('clamps only a successfully loaded count and retains unrelated query context', async () => {
|
|
47
|
+
await renderWithApp(<Probe />, {
|
|
48
|
+
route: '/?page=5&pageSize=10&q=kept&returnTo=%2Fworkflows',
|
|
49
|
+
});
|
|
50
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":4');
|
|
51
|
+
await userEvent.click(screen.getByRole('button', { name: 'Loaded' }));
|
|
52
|
+
await waitFor(() =>
|
|
53
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2'),
|
|
54
|
+
);
|
|
55
|
+
await userEvent.click(screen.getByRole('button', { name: 'Removed' }));
|
|
56
|
+
await waitFor(() =>
|
|
57
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":0'),
|
|
58
|
+
);
|
|
59
|
+
expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
|
|
60
|
+
expect(screen.getByTestId('url')).toHaveTextContent('returnTo=%2Fworkflows');
|
|
61
|
+
});
|
|
62
|
+
it('restores external URL changes, resets the page on sort and retains sorting when paging', async () => {
|
|
63
|
+
await renderWithApp(<Probe />, { route: '/?page=3&pageSize=10' });
|
|
64
|
+
await userEvent.click(screen.getByRole('button', { name: 'History' }));
|
|
65
|
+
expect(screen.getByTestId('state')).toHaveTextContent(
|
|
66
|
+
'"pageIndex":1,"pageSize":20',
|
|
67
|
+
);
|
|
68
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"desc":false');
|
|
69
|
+
await userEvent.click(screen.getByRole('button', { name: 'Sort' }));
|
|
70
|
+
expect(screen.getByTestId('url')).not.toHaveTextContent('page=2');
|
|
71
|
+
expect(screen.getByTestId('url')).toHaveTextContent('q=historic');
|
|
72
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"desc":true');
|
|
73
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
74
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":1');
|
|
75
|
+
expect(screen.getByTestId('url')).toHaveTextContent('order=desc');
|
|
76
|
+
});
|
|
77
|
+
it('normalizes invalid pagination and ignores unsupported sort columns', async () => {
|
|
78
|
+
await renderWithApp(<Probe />, {
|
|
79
|
+
route: '/?page=1.5&pageSize=3&sort=unknown&order=desc',
|
|
80
|
+
});
|
|
81
|
+
expect(screen.getByTestId('state')).toHaveTextContent(
|
|
82
|
+
'{"pageIndex":0,"pageSize":10}|[]',
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('allows browser Back to restore the preceding page', async () => {
|
|
87
|
+
await renderWithApp(<Probe />, { route: '/?page=3&pageSize=10&q=kept' });
|
|
88
|
+
await userEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
89
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":1');
|
|
90
|
+
await userEvent.click(
|
|
91
|
+
screen.getByRole('button', { name: 'Back in history' }),
|
|
92
|
+
);
|
|
93
|
+
expect(screen.getByTestId('state')).toHaveTextContent('"pageIndex":2');
|
|
94
|
+
expect(screen.getByTestId('url')).toHaveTextContent('q=kept');
|
|
95
|
+
});
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { answerApi, renderWithApp } from '@nocobase/app-testing/client';
|
|
2
|
+
import { apiClientToken, type ClientApplication } from '@nocobase/app-client';
|
|
3
|
+
import {
|
|
4
|
+
AuthorizationClient,
|
|
5
|
+
authorizationClientToken,
|
|
6
|
+
} from '@nocobase/app-plugin-authorization/client';
|
|
7
|
+
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
|
|
8
|
+
import userEvent from '@testing-library/user-event';
|
|
9
|
+
import { Routes, Route, useLocation } from 'react-router';
|
|
10
|
+
import { expect, it, vi } from 'vitest';
|
|
11
|
+
import UsersPage from '../../client/pages/settings/users.js';
|
|
12
|
+
import UserDetail from '../../client/pages/settings/user-detail.js';
|
|
13
|
+
import UserForm from '../../client/pages/settings/user-form.js';
|
|
14
|
+
import enUS from '../../client/locales/en-US.js';
|
|
15
|
+
import zhCN from '../../client/locales/zh-CN.js';
|
|
16
|
+
|
|
17
|
+
const namespace = '@nocobase/user-drawer-test';
|
|
18
|
+
const namespaces = {
|
|
19
|
+
[namespace]: {
|
|
20
|
+
'en-US': async () => ({ default: enUS }),
|
|
21
|
+
'zh-CN': async () => ({ default: zhCN }),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const services = (app: ClientApplication) =>
|
|
25
|
+
app.container.instance(
|
|
26
|
+
authorizationClientToken,
|
|
27
|
+
new AuthorizationClient(app.container.resolve(apiClientToken)),
|
|
28
|
+
);
|
|
29
|
+
const original = {
|
|
30
|
+
id: 'alice',
|
|
31
|
+
name: 'Alice',
|
|
32
|
+
username: 'alice',
|
|
33
|
+
email: 'alice@example.test',
|
|
34
|
+
disabledAt: null,
|
|
35
|
+
roleScopes: {},
|
|
36
|
+
};
|
|
37
|
+
const search = '?q=Ali&status=enabled&page=2&pageSize=20&order=desc';
|
|
38
|
+
function Location() {
|
|
39
|
+
const location = useLocation();
|
|
40
|
+
return (
|
|
41
|
+
<output data-testid='location'>
|
|
42
|
+
{location.pathname}
|
|
43
|
+
{location.search}
|
|
44
|
+
</output>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
function Pages({ base }: { readonly base: string }) {
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<Routes>
|
|
51
|
+
<Route path={base} element={<UsersPage />}>
|
|
52
|
+
<Route path='create' element={<UserForm />} />
|
|
53
|
+
<Route path=':userId' element={<UserDetail />}>
|
|
54
|
+
<Route path='edit' element={<UserForm />} />
|
|
55
|
+
</Route>
|
|
56
|
+
</Route>
|
|
57
|
+
<Route path='*' element={<h1>Fallback home</h1>} />
|
|
58
|
+
</Routes>
|
|
59
|
+
<Location />
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
async function renderUsers(
|
|
64
|
+
base: string,
|
|
65
|
+
child = '',
|
|
66
|
+
unrestricted = true,
|
|
67
|
+
withRole = false,
|
|
68
|
+
actions: string[] = [],
|
|
69
|
+
initialRoles: Record<string, string[]> = {},
|
|
70
|
+
requiredRole = false,
|
|
71
|
+
) {
|
|
72
|
+
let current = { ...original, roleScopes: initialRoles };
|
|
73
|
+
let roleFailure = false;
|
|
74
|
+
const calls = vi.fn(
|
|
75
|
+
({
|
|
76
|
+
path,
|
|
77
|
+
method,
|
|
78
|
+
json,
|
|
79
|
+
}: {
|
|
80
|
+
path: string;
|
|
81
|
+
method: string;
|
|
82
|
+
json?: {
|
|
83
|
+
name?: string;
|
|
84
|
+
username?: string;
|
|
85
|
+
email?: string;
|
|
86
|
+
roleScopes?: Record<string, string[]>;
|
|
87
|
+
};
|
|
88
|
+
}) => {
|
|
89
|
+
if (path === 'authorization/permissions')
|
|
90
|
+
return {
|
|
91
|
+
data: {
|
|
92
|
+
unrestricted,
|
|
93
|
+
permissions: actions.length
|
|
94
|
+
? [{ resource: { type: 'user', id: 'alice' }, actions }]
|
|
95
|
+
: [],
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
if (path === 'users/options')
|
|
99
|
+
return {
|
|
100
|
+
data: {
|
|
101
|
+
roleScopes: withRole
|
|
102
|
+
? [
|
|
103
|
+
{
|
|
104
|
+
key: 'app',
|
|
105
|
+
label: 'Application',
|
|
106
|
+
selection: 'multiple',
|
|
107
|
+
requiredOnCreate: requiredRole,
|
|
108
|
+
options: [
|
|
109
|
+
{ value: 'sales', label: 'Sales team' },
|
|
110
|
+
{ value: 'finance', label: 'Finance team' },
|
|
111
|
+
{
|
|
112
|
+
value: 'root',
|
|
113
|
+
label: 'System administrator',
|
|
114
|
+
assignable: false,
|
|
115
|
+
removable: false,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
if (path === 'users/alice') {
|
|
124
|
+
if (method === 'PATCH') {
|
|
125
|
+
if (roleFailure)
|
|
126
|
+
return Response.json(
|
|
127
|
+
{
|
|
128
|
+
error: {
|
|
129
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
130
|
+
message: 'Unavailable',
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{ status: 500 },
|
|
134
|
+
);
|
|
135
|
+
current = {
|
|
136
|
+
...current,
|
|
137
|
+
...json,
|
|
138
|
+
roleScopes: { ...current.roleScopes, ...json?.roleScopes },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return { data: current };
|
|
142
|
+
}
|
|
143
|
+
return { data: [current], meta: { total: 41 } };
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
const view = await renderWithApp(<Pages base={base} />, {
|
|
147
|
+
route: `${base}${child}${search}`,
|
|
148
|
+
namespace,
|
|
149
|
+
namespaces,
|
|
150
|
+
services,
|
|
151
|
+
fetch: answerApi(calls),
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
...view,
|
|
155
|
+
calls,
|
|
156
|
+
failRole: () => {
|
|
157
|
+
roleFailure = true;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function selectSales(user: ReturnType<typeof userEvent.setup>) {
|
|
163
|
+
await user.click(
|
|
164
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
165
|
+
);
|
|
166
|
+
await user.click(
|
|
167
|
+
await screen.findByRole('option', { name: 'Sales team', exact: true }),
|
|
168
|
+
);
|
|
169
|
+
await user.keyboard('{Escape}');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it.each(['/users', '/settings/users'])(
|
|
173
|
+
'opens name editing in a drawer with inline account actions and cancels to the same list at %s',
|
|
174
|
+
async (base) => {
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
await renderUsers(base);
|
|
177
|
+
const edit = await screen.findByRole('link', {
|
|
178
|
+
name: 'Alice',
|
|
179
|
+
exact: true,
|
|
180
|
+
});
|
|
181
|
+
expect(edit).toHaveAttribute('href', `${base}/alice/edit${search}`);
|
|
182
|
+
expect(
|
|
183
|
+
screen.getByRole('button', { name: 'Disable', exact: true }),
|
|
184
|
+
).toBeVisible();
|
|
185
|
+
expect(
|
|
186
|
+
screen.getByRole('button', { name: 'Revoke sessions', exact: true }),
|
|
187
|
+
).toBeVisible();
|
|
188
|
+
expect(
|
|
189
|
+
screen.queryByRole('button', { name: 'Edit Alice' }),
|
|
190
|
+
).not.toBeInTheDocument();
|
|
191
|
+
expect(
|
|
192
|
+
screen.queryByRole('button', { name: 'Actions for Alice' }),
|
|
193
|
+
).not.toBeInTheDocument();
|
|
194
|
+
if (base === '/users') {
|
|
195
|
+
edit.focus();
|
|
196
|
+
await user.keyboard('{Enter}');
|
|
197
|
+
} else await user.click(edit);
|
|
198
|
+
const panel = await screen.findByRole('dialog', { name: 'Edit user' });
|
|
199
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
200
|
+
expect(panel).toHaveAttribute('data-presentation', 'drawer');
|
|
201
|
+
const name = await within(panel).findByRole('textbox', { name: 'Name *' });
|
|
202
|
+
await waitFor(() => expect(name).toHaveFocus());
|
|
203
|
+
await user.click(
|
|
204
|
+
within(panel).getByRole('button', { name: 'Cancel', exact: true }),
|
|
205
|
+
);
|
|
206
|
+
await waitFor(() =>
|
|
207
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
208
|
+
);
|
|
209
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
210
|
+
`${base}${search}`,
|
|
211
|
+
);
|
|
212
|
+
await waitFor(() =>
|
|
213
|
+
expect(
|
|
214
|
+
screen.getByRole('textbox', { name: enUS.settings.userSearch }),
|
|
215
|
+
).toHaveFocus(),
|
|
216
|
+
);
|
|
217
|
+
expect(screen.queryByText('Fallback home')).not.toBeInTheDocument();
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
it.each(['/users', '/settings/users'])(
|
|
222
|
+
'validates and saves profile fields from the sole fixed-footer Save at %s',
|
|
223
|
+
async (base) => {
|
|
224
|
+
const user = userEvent.setup();
|
|
225
|
+
const view = await renderUsers(base, '/alice/edit');
|
|
226
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
227
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
228
|
+
const save = screen.getByRole('button', { name: 'Save', exact: true });
|
|
229
|
+
expect(
|
|
230
|
+
save.closest('[data-slot="route-overlay-footer"]'),
|
|
231
|
+
).toBeInTheDocument();
|
|
232
|
+
await user.clear(name);
|
|
233
|
+
await user.click(save);
|
|
234
|
+
expect(await screen.findByText(enUS.settings.required)).toBeVisible();
|
|
235
|
+
expect(
|
|
236
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
237
|
+
).toHaveLength(0);
|
|
238
|
+
await user.type(name, 'Alice updated');
|
|
239
|
+
const form = name.closest('form')!;
|
|
240
|
+
expect(save).toHaveProperty('form', form);
|
|
241
|
+
fireEvent.submit(form);
|
|
242
|
+
await waitFor(() =>
|
|
243
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
244
|
+
);
|
|
245
|
+
expect(view.toasts()).toHaveLength(1);
|
|
246
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
247
|
+
expect.objectContaining({
|
|
248
|
+
path: 'users/alice',
|
|
249
|
+
method: 'PATCH',
|
|
250
|
+
json: {
|
|
251
|
+
name: 'Alice updated',
|
|
252
|
+
username: 'alice',
|
|
253
|
+
email: 'alice@example.test',
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
await screen.findByText('Alice updated', { exact: true }),
|
|
259
|
+
).toBeVisible();
|
|
260
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
261
|
+
`${base}${search}`,
|
|
262
|
+
);
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
it('redirects legacy URLs and uses a searchable multi-select with one Save for both drafts', async () => {
|
|
267
|
+
const user = userEvent.setup();
|
|
268
|
+
const view = await renderUsers('/users', '/alice', true, true);
|
|
269
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
270
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
271
|
+
expect(screen.getByTestId('location')).toHaveTextContent(
|
|
272
|
+
`/users/alice/edit${search}`,
|
|
273
|
+
);
|
|
274
|
+
expect(
|
|
275
|
+
screen.getAllByRole('button', { name: 'Save', exact: true }),
|
|
276
|
+
).toHaveLength(1);
|
|
277
|
+
await user.type(name, ' updated');
|
|
278
|
+
await user.click(
|
|
279
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
280
|
+
);
|
|
281
|
+
const searchRoles = await screen.findByRole('combobox', {
|
|
282
|
+
name: enUS.settings.searchRoles,
|
|
283
|
+
});
|
|
284
|
+
await user.type(searchRoles, 'Sales');
|
|
285
|
+
expect(
|
|
286
|
+
await screen.findByRole('option', { name: 'Sales team' }),
|
|
287
|
+
).toBeVisible();
|
|
288
|
+
expect(
|
|
289
|
+
screen.queryByRole('option', { name: 'Finance team' }),
|
|
290
|
+
).not.toBeInTheDocument();
|
|
291
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
292
|
+
await user.keyboard('{Escape}');
|
|
293
|
+
expect(
|
|
294
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
295
|
+
).toHaveTextContent('Sales team');
|
|
296
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
297
|
+
await waitFor(() =>
|
|
298
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
299
|
+
);
|
|
300
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
path: 'users/alice',
|
|
303
|
+
method: 'PATCH',
|
|
304
|
+
json: {
|
|
305
|
+
name: 'Alice updated',
|
|
306
|
+
username: 'alice',
|
|
307
|
+
email: 'alice@example.test',
|
|
308
|
+
roleScopes: { app: ['sales'] },
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
expect(
|
|
313
|
+
view.calls.mock.calls.filter(([call]) =>
|
|
314
|
+
['PATCH', 'PUT'].includes(call.method),
|
|
315
|
+
),
|
|
316
|
+
).toHaveLength(1);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('retains both drafts on failed unified save and keeps one discard guard', async () => {
|
|
320
|
+
const user = userEvent.setup();
|
|
321
|
+
const view = await renderUsers('/users', '/alice/edit', true, true);
|
|
322
|
+
view.failRole();
|
|
323
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
324
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
325
|
+
await user.type(name, ' draft');
|
|
326
|
+
await selectSales(user);
|
|
327
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
328
|
+
expect(await screen.findByText(enUS.settings.failed)).toBeVisible();
|
|
329
|
+
expect(name).toHaveValue('Alice draft');
|
|
330
|
+
expect(
|
|
331
|
+
screen.getByRole('combobox', { name: 'Application', exact: true }),
|
|
332
|
+
).toHaveTextContent('Sales team');
|
|
333
|
+
expect(view.toasts()).toHaveLength(0);
|
|
334
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
335
|
+
const confirm = await screen.findByRole('alertdialog');
|
|
336
|
+
await user.click(
|
|
337
|
+
within(confirm).getByRole('button', { name: 'Cancel', exact: true }),
|
|
338
|
+
);
|
|
339
|
+
expect(name).toHaveValue('Alice draft');
|
|
340
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
341
|
+
await user.click(
|
|
342
|
+
within(await screen.findByRole('alertdialog')).getByRole('button', {
|
|
343
|
+
name: 'Confirm',
|
|
344
|
+
exact: true,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
await waitFor(() =>
|
|
348
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
349
|
+
);
|
|
350
|
+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('keeps names as plain text and hides account actions for read-only users', async () => {
|
|
354
|
+
await renderUsers('/users', '', false, true, ['read']);
|
|
355
|
+
expect(await screen.findByText('Alice', { exact: true })).toBeVisible();
|
|
356
|
+
expect(
|
|
357
|
+
screen.queryByRole('link', { name: 'Alice', exact: true }),
|
|
358
|
+
).not.toBeInTheDocument();
|
|
359
|
+
for (const name of ['Disable', 'Enable', 'Revoke sessions'])
|
|
360
|
+
expect(
|
|
361
|
+
screen.queryByRole('button', { name, exact: true }),
|
|
362
|
+
).not.toBeInTheDocument();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('keeps the editor read-only without management permission', async () => {
|
|
366
|
+
await renderUsers('/users', '/alice/edit', false, true);
|
|
367
|
+
expect(await screen.findByRole('textbox', { name: 'Name *' })).toBeDisabled();
|
|
368
|
+
expect(
|
|
369
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
370
|
+
).toBeDisabled();
|
|
371
|
+
expect(
|
|
372
|
+
screen.queryByRole('button', { name: 'Save', exact: true }),
|
|
373
|
+
).not.toBeInTheDocument();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('lets role-only managers save only roleScopes and prevents protected role selection', async () => {
|
|
377
|
+
const user = userEvent.setup();
|
|
378
|
+
const view = await renderUsers('/users', '', false, true, [
|
|
379
|
+
'read',
|
|
380
|
+
'assign-role',
|
|
381
|
+
]);
|
|
382
|
+
const edit = await screen.findByRole('link', {
|
|
383
|
+
name: 'Alice',
|
|
384
|
+
exact: true,
|
|
385
|
+
});
|
|
386
|
+
expect(
|
|
387
|
+
screen.queryByRole('button', { name: 'Actions for Alice' }),
|
|
388
|
+
).not.toBeInTheDocument();
|
|
389
|
+
await user.click(edit);
|
|
390
|
+
expect(await screen.findByRole('textbox', { name: 'Name *' })).toBeDisabled();
|
|
391
|
+
await user.click(
|
|
392
|
+
await screen.findByRole('combobox', { name: 'Application', exact: true }),
|
|
393
|
+
);
|
|
394
|
+
expect(
|
|
395
|
+
await screen.findByRole('option', { name: 'System administrator' }),
|
|
396
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
397
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
398
|
+
await user.keyboard('{Escape}');
|
|
399
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
400
|
+
await waitFor(() =>
|
|
401
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
402
|
+
);
|
|
403
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
404
|
+
expect.objectContaining({
|
|
405
|
+
method: 'PATCH',
|
|
406
|
+
json: { roleScopes: { app: ['sales'] } },
|
|
407
|
+
}),
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('blocks closing and duplicate submission until the unified save completes', async () => {
|
|
412
|
+
const user = userEvent.setup();
|
|
413
|
+
let current = { ...original };
|
|
414
|
+
let complete!: (value: unknown) => void;
|
|
415
|
+
const pending = new Promise((resolve) => {
|
|
416
|
+
complete = resolve;
|
|
417
|
+
});
|
|
418
|
+
const calls = vi.fn(({ path, method }: { path: string; method: string }) => {
|
|
419
|
+
if (path === 'authorization/permissions')
|
|
420
|
+
return { data: { unrestricted: true, permissions: [] } };
|
|
421
|
+
if (path === 'users/options') return { data: { roleScopes: [] } };
|
|
422
|
+
if (path === 'users/alice' && method === 'PATCH') return pending;
|
|
423
|
+
if (path === 'users/alice') return { data: current };
|
|
424
|
+
return { data: [current], meta: { total: 41 } };
|
|
425
|
+
});
|
|
426
|
+
const view = await renderWithApp(<Pages base='/users' />, {
|
|
427
|
+
route: `/users/alice/edit${search}`,
|
|
428
|
+
namespace,
|
|
429
|
+
namespaces,
|
|
430
|
+
services,
|
|
431
|
+
fetch: answerApi(calls),
|
|
432
|
+
});
|
|
433
|
+
const name = await screen.findByRole('textbox', { name: 'Name *' });
|
|
434
|
+
await waitFor(() => expect(name).toHaveValue('Alice'));
|
|
435
|
+
await user.type(name, ' changed');
|
|
436
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
437
|
+
await waitFor(() => expect(name).toBeDisabled());
|
|
438
|
+
expect(
|
|
439
|
+
screen.getByRole('button', { name: enUS.settings.saving }),
|
|
440
|
+
).toBeDisabled();
|
|
441
|
+
expect(
|
|
442
|
+
screen.getByRole('button', { name: 'Cancel', exact: true }),
|
|
443
|
+
).toBeDisabled();
|
|
444
|
+
await user.click(screen.getByRole('button', { name: 'Close', exact: true }));
|
|
445
|
+
expect(screen.getAllByRole('dialog')).toHaveLength(1);
|
|
446
|
+
expect(view.toasts()).toHaveLength(0);
|
|
447
|
+
current = { ...original, name: 'Alice changed' };
|
|
448
|
+
complete({ data: current });
|
|
449
|
+
await waitFor(() =>
|
|
450
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
451
|
+
);
|
|
452
|
+
expect(
|
|
453
|
+
calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
454
|
+
).toHaveLength(1);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('preserves protected and unknown assignments and confirms actual removals before the one Save', async () => {
|
|
458
|
+
const user = userEvent.setup();
|
|
459
|
+
const view = await renderUsers('/users', '/alice/edit', true, true, [], {
|
|
460
|
+
app: ['root', 'sales', 'legacy-role'],
|
|
461
|
+
external: ['retained'],
|
|
462
|
+
});
|
|
463
|
+
const picker = await screen.findByRole('combobox', {
|
|
464
|
+
name: 'Application',
|
|
465
|
+
exact: true,
|
|
466
|
+
});
|
|
467
|
+
expect(picker).toHaveTextContent('legacy-role');
|
|
468
|
+
await user.click(picker);
|
|
469
|
+
expect(
|
|
470
|
+
await screen.findByRole('option', { name: 'System administrator' }),
|
|
471
|
+
).toHaveAttribute('aria-disabled', 'true');
|
|
472
|
+
expect(screen.getByRole('option', { name: 'legacy-role' })).toHaveAttribute(
|
|
473
|
+
'aria-selected',
|
|
474
|
+
'true',
|
|
475
|
+
);
|
|
476
|
+
await user.click(screen.getByRole('option', { name: 'Sales team' }));
|
|
477
|
+
await user.keyboard('{Escape}');
|
|
478
|
+
await user.click(screen.getByRole('button', { name: 'Save', exact: true }));
|
|
479
|
+
const confirm = await screen.findByRole('alertdialog');
|
|
480
|
+
expect(confirm).toHaveTextContent('Sales team');
|
|
481
|
+
expect(
|
|
482
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'PATCH'),
|
|
483
|
+
).toHaveLength(0);
|
|
484
|
+
await user.click(
|
|
485
|
+
within(confirm).getByRole('button', { name: 'Save', exact: true }),
|
|
486
|
+
);
|
|
487
|
+
await waitFor(() =>
|
|
488
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
|
489
|
+
);
|
|
490
|
+
expect(view.calls).toHaveBeenCalledWith(
|
|
491
|
+
expect.objectContaining({
|
|
492
|
+
method: 'PATCH',
|
|
493
|
+
json: expect.objectContaining({
|
|
494
|
+
roleScopes: { app: ['root', 'legacy-role'] },
|
|
495
|
+
}),
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('focuses and describes the required role dropdown when creating without a role', async () => {
|
|
501
|
+
const user = userEvent.setup();
|
|
502
|
+
const view = await renderUsers('/users', '/create', true, true, [], {}, true);
|
|
503
|
+
await user.type(
|
|
504
|
+
await screen.findByRole('textbox', { name: 'Name *' }),
|
|
505
|
+
'New user',
|
|
506
|
+
);
|
|
507
|
+
await user.type(
|
|
508
|
+
screen.getByRole('textbox', { name: 'Email *' }),
|
|
509
|
+
'new@example.test',
|
|
510
|
+
);
|
|
511
|
+
await user.type(
|
|
512
|
+
screen.getByLabelText(`${enUS.settings.password} *`),
|
|
513
|
+
'long-enough-password',
|
|
514
|
+
);
|
|
515
|
+
await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
|
|
516
|
+
const roles = screen.getByRole('combobox', {
|
|
517
|
+
name: 'Application',
|
|
518
|
+
exact: true,
|
|
519
|
+
});
|
|
520
|
+
await waitFor(() => expect(roles).toHaveFocus());
|
|
521
|
+
expect(roles).toHaveAttribute('aria-invalid', 'true');
|
|
522
|
+
expect(roles).toHaveAccessibleDescription(enUS.settings.required);
|
|
523
|
+
expect(
|
|
524
|
+
view.calls.mock.calls.filter(([call]) => call.method === 'POST'),
|
|
525
|
+
).toHaveLength(0);
|
|
526
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import { safeReturnTo } from '../../client/lib/return-to.js';
|
|
3
|
+
it('accepts application-relative state and rejects external or ambiguous destinations', () => {
|
|
4
|
+
expect(safeReturnTo('/settings/processes/loan?q=review&page=2#node')).toBe(
|
|
5
|
+
'/settings/processes/loan?q=review&page=2#node',
|
|
6
|
+
);
|
|
7
|
+
for (const value of [
|
|
8
|
+
null,
|
|
9
|
+
'',
|
|
10
|
+
'https://outside.test',
|
|
11
|
+
'//outside.test',
|
|
12
|
+
'/a/..//outside.test',
|
|
13
|
+
'/\\outside.test',
|
|
14
|
+
'/\n/outside.test',
|
|
15
|
+
'javascript:alert(1)',
|
|
16
|
+
'settings/users',
|
|
17
|
+
])
|
|
18
|
+
expect(safeReturnTo(value)).toBeUndefined();
|
|
19
|
+
});
|