@truedat/auth 8.14.3 → 8.14.5

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.
Files changed (78) hide show
  1. package/package.json +4 -4
  2. package/src/groups/components/GroupCard.js +2 -2
  3. package/src/groups/components/GroupCards.js +129 -37
  4. package/src/groups/components/GroupForm.js +13 -7
  5. package/src/groups/components/GroupFormModal.js +8 -7
  6. package/src/groups/components/GroupInfoModal.js +6 -6
  7. package/src/groups/components/GroupMemberPicker.js +2 -4
  8. package/src/groups/components/GroupRouteModal.js +15 -10
  9. package/src/groups/components/__tests__/GroupCards.spec.js +94 -0
  10. package/src/groups/components/__tests__/GroupForm.spec.js +2 -2
  11. package/src/groups/components/__tests__/GroupModals.spec.js +42 -16
  12. package/src/groups/components/__tests__/__snapshots__/GroupCards.spec.js.snap +14 -0
  13. package/src/messages/en.js +6 -5
  14. package/src/messages/es.js +8 -7
  15. package/src/styles/Groups.less +96 -131
  16. package/src/styles/UserCard.less +416 -0
  17. package/src/styles/UserModals.less +831 -0
  18. package/src/styles/Users.less +313 -0
  19. package/src/users/components/AdminUserRoutes.js +7 -20
  20. package/src/users/components/EditUser.js +7 -34
  21. package/src/users/components/EditUserModal.js +150 -0
  22. package/src/users/components/NewUser.js +128 -17
  23. package/src/users/components/Password.js +11 -189
  24. package/src/users/components/UserAcls.js +117 -6
  25. package/src/users/components/UserActions.js +99 -34
  26. package/src/users/components/UserCard.js +171 -22
  27. package/src/users/components/UserCards.js +188 -35
  28. package/src/users/components/UserFormFields.js +206 -0
  29. package/src/users/components/UserGroupAclRow.js +8 -2
  30. package/src/users/components/UserGroupAcls.js +2 -2
  31. package/src/users/components/UserGroupLink.js +21 -0
  32. package/src/users/components/UserInfoModal.js +228 -0
  33. package/src/users/components/UserPasswordField.js +66 -0
  34. package/src/users/components/UserPasswordModal.js +210 -0
  35. package/src/users/components/UserRoutes.js +4 -13
  36. package/src/users/components/UserTabs.js +5 -2
  37. package/src/users/components/UserTypeSelector.js +109 -0
  38. package/src/users/components/UsersAndGroups.js +39 -5
  39. package/src/users/components/UsersTable.js +234 -0
  40. package/src/users/components/__tests__/EditUser.spec.js +84 -10
  41. package/src/users/components/__tests__/NewUser.spec.js +34 -4
  42. package/src/users/components/__tests__/Password.spec.js +53 -146
  43. package/src/users/components/__tests__/UserAcls.spec.js +62 -2
  44. package/src/users/components/__tests__/UserCard.spec.js +111 -5
  45. package/src/users/components/__tests__/UserCards.spec.js +149 -0
  46. package/src/users/components/__tests__/UserGroupAclRow.spec.js +19 -1
  47. package/src/users/components/__tests__/UserModals.spec.js +554 -0
  48. package/src/users/components/__tests__/UserRoutes.spec.js +8 -3
  49. package/src/users/components/__tests__/UserTypeSelector.spec.js +32 -0
  50. package/src/users/components/__tests__/UsersAndGroups.spec.js +17 -0
  51. package/src/users/components/__tests__/__snapshots__/UserCard.spec.js.snap +66 -37
  52. package/src/users/components/__tests__/__snapshots__/UserCards.spec.js.snap +375 -75
  53. package/src/users/components/__tests__/__snapshots__/UserGroupAclRow.spec.js.snap +7 -3
  54. package/src/users/components/__tests__/__snapshots__/UserRoutes.spec.js.snap +4 -13
  55. package/src/users/hooks/useSortedGroups.js +13 -0
  56. package/src/users/hooks/useUserModalOperation.js +55 -0
  57. package/src/users/reducers/__tests__/userRedirect.spec.js +14 -3
  58. package/src/users/reducers/index.js +1 -0
  59. package/src/users/reducers/userModalOperation.js +29 -0
  60. package/src/users/reducers/userRedirect.js +5 -4
  61. package/src/users/sagas/__tests__/updateUser.spec.js +19 -1
  62. package/src/users/sagas/createUser.js +12 -5
  63. package/src/users/sagas/deleteUser.js +12 -5
  64. package/src/users/sagas/updateUser.js +19 -8
  65. package/src/users/selectors/__tests__/filterUsers.spec.js +11 -0
  66. package/src/users/selectors/__tests__/getUserAcls.spec.js +1 -0
  67. package/src/users/selectors/__tests__/getUserGroupAcls.spec.js +2 -0
  68. package/src/users/selectors/filterUsers.js +9 -1
  69. package/src/users/selectors/getUserGroupAcls.js +1 -0
  70. package/src/users/components/User.js +0 -68
  71. package/src/users/components/UserPassword.js +0 -24
  72. package/src/users/components/__tests__/User.spec.js +0 -20
  73. package/src/users/components/__tests__/UserPassword.spec.js +0 -24
  74. package/src/users/components/__tests__/__snapshots__/EditUser.spec.js.snap +0 -281
  75. package/src/users/components/__tests__/__snapshots__/NewUser.spec.js.snap +0 -321
  76. package/src/users/components/__tests__/__snapshots__/Password.spec.js.snap +0 -128
  77. package/src/users/components/__tests__/__snapshots__/User.spec.js.snap +0 -129
  78. package/src/users/components/__tests__/__snapshots__/UserPassword.spec.js.snap +0 -124
@@ -0,0 +1,554 @@
1
+ import { act, fireEvent, screen, waitFor } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { Provider } from "react-redux";
4
+ import { createStore, combineReducers, applyMiddleware } from "redux";
5
+ import { render } from "@truedat/test/render";
6
+ import coreEs from "@truedat/core/messages/es";
7
+ import authEs from "../../../messages/es";
8
+ import { userModalOperation } from "../../reducers/userModalOperation";
9
+ import {
10
+ createUser,
11
+ updateUser,
12
+ updatePassword,
13
+ deleteUser,
14
+ } from "../../routines";
15
+ import { NewUser } from "../NewUser";
16
+ import { EditUserModal } from "../EditUserModal";
17
+ import { UserInfoModal } from "../UserInfoModal";
18
+ import { UserPasswordModal } from "../UserPasswordModal";
19
+ import { DeleteUserModal } from "../UserActions";
20
+
21
+ describe("User modal shell", () => {
22
+ const user = { id: 1, user_name: "alice", full_name: "Alice", role: "user" };
23
+
24
+ it("confirms deletion from edit and preserves edits when cancelled", async () => {
25
+ const onClose = jest.fn();
26
+ const dispatch = jest.fn();
27
+ render(<EditUserModal user={user} groups={[]} onClose={onClose} />, {
28
+ dispatch,
29
+ });
30
+ const keyboard = userEvent.setup({ delay: null });
31
+ fireEvent.change(document.getElementById("full_name"), {
32
+ target: { value: "Alice Updated" },
33
+ });
34
+ await keyboard.click(screen.getByRole("button", { name: "Delete user" }));
35
+ expect(screen.getAllByRole("dialog")).toHaveLength(1);
36
+ expect(
37
+ screen.getByRole("button", { name: "user.delete.submit" }),
38
+ ).toBeVisible();
39
+ expect(onClose).not.toHaveBeenCalled();
40
+ await keyboard.click(
41
+ screen.getByRole("button", { name: "actions.cancel" }),
42
+ );
43
+ expect(document.getElementById("full_name")).toHaveValue("Alice Updated");
44
+ await keyboard.click(screen.getByRole("button", { name: "Delete user" }));
45
+ await keyboard.click(
46
+ screen.getByRole("button", { name: "user.delete.submit" }),
47
+ );
48
+ expect(dispatch).toHaveBeenCalledWith(
49
+ expect.objectContaining({
50
+ type: deleteUser.TRIGGER,
51
+ payload: { id: user.id },
52
+ }),
53
+ );
54
+ });
55
+
56
+ it.each(["alice", "admin"])(
57
+ "waits for the target user before showing password fields for session %s",
58
+ (authUserName) => {
59
+ const onClose = jest.fn();
60
+ const { rerender } = render(
61
+ <UserPasswordModal
62
+ user={{}}
63
+ authUserName={authUserName}
64
+ onClose={onClose}
65
+ />,
66
+ );
67
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
68
+ expect(document.getElementById("currentPassword")).toBeNull();
69
+
70
+ rerender(
71
+ <UserPasswordModal
72
+ user={user}
73
+ authUserName={authUserName}
74
+ onClose={onClose}
75
+ />,
76
+ );
77
+ expect(screen.getByRole("dialog")).toBeVisible();
78
+ if (authUserName === user.user_name) {
79
+ expect(document.getElementById("currentPassword")).toBeVisible();
80
+ } else {
81
+ expect(document.getElementById("currentPassword")).toBeNull();
82
+ }
83
+ },
84
+ );
85
+
86
+ it.each([
87
+ ["finance", "finance"],
88
+ ["Finance", "Finance"],
89
+ [{ alias: "Finance" }, "Finance"],
90
+ [{ name: "Finance" }, "Finance"],
91
+ [{ name: "finance", alias: "Finance" }, "Finance"],
92
+ [{ id: 7, name: "finance", alias: "Finance" }, "Finance"],
93
+ ])("links group %p to its detail route", (group, label) => {
94
+ render(
95
+ <UserInfoModal
96
+ user={{ ...user, groups: [group] }}
97
+ groups={[{ id: 7, name: "finance", alias: "Finance" }]}
98
+ onClose={jest.fn()}
99
+ />,
100
+ );
101
+ const link = screen.getByRole("link", { name: label });
102
+ expect(link).toHaveAttribute("href", "/groups/7");
103
+ expect(link).toHaveAttribute("target", "_blank");
104
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
105
+ expect(link.querySelector(".external.alternate.icon")).toHaveAttribute(
106
+ "aria-hidden",
107
+ "true",
108
+ );
109
+ });
110
+
111
+ it.each([
112
+ [NewUser, { createUser: jest.fn() }, "new-user-form"],
113
+ [EditUserModal, { user, updateUser: jest.fn() }, "edit-user-form"],
114
+ [
115
+ UserPasswordModal,
116
+ { user, updatePassword: jest.fn() },
117
+ "password-modal-form",
118
+ ],
119
+ [UserInfoModal, { user }, null],
120
+ ])(
121
+ "renders %p through the shared accessible modal",
122
+ async (Component, props, formId) => {
123
+ const onClose = jest.fn();
124
+ render(<Component {...props} onClose={onClose} />);
125
+ const dialog = screen.getByRole("dialog");
126
+ expect(dialog).toHaveClass("true-modal", "user-modal");
127
+ expect(dialog).toHaveAccessibleName();
128
+ expect(dialog.querySelectorAll(".content")).toHaveLength(1);
129
+ if (formId) {
130
+ expect(dialog.querySelector('button[type="submit"]')).toHaveAttribute(
131
+ "form",
132
+ formId,
133
+ );
134
+ expect(document.getElementById(formId)).toHaveProperty(
135
+ "tagName",
136
+ "FORM",
137
+ );
138
+ }
139
+ const keyboard = userEvent.setup({
140
+ advanceTimers: jest.advanceTimersByTime,
141
+ });
142
+ await keyboard.keyboard("{Escape}");
143
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
144
+ await keyboard.click(
145
+ screen.getByLabelText("Close", { selector: "button" }),
146
+ );
147
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(2));
148
+ },
149
+ );
150
+ });
151
+
152
+ describe("User modal operations", () => {
153
+ const account = {
154
+ id: 1,
155
+ user_name: "alice",
156
+ full_name: "Alice",
157
+ role: "user",
158
+ };
159
+
160
+ it.each([
161
+ [
162
+ NewUser,
163
+ createUser,
164
+ { groups: [] },
165
+ {
166
+ full_name: "Alice",
167
+ user_name: "alice",
168
+ password: "secret123",
169
+ rep_password: "secret123",
170
+ },
171
+ ],
172
+ [
173
+ EditUserModal,
174
+ updateUser,
175
+ { user: account, groups: [] },
176
+ { full_name: "Alice Updated" },
177
+ ],
178
+ [
179
+ UserPasswordModal,
180
+ updatePassword,
181
+ { user: account },
182
+ { password: "secret123", passwordConfirmation: "secret123" },
183
+ ],
184
+ [DeleteUserModal, deleteUser, { id: 1, name: "Alice" }, {}],
185
+ ])(
186
+ "%p waits for its own success, retains errors and blocks duplicate submissions",
187
+ async (Component, routine, props, fields) => {
188
+ const actions = [];
189
+ const store = createStore(
190
+ combineReducers({ userModalOperation }),
191
+ applyMiddleware(() => (next) => (action) => {
192
+ actions.push(action);
193
+ return next(action);
194
+ }),
195
+ );
196
+ const onClose = jest.fn();
197
+ render(
198
+ <Provider store={store}>
199
+ <Component {...props} onClose={onClose} />
200
+ </Provider>,
201
+ );
202
+ Object.entries(fields).forEach(([id, value]) =>
203
+ fireEvent.change(document.getElementById(id), { target: { value } }),
204
+ );
205
+ const keyboard = userEvent.setup({ delay: null });
206
+ const button =
207
+ Component === DeleteUserModal
208
+ ? screen.getByRole("button", { name: "user.delete.submit" })
209
+ : document.querySelector('button[type="submit"]');
210
+ await waitFor(() => expect(button).not.toBeDisabled());
211
+ await keyboard.click(button);
212
+ await waitFor(() =>
213
+ expect(
214
+ actions.filter(({ type }) => type === routine.TRIGGER),
215
+ ).toHaveLength(1),
216
+ );
217
+ const trigger = actions.find(({ type }) => type === routine.TRIGGER);
218
+ await keyboard.click(button);
219
+ await keyboard.keyboard("{Escape}");
220
+ await keyboard.click(
221
+ screen.getByLabelText("Close", { selector: "button" }),
222
+ );
223
+ await keyboard.click(screen.getByRole("dialog").parentElement);
224
+ expect(
225
+ actions.filter(({ type }) => type === routine.TRIGGER),
226
+ ).toHaveLength(1);
227
+ expect(onClose).not.toHaveBeenCalled();
228
+ expect(button).toBeDisabled();
229
+ Object.keys(fields).forEach((id) =>
230
+ expect(document.getElementById(id)).toBeDisabled(),
231
+ );
232
+ act(() => store.dispatch(routine.fulfill()));
233
+ expect(onClose).not.toHaveBeenCalled();
234
+ act(() =>
235
+ store.dispatch({ ...routine.failure("failure"), meta: trigger.meta }),
236
+ );
237
+ expect(
238
+ document.querySelector(".true-modal-banner [role=alert]"),
239
+ ).toBeInTheDocument();
240
+ Object.entries(fields).forEach(([id, value]) =>
241
+ expect(document.getElementById(id)).toHaveValue(value),
242
+ );
243
+ await keyboard.click(button);
244
+ await waitFor(() =>
245
+ expect(
246
+ actions.filter(({ type }) => type === routine.TRIGGER),
247
+ ).toHaveLength(2),
248
+ );
249
+ const retries = actions.filter(({ type }) => type === routine.TRIGGER);
250
+ expect(retries).toHaveLength(2);
251
+ expect(
252
+ document.querySelector(".true-modal-banner [role=alert]"),
253
+ ).not.toBeInTheDocument();
254
+ act(() => store.dispatch({ ...routine.success({}), meta: trigger.meta }));
255
+ expect(onClose).not.toHaveBeenCalled();
256
+ act(() =>
257
+ store.dispatch({ ...routine.success({}), meta: retries[1].meta }),
258
+ );
259
+ expect(onClose).toHaveBeenCalledTimes(1);
260
+ },
261
+ );
262
+ });
263
+
264
+ describe("User form interactions and content", () => {
265
+ const account = {
266
+ id: 1,
267
+ full_name: "Usuario de prueba",
268
+ user_name: "test.user",
269
+ role: "user",
270
+ };
271
+ const options = {
272
+ locale: "es",
273
+ messages: {
274
+ es: {
275
+ ...coreEs,
276
+ ...authEs,
277
+ "user.info.title": "Información del usuario",
278
+ "user.info.unavailable": "No disponible",
279
+ "user.info.no_groups": "Sin grupos asignados",
280
+ "user.info.no_permissions": "Sin roles asignados",
281
+ "user.info.no_group_permissions": "Sin roles heredados de grupos",
282
+ "user.delete.title": "Eliminar usuario",
283
+ "user.delete.description":
284
+ "La cuenta de <strong>{name}</strong> será <danger>eliminada</danger>.",
285
+ "user.delete.confirm": "¿Confirmas la eliminación?",
286
+ },
287
+ },
288
+ };
289
+ const change = (name, value) =>
290
+ fireEvent.change(document.getElementById(name), { target: { value } });
291
+ afterEach(() => {
292
+ delete document.execCommand;
293
+ });
294
+
295
+ it("shows all creation fields and validates the entire form before submitting", async () => {
296
+ const dispatch = jest.fn();
297
+ render(<NewUser groups={[]} onClose={jest.fn()} />, {
298
+ ...options,
299
+ dispatch,
300
+ });
301
+ const form = document.getElementById("new-user-form");
302
+ fireEvent.submit(form);
303
+ await screen.findByText(/Nombre completo.*requerido/i);
304
+ expect(document.getElementById("password")).toBeVisible();
305
+ expect(document.getElementById("full_name")).toHaveFocus();
306
+ await act(async () => {
307
+ change("full_name", account.full_name);
308
+ change("user_name", account.user_name);
309
+ });
310
+ await act(async () => fireEvent.submit(form));
311
+ expect(document.getElementById("password")).toHaveAttribute(
312
+ "aria-invalid",
313
+ "true",
314
+ );
315
+ expect(dispatch).not.toHaveBeenCalledWith(
316
+ expect.objectContaining({ type: createUser.TRIGGER }),
317
+ );
318
+ await act(async () => {
319
+ change("password", "example123");
320
+ change("rep_password", "example123");
321
+ });
322
+ fireEvent.submit(form);
323
+ await waitFor(() =>
324
+ expect(dispatch).toHaveBeenCalledWith(
325
+ expect.objectContaining({
326
+ type: createUser.TRIGGER,
327
+ payload: expect.objectContaining({
328
+ full_name: account.full_name,
329
+ user_name: account.user_name,
330
+ password: "example123",
331
+ rep_password: "example123",
332
+ }),
333
+ }),
334
+ ),
335
+ );
336
+ });
337
+
338
+ it.each(["cancel", "escape", "close", "backdrop"])(
339
+ "clears password fields before %s closes without submitting",
340
+ async (method) => {
341
+ const dispatch = jest.fn();
342
+ const editedFields = [];
343
+ document.execCommand = jest.fn((command) => {
344
+ expect(command).toBe("delete");
345
+ const input = document.activeElement;
346
+ expect(input.type).toBe("password");
347
+ expect(input.selectionStart).toBe(0);
348
+ expect(input.selectionEnd).toBe(input.value.length);
349
+ editedFields.push(input.id);
350
+ return true;
351
+ });
352
+ const onClose = jest.fn(() => {
353
+ expect(document.getElementById("password")).toHaveValue("");
354
+ expect(document.getElementById("passwordConfirmation")).toHaveValue("");
355
+ });
356
+ render(<UserPasswordModal user={account} onClose={onClose} />, {
357
+ ...options,
358
+ dispatch,
359
+ });
360
+ await act(async () => {
361
+ change("password", "example123");
362
+ change("passwordConfirmation", "example123");
363
+ });
364
+ if (method === "cancel")
365
+ fireEvent.click(screen.getByRole("button", { name: "Cancelar" }));
366
+ if (method === "close")
367
+ fireEvent.click(
368
+ screen.getByLabelText("Cerrar", { selector: "button" }),
369
+ );
370
+ if (method === "escape")
371
+ fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
372
+ if (method === "backdrop")
373
+ fireEvent.click(screen.getByRole("dialog").parentElement);
374
+ expect(editedFields).toEqual(["password", "passwordConfirmation"]);
375
+ expect(onClose).not.toHaveBeenCalled();
376
+ fireEvent.click(screen.getByRole("button", { name: "Cancelar" }));
377
+ fireEvent.submit(document.getElementById("password-modal-form"));
378
+ expect(editedFields).toEqual(["password", "passwordConfirmation"]);
379
+ await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
380
+ expect(dispatch).not.toHaveBeenCalledWith(
381
+ expect.objectContaining({ type: updatePassword.TRIGGER }),
382
+ );
383
+ },
384
+ );
385
+
386
+ it("does not navigate after unmounting during password discard", () => {
387
+ const onClose = jest.fn();
388
+ const { unmount } = render(
389
+ <UserPasswordModal user={account} onClose={onClose} />,
390
+ options,
391
+ );
392
+ fireEvent.click(screen.getByRole("button", { name: "Cancelar" }));
393
+ unmount();
394
+ act(() => jest.runOnlyPendingTimers());
395
+ expect(onClose).not.toHaveBeenCalled();
396
+ });
397
+
398
+ it("revalidates confirmation when the original password changes", async () => {
399
+ render(<UserPasswordModal user={account} onClose={jest.fn()} />, options);
400
+ await act(async () => {
401
+ change("password", "example123");
402
+ change("passwordConfirmation", "example123");
403
+ });
404
+ expect(
405
+ screen.getByRole("button", { name: "Cambiar contraseña" }),
406
+ ).toBeEnabled();
407
+ await act(async () => change("password", "different123"));
408
+ await waitFor(() =>
409
+ expect(document.getElementById("passwordConfirmation")).toHaveAttribute(
410
+ "aria-invalid",
411
+ "true",
412
+ ),
413
+ );
414
+ expect(
415
+ screen.getByRole("button", { name: "Cambiar contraseña" }),
416
+ ).toBeDisabled();
417
+ expect(document.getElementById("passwordConfirmation")).toHaveAttribute(
418
+ "aria-describedby",
419
+ "passwordConfirmation-feedback",
420
+ );
421
+ });
422
+
423
+ it("shows labeled user details and explicit empty states", () => {
424
+ render(
425
+ <UserInfoModal
426
+ user={account}
427
+ acls={[]}
428
+ groupAcls={[]}
429
+ onClose={jest.fn()}
430
+ />,
431
+ options,
432
+ );
433
+ expect(screen.getByRole("dialog")).toHaveAccessibleName(
434
+ "Información del usuario",
435
+ );
436
+ expect(screen.getByText("Nombre de usuario")).toBeVisible();
437
+ expect(screen.getByText("ID externo")).toBeVisible();
438
+ expect(screen.getAllByText("No disponible")).toHaveLength(2);
439
+ expect(screen.getByText("Sin grupos asignados")).toBeVisible();
440
+ expect(
441
+ screen.queryByRole("heading", { name: /^Grupos/ }),
442
+ ).not.toBeInTheDocument();
443
+ expect(screen.getByText("Sin roles asignados")).toBeVisible();
444
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
445
+ });
446
+
447
+ it("validates the edit form like the creation form and focuses the first invalid field", async () => {
448
+ const dispatch = jest.fn();
449
+ render(<EditUserModal user={account} groups={[]} onClose={jest.fn()} />, {
450
+ ...options,
451
+ dispatch,
452
+ });
453
+ const form = document.getElementById("edit-user-form");
454
+ expect(form).toHaveAttribute("novalidate");
455
+ await act(async () => change("full_name", ""));
456
+ await act(async () => fireEvent.submit(form));
457
+ await screen.findByText(/Nombre completo.*requerido/i);
458
+ expect(document.getElementById("full_name")).toHaveFocus();
459
+ expect(dispatch).not.toHaveBeenCalledWith(
460
+ expect.objectContaining({ type: updateUser.TRIGGER }),
461
+ );
462
+ });
463
+
464
+ it("shows the same user fields in the creation and edit forms", () => {
465
+ const fieldIds = () =>
466
+ [...document.querySelectorAll("form [id]")]
467
+ .map((element) => element.id)
468
+ .filter((id) => !/password|feedback/.test(id));
469
+ const created = render(
470
+ <NewUser groups={[]} onClose={jest.fn()} />,
471
+ options,
472
+ );
473
+ const creationFields = fieldIds();
474
+ created.unmount();
475
+ render(
476
+ <EditUserModal user={account} groups={[]} onClose={jest.fn()} />,
477
+ options,
478
+ );
479
+ expect(fieldIds()).toEqual(creationFields);
480
+ expect(creationFields).toEqual(
481
+ expect.arrayContaining([
482
+ "full_name",
483
+ "email",
484
+ "role",
485
+ "user_name",
486
+ "external_id",
487
+ "groups",
488
+ ]),
489
+ );
490
+ });
491
+
492
+ it("shows a visible deletion heading and structured destructive copy", () => {
493
+ render(
494
+ <DeleteUserModal id={1} name={account.full_name} onClose={jest.fn()} />,
495
+ options,
496
+ );
497
+ const dialog = screen.getByRole("dialog", { name: "Eliminar usuario" });
498
+ expect(dialog.querySelector(".header")).toBeVisible();
499
+ expect(screen.getByText(account.full_name).tagName).toBe("STRONG");
500
+ expect(screen.getByText("eliminada")).toHaveClass("um-danger-text");
501
+ expect(screen.getByText("¿Confirmas la eliminación?").tagName).toBe("P");
502
+ });
503
+ });
504
+
505
+ describe("Unified user permissions", () => {
506
+ const resource = { id: 1, type: "domain", name: "Finance" };
507
+ const user = { id: 1, full_name: "Alice", role: "user" };
508
+
509
+ it.each([[[]], [[{ resource, role: "Reader" }]]])(
510
+ "hides role grouping for zero or one distinct role across direct and inherited permissions",
511
+ (acls) => {
512
+ render(
513
+ <UserInfoModal
514
+ user={user}
515
+ acls={acls}
516
+ groupAcls={[{ resource, role: "Reader", group: "Team", groupId: 7 }]}
517
+ onClose={jest.fn()}
518
+ />,
519
+ );
520
+ expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
521
+ expect(screen.getAllByRole("table")).toHaveLength(1);
522
+ expect(screen.getByRole("link", { name: "Team" })).toHaveAttribute(
523
+ "href",
524
+ "/groups/7",
525
+ );
526
+ },
527
+ );
528
+
529
+ it("switches between group and role grouping in one collapsible table", async () => {
530
+ render(
531
+ <UserInfoModal
532
+ user={user}
533
+ acls={[{ resource, role: "Editor" }]}
534
+ groupAcls={[{ resource, role: "Reader", group: "Team", groupId: 7 }]}
535
+ onClose={jest.fn()}
536
+ />,
537
+ );
538
+ const keyboard = userEvent.setup({ delay: null });
539
+ const grouping = screen.getByRole("checkbox");
540
+ expect(grouping).not.toBeChecked();
541
+ await keyboard.click(grouping);
542
+ expect(grouping).toBeChecked();
543
+ expect(screen.getAllByRole("table")).toHaveLength(1);
544
+ expect(screen.getByRole("link", { name: "Team" })).toHaveAttribute(
545
+ "href",
546
+ "/groups/7",
547
+ );
548
+ const header = screen.getByRole("button", { expanded: true });
549
+ await keyboard.click(header);
550
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
551
+ await keyboard.click(header);
552
+ expect(screen.getByRole("table")).toBeVisible();
553
+ });
554
+ });
@@ -4,6 +4,7 @@ import { renderWithIntl } from "@truedat/test/renderWithIntl";
4
4
  import { waitForLoad } from "@truedat/test/waitForLoad";
5
5
  import { useAuthorized } from "@truedat/core/hooks/useAuthorized";
6
6
  import { UserRoutes } from "../UserRoutes";
7
+ import { AdminUserRoutes } from "../AdminUserRoutes";
7
8
 
8
9
  const render = (component, { routes = ["/"] } = {}) =>
9
10
  renderWithIntl(
@@ -30,14 +31,11 @@ jest.mock("../../../groups/components/NewGroup", () => () => (
30
31
  jest.mock("../../../groups/components/EditGroup", () => () => (
31
32
  <div>EditGroup</div>
32
33
  ));
33
- jest.mock("../EditUser", () => () => <div>EditUser</div>);
34
34
  jest.mock("../GroupUserCrumbs", () => () => <div>GroupUserCrumbs</div>);
35
35
  jest.mock("../NewUser", () => () => <div>NewUser</div>);
36
- jest.mock("../User", () => () => <div>User</div>);
37
36
  jest.mock("../UserLoader", () => () => <div>UserLoader</div>);
38
37
  jest.mock("../UsersAndGroups", () => () => <div>UsersAndGroups</div>);
39
38
  jest.mock("../UsersLoader", () => () => <div>UsersLoader</div>);
40
- jest.mock("../UserPassword", () => () => <div>UserPassword</div>);
41
39
  jest.mock("@truedat/core/router/Unauthorized", () => () => (
42
40
  <div>Unauthorized</div>
43
41
  ));
@@ -138,3 +136,10 @@ describe("<UserRoutes />", () => {
138
136
  expect(rendered.container).toMatchSnapshot();
139
137
  });
140
138
  });
139
+
140
+ it("renders the user modal host on the administrative creation route", async () => {
141
+ const rendered = render(<AdminUserRoutes />, { routes: ["/users/new"] });
142
+ await waitForLoad(rendered);
143
+ expect(rendered.getByText("UsersAndGroups")).toBeInTheDocument();
144
+ expect(rendered.queryByText("NewUser")).not.toBeInTheDocument();
145
+ });
@@ -0,0 +1,32 @@
1
+ import { useState } from "react";
2
+ import { screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { render } from "@truedat/test/render";
5
+ import UserTypeSelector from "../UserTypeSelector";
6
+
7
+ it("moves selection and focus with arrows, Home and End", async () => {
8
+ const Selector = () => {
9
+ const [value, setValue] = useState("user");
10
+ return <UserTypeSelector value={value} onChange={setValue} />;
11
+ };
12
+ render(<Selector />);
13
+ const keyboard = userEvent.setup({ delay: null });
14
+ await keyboard.tab();
15
+ expect(screen.getByRole("radio", { name: "user.type.user" })).toHaveFocus();
16
+ await keyboard.keyboard("{ArrowRight}");
17
+ expect(
18
+ screen.getByRole("radio", { name: "user.type.service" }),
19
+ ).toHaveFocus();
20
+ expect(
21
+ screen.getByRole("radio", { name: "user.type.service" }),
22
+ ).toHaveAttribute("aria-checked", "true");
23
+ await keyboard.keyboard("{End}{ArrowRight}");
24
+ expect(screen.getByRole("radio", { name: "user.type.admin" })).toHaveFocus();
25
+ await keyboard.keyboard("{ArrowLeft}{Home}");
26
+ expect(screen.getByRole("radio", { name: "user.type.admin" })).toHaveFocus();
27
+ });
28
+
29
+ it("disables all options while saving", () => {
30
+ render(<UserTypeSelector disabled value="user" />);
31
+ screen.getAllByRole("radio").forEach((radio) => expect(radio).toBeDisabled());
32
+ });
@@ -22,6 +22,13 @@ jest.mock("../../../groups/components/GroupCards", () => ({
22
22
  return <div>GroupCards</div>;
23
23
  },
24
24
  }));
25
+ jest.mock(
26
+ "../NewUser",
27
+ () =>
28
+ function NewUser() {
29
+ return <div>NewUserModal</div>;
30
+ },
31
+ );
25
32
 
26
33
  describe("<UsersAndGroups />", () => {
27
34
  it("renders correctly with default route", async () => {
@@ -45,4 +52,14 @@ describe("<UsersAndGroups />", () => {
45
52
  await waitForLoad(rendered);
46
53
  expect(rendered.container).toMatchSnapshot();
47
54
  });
55
+
56
+ it("renders UserCards behind the new user modal", async () => {
57
+ const rendered = render(<UsersAndGroups />, {
58
+ routes: [`${USERS}/new`],
59
+ });
60
+ await waitForLoad(rendered);
61
+
62
+ expect(rendered.getByText("UserCards")).toBeInTheDocument();
63
+ expect(rendered.getByText("NewUserModal")).toBeInTheDocument();
64
+ });
48
65
  });