@truedat/auth 8.14.4 → 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 (28) 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/styles/Groups.less +96 -131
  14. package/src/styles/UserCard.less +60 -226
  15. package/src/styles/UserModals.less +6 -1
  16. package/src/styles/Users.less +40 -7
  17. package/src/users/components/EditUserModal.js +3 -3
  18. package/src/users/components/UserAcls.js +61 -0
  19. package/src/users/components/UserCard.js +4 -1
  20. package/src/users/components/UserCards.js +35 -120
  21. package/src/users/components/UserInfoModal.js +22 -62
  22. package/src/users/components/__tests__/EditUser.spec.js +1 -1
  23. package/src/users/components/__tests__/UserAcls.spec.js +40 -1
  24. package/src/users/components/__tests__/UserCard.spec.js +1 -1
  25. package/src/users/components/__tests__/UserCards.spec.js +0 -45
  26. package/src/users/components/__tests__/UserModals.spec.js +54 -7
  27. package/src/users/components/__tests__/__snapshots__/UserCard.spec.js.snap +2 -0
  28. package/src/users/components/__tests__/__snapshots__/UserCards.spec.js.snap +20 -69
@@ -49,6 +49,7 @@ const GroupsBadge = ({ groups, id }) => {
49
49
  return (
50
50
  <Popup
51
51
  className="uc-groups-popover"
52
+ transition={{ duration: 0 }}
52
53
  content={
53
54
  <div className="uc-groups-popup">
54
55
  <div className="uc-groups-popup__header">
@@ -135,7 +136,7 @@ export const UserCard = ({
135
136
  className="uc-external-id"
136
137
  title={`${formatMessage({
137
138
  id: "user.form.external_id",
138
- defaultMessage: "ID externo",
139
+ defaultMessage: "External ID",
139
140
  })}: ${externalId}`}
140
141
  >
141
142
  {externalId}
@@ -150,6 +151,7 @@ export const UserCard = ({
150
151
  className="uc-action-btn"
151
152
  to={linkTo.USER_EDIT({ id })}
152
153
  aria-label={editUserLabel}
154
+ title={editUserLabel}
153
155
  onClick={(e) => e.stopPropagation()}
154
156
  >
155
157
  <Icon name="pencil alternate" />
@@ -159,6 +161,7 @@ export const UserCard = ({
159
161
  className="uc-action-btn uc-action-btn--key"
160
162
  to={linkTo.USER_EDIT_PASSWORD({ id })}
161
163
  aria-label={editPasswordLabel}
164
+ title={editPasswordLabel}
162
165
  onClick={(e) => e.stopPropagation()}
163
166
  >
164
167
  <Icon name="key" />
@@ -14,14 +14,10 @@ import {
14
14
  Message,
15
15
  } from "semantic-ui-react";
16
16
  import { FormattedMessage, useIntl } from "react-intl";
17
- import SearchContext from "@truedat/core/search/SearchContext";
18
- import SearchFilters from "@truedat/core/search/SearchFilters";
19
- import FilterDropdown from "@truedat/core/components/FilterDropdown";
20
17
  import { USERS_NEW } from "@truedat/core/routes";
21
18
  import { filterUsers } from "../selectors/filterUsers";
22
19
  import UserCard from "./UserCard";
23
20
  import UsersTable from "./UsersTable";
24
- import { userTypeOptions } from "./UserTypeSelector";
25
21
 
26
22
  const ROLE_ORDER = ["admin", "user", "agent", "service"];
27
23
  const rolePosition = (role = "") => {
@@ -41,10 +37,6 @@ const roleSort = ([roleA], [roleB]) => {
41
37
 
42
38
  export const UserCards = ({ users: allUsers }) => {
43
39
  const [filter, setFilter] = useState("");
44
- const [selectedFilters, setSelectedFilters] = useState([]);
45
- const [openFilter, setOpenFilter] = useState(null);
46
- const [types, setTypes] = useState([]);
47
- const [groups, setGroups] = useState([]);
48
40
  const [sort, setSort] = useState({ column: "name", ascending: true });
49
41
  const [view, setView] = useState(() => {
50
42
  try {
@@ -68,102 +60,65 @@ export const UserCards = ({ users: allUsers }) => {
68
60
  const toggleRole = (role) => {
69
61
  setOpenRoles((prev) => ({ ...prev, [role]: !(prev[role] ?? true) }));
70
62
  };
71
- const groupOptions = _.uniqBy("value")(
72
- (allUsers || []).flatMap((user) =>
73
- (user.groups || []).map((group) => ({
74
- key: typeof group === "string" ? group : group.name,
75
- value: typeof group === "string" ? group : group.name,
76
- text: typeof group === "string" ? group : group.alias || group.name,
77
- })),
78
- ),
79
- ).sort((a, b) => a.text.localeCompare(b.text));
80
- const filterOptions = {
81
- "users.type": userTypeOptions.map(({ value, messageId }) => ({
82
- value,
83
- text: formatMessage({ id: messageId }),
84
- })),
85
- "users.groups": groupOptions,
86
- };
87
- const resetFilters = () => {
88
- setFilter("");
89
- setTypes([]);
90
- setGroups([]);
91
- setSelectedFilters([]);
92
- setOpenFilter(null);
93
- };
94
- const removeFilter = ({ filter: name }) => {
95
- setSelectedFilters((values) => values.filter((value) => value !== name));
96
- (name === "users.type" ? setTypes : setGroups)([]);
97
- setOpenFilter(null);
98
- };
63
+ const resetFilters = () => setFilter("");
99
64
  const users = _.flow(
100
65
  filterUsers(filter),
101
- _.filter(
102
- (user) =>
103
- (!types.length || types.includes(user.role)) &&
104
- (!groups.length ||
105
- (user.groups || []).some((group) =>
106
- groups.includes(typeof group === "string" ? group : group.name),
107
- )),
108
- ),
109
66
  _.sortBy(userSortKey),
110
67
  )(allUsers || []);
111
- const hasFilters = Boolean(filter.trim() || types.length || groups.length);
68
+ const hasFilters = Boolean(filter.trim());
112
69
  const usersByRole = _.flow(
113
70
  _.groupBy("role"),
114
71
  _.toPairs,
115
72
  )(users).sort(roleSort);
116
73
 
74
+ const results = (
75
+ <div className="users-results" role="status">
76
+ <Label>
77
+ {hasFilters && (
78
+ <>
79
+ <FormattedMessage
80
+ id="users.results.filtered"
81
+ values={{ count: users.length }}
82
+ />{" "}
83
+ </>
84
+ )}
85
+ <FormattedMessage
86
+ id="users.results"
87
+ values={{ count: (allUsers || []).length }}
88
+ />
89
+ </Label>
90
+ </div>
91
+ );
92
+
117
93
  return (
118
94
  <>
119
95
  <div className="users-toolbar">
120
96
  <Input
121
97
  iconPosition="left"
122
- action
123
98
  value={filter}
124
99
  onChange={(_event, { value }) => setFilter(value)}
125
100
  placeholder={formatMessage({ id: "user.search.placeholder" })}
126
101
  >
127
102
  <Icon name="search" aria-hidden="true" />
128
103
  <input />
129
- <SearchContext
130
- value={{
131
- availableGroupedFilters: [
132
- [
133
- "",
134
- Object.keys(filterOptions).filter(
135
- (name) => !selectedFilters.includes(name),
136
- ),
137
- ],
138
- ],
139
- addFilter: ({ filter: name }) => {
140
- setSelectedFilters((values) => [...values, name]);
141
- setOpenFilter(name);
142
- },
143
- resetFilters,
144
- loadingFilters: false,
145
- }}
146
- >
147
- <SearchFilters />
148
- </SearchContext>
149
104
  </Input>
150
105
  <div className="users-toolbar-actions">
151
106
  <div
152
- className="users-view-buttons"
107
+ className="ui buttons users-view-buttons"
153
108
  role="group"
154
109
  aria-label={formatMessage({ id: "users.view.label" })}
155
110
  >
156
111
  {["cards", "table"].map((mode) => (
157
- <Button
112
+ <button
158
113
  key={mode}
159
114
  type="button"
160
- toggle
161
- active={view === mode}
115
+ className={`ui ${view === mode ? "active primary " : ""}button`}
162
116
  aria-pressed={view === mode}
163
117
  onClick={() => changeView(mode)}
164
- icon={mode === "cards" ? "grid layout" : "list"}
165
- content={formatMessage({ id: `users.view.${mode}` })}
166
- />
118
+ >
119
+ <Icon name={mode === "cards" ? "grid layout" : "list"} />
120
+ {formatMessage({ id: `users.view.${mode}` })}
121
+ </button>
167
122
  ))}
168
123
  </div>
169
124
  <Button
@@ -175,34 +130,8 @@ export const UserCards = ({ users: allUsers }) => {
175
130
  />
176
131
  </div>
177
132
  </div>
178
- <div className="selectedFilters users-filters">
179
- {selectedFilters.length > 0 && (
180
- <div className="appliedFilters">
181
- <FormattedMessage id="search.applied_filters" />
182
- </div>
183
- )}
184
- {selectedFilters.map((name) => (
185
- <FilterDropdown
186
- key={name}
187
- filter={name}
188
- loading={false}
189
- options={openFilter === name ? filterOptions[name] : null}
190
- activeValues={name === "users.type" ? types : groups}
191
- openFilter={() => setOpenFilter(name)}
192
- closeFilter={() =>
193
- setOpenFilter((current) => (current === name ? null : current))
194
- }
195
- removeFilter={removeFilter}
196
- toggleFilterValue={({ value }) =>
197
- (name === "users.type" ? setTypes : setGroups)((values) =>
198
- values.includes(value)
199
- ? values.filter((item) => item !== value)
200
- : [...values, value],
201
- )
202
- }
203
- />
204
- ))}
205
- {(hasFilters || selectedFilters.length > 0) && (
133
+ {hasFilters && (
134
+ <div className="selectedFilters users-filters">
206
135
  <a
207
136
  className="resetFilters"
208
137
  role="button"
@@ -217,29 +146,14 @@ export const UserCards = ({ users: allUsers }) => {
217
146
  >
218
147
  <FormattedMessage id="users.filter.clear" />
219
148
  </a>
220
- )}
221
- </div>
222
- <div className="users-results" role="status">
223
- <Label>
224
- {hasFilters && (
225
- <>
226
- <FormattedMessage
227
- id="users.results.filtered"
228
- values={{ count: users.length }}
229
- />{" "}
230
- </>
231
- )}
232
- <FormattedMessage
233
- id="users.results"
234
- values={{ count: (allUsers || []).length }}
235
- />
236
- </Label>
237
- </div>
149
+ </div>
150
+ )}
151
+ {(view === "table" || usersByRole.length === 0) && results}
238
152
  {view === "table" ? (
239
153
  <UsersTable users={users} sort={sort} onSort={setSort} />
240
154
  ) : (
241
155
  <Accordion fluid styled className="users-role-accordion">
242
- {usersByRole.map(([role, users]) => {
156
+ {usersByRole.map(([role, users], index) => {
243
157
  const active = openRoles[role] ?? true;
244
158
  const panelId = `users-role-panel-${role}`;
245
159
 
@@ -272,6 +186,7 @@ export const UserCards = ({ users: allUsers }) => {
272
186
  </Label>
273
187
  </span>
274
188
  </button>
189
+ {index === 0 && results}
275
190
  </Accordion.Title>
276
191
  <Accordion.Content
277
192
  id={panelId}
@@ -8,8 +8,7 @@ import TrueModal from "@truedat/core/components/TrueModal";
8
8
  import { linkTo } from "@truedat/core/routes";
9
9
  import { useGroups } from "../../groups/hooks/useGroups";
10
10
  import { getUserAcls, getUserGroupAcls } from "../selectors";
11
- import { UserAcls } from "./UserAcls";
12
- import { UserGroupAcls } from "./UserGroupAcls";
11
+ import { UnifiedAcls } from "./UserAcls";
13
12
  import UserGroupLink from "./UserGroupLink";
14
13
  import { userTypeOptions } from "./UserTypeSelector";
15
14
 
@@ -28,7 +27,6 @@ export const UserInfoModal = ({
28
27
  const { formatMessage } = useIntl();
29
28
  const [groupByRole, setGroupByRole] = useState(false);
30
29
  const [aclsOpen, setAclsOpen] = useState(true);
31
- const [groupAclsOpen, setGroupAclsOpen] = useState(true);
32
30
 
33
31
  if (!user?.id) return null;
34
32
 
@@ -39,10 +37,10 @@ export const UserInfoModal = ({
39
37
  (nameParts.length > 1 ? nameParts[nameParts.length - 1][0] : "");
40
38
  const userType = userTypeOptions.find(({ value }) => value === user.role);
41
39
  const unavailable = formatMessage({ id: "user.info.unavailable" });
42
- const hasAcls = acls?.length > 0;
43
- const hasGroupAcls = groupAcls?.length > 0;
40
+ const permissions = [...(acls || []), ...(groupAcls || [])];
41
+ const roleCount = new Set(permissions.map(({ role }) => role)).size;
42
+ const hasAcls = permissions.length > 0;
44
43
  const aclsPanelId = `user-acls-panel-${user.id}`;
45
- const groupAclsPanelId = `user-group-acls-panel-${user.id}`;
46
44
 
47
45
  return (
48
46
  <TrueModal
@@ -61,7 +59,7 @@ export const UserInfoModal = ({
61
59
  },
62
60
  {
63
61
  key: "edit",
64
- content: formatMessage({ id: "users.actions.edit" }),
62
+ content: formatMessage({ id: "actions.edit" }),
65
63
  primary: true,
66
64
  as: Link,
67
65
  to: linkTo.USER_EDIT({ id: user.id }),
@@ -163,21 +161,22 @@ export const UserInfoModal = ({
163
161
  <Icon name="shield alternate" />
164
162
  </span>
165
163
  <span className="um-acl-title">
166
- <FormattedMessage id="user.info.direct_permissions" />
167
- <span className="um-acl-count">
168
- ({new Set(acls.map(({ role }) => role)).size})
169
- </span>
164
+ <FormattedMessage id="user.acl" />
165
+ <span className="um-acl-count">({roleCount})</span>
170
166
  </span>
171
167
  <Icon name={aclsOpen ? "chevron up" : "chevron down"} />
172
168
  </button>
173
- <div className="um-acl-grouping">
174
- <Checkbox
175
- toggle
176
- label={formatMessage({ id: "user.info.group_by_role" })}
177
- checked={groupByRole}
178
- onChange={(_event, { checked }) => setGroupByRole(checked)}
179
- />
180
- </div>
169
+ {roleCount > 1 && (
170
+ <div className="um-acl-grouping">
171
+ <Checkbox
172
+ id={`user-group-by-role-${user.id}`}
173
+ toggle
174
+ label={formatMessage({ id: "user.info.group_by_role" })}
175
+ checked={groupByRole}
176
+ onChange={(_event, { checked }) => setGroupByRole(checked)}
177
+ />
178
+ </div>
179
+ )}
181
180
  </div>
182
181
  {aclsOpen && (
183
182
  <div
@@ -185,13 +184,13 @@ export const UserInfoModal = ({
185
184
  className="um-acl-panel"
186
185
  role="region"
187
186
  aria-label={formatMessage({
188
- id: "user.info.direct_permissions",
187
+ id: "user.acl",
189
188
  })}
190
189
  tabIndex={0}
191
190
  >
192
- <UserAcls
193
- acls={acls}
194
- groupBy={groupByRole ? "role" : "domain"}
191
+ <UnifiedAcls
192
+ acls={permissions}
193
+ groupBy={groupByRole && roleCount > 1 ? "role" : "group"}
195
194
  />
196
195
  </div>
197
196
  )}
@@ -202,45 +201,6 @@ export const UserInfoModal = ({
202
201
  <FormattedMessage id="user.info.no_permissions" />
203
202
  </p>
204
203
  )}
205
-
206
- {hasGroupAcls ? (
207
- <div className="um-acl-section um-acl-section--groups">
208
- <button
209
- type="button"
210
- className="um-acl-header"
211
- aria-expanded={groupAclsOpen}
212
- aria-controls={groupAclsPanelId}
213
- onClick={() => setGroupAclsOpen((open) => !open)}
214
- >
215
- <span className="um-acl-icon--groups">
216
- <Icon name="users" />
217
- </span>
218
- <span className="um-acl-title">
219
- <FormattedMessage id="user.info.group_permissions" />
220
- <span className="um-acl-count">({groupAcls.length})</span>
221
- </span>
222
- <Icon name={groupAclsOpen ? "chevron up" : "chevron down"} />
223
- </button>
224
- {groupAclsOpen && (
225
- <div
226
- id={groupAclsPanelId}
227
- className="um-acl-panel"
228
- role="region"
229
- aria-label={formatMessage({
230
- id: "user.info.group_permissions",
231
- })}
232
- tabIndex={0}
233
- >
234
- <UserGroupAcls acls={groupAcls} />
235
- </div>
236
- )}
237
- </div>
238
- ) : (
239
- <p className="um-empty">
240
- <Icon name="users" aria-hidden="true" />
241
- <FormattedMessage id="user.info.no_group_permissions" />
242
- </p>
243
- )}
244
204
  </div>
245
205
  </TrueModal>
246
206
  );
@@ -37,7 +37,7 @@ describe("<EditUser />", () => {
37
37
  const rendered = render(routes, { ...renderOptions, dispatch });
38
38
  expect(rendered.getByRole("dialog")).toHaveClass("user-modal--edit");
39
39
  expect(document.getElementById("full_name")).toHaveValue(account.full_name);
40
- fireEvent.click(rendered.getByRole("button", { name: "actions.cancel" }));
40
+ fireEvent.click(rendered.getByRole("button", { name: "actions.close" }));
41
41
  expect(await rendered.findByText("User list")).toBeVisible();
42
42
  expect(dispatch).not.toHaveBeenCalledWith(
43
43
  expect.objectContaining({ type: updateUser.TRIGGER }),
@@ -1,6 +1,6 @@
1
1
  import { screen, waitFor, within } from "@testing-library/react";
2
2
  import { render } from "@truedat/test/render";
3
- import UserAcls, { UserAcls as UserAclsTable } from "../UserAcls";
3
+ import UserAcls, { UserAcls as UserAclsTable, UnifiedAcls } from "../UserAcls";
4
4
 
5
5
  const renderOpts = {
6
6
  messages: {
@@ -53,3 +53,42 @@ it("groups roles by domain identity and deduplicates repeated assignments", () =
53
53
  within(screen.getAllByRole("row")[2]).getAllByRole("listitem"),
54
54
  ).toHaveLength(2);
55
55
  });
56
+
57
+ it.each(["group", "role"])(
58
+ "keeps each permission's origin when grouping unified permissions by %s",
59
+ (groupBy) => {
60
+ const resource = { id: 1, name: "Finance", type: "domain" };
61
+ const acls = [
62
+ { resource, role: "Reader" },
63
+ { resource, role: "Reader", group: "Team", groupId: 7 },
64
+ { resource, role: "Reader", group: "Team", groupId: 8 },
65
+ { resource, role: "Editor", group: "Team", groupId: 7 },
66
+ ];
67
+ render(<UnifiedAcls acls={acls} groupBy={groupBy} />);
68
+ const rows = screen.getAllByRole("row").slice(1);
69
+ expect(rows).toHaveLength(4);
70
+ expect(
71
+ screen
72
+ .getAllByRole("link", { name: "Team" })
73
+ .map((link) => link.getAttribute("href")),
74
+ ).toEqual(
75
+ groupBy === "group"
76
+ ? ["/groups/7", "/groups/8"]
77
+ : ["/groups/7", "/groups/7", "/groups/8"],
78
+ );
79
+ const directRow = rows.find((row) =>
80
+ within(row).queryByRole("cell", { name: "" }),
81
+ );
82
+ expect(directRow).toBeDefined();
83
+ if (groupBy === "role") {
84
+ expect(screen.getByRole("cell", { name: "Reader" })).toHaveAttribute(
85
+ "rowspan",
86
+ "3",
87
+ );
88
+ } else {
89
+ expect(
90
+ screen.getAllByRole("link", { name: "Team" })[0].closest("td"),
91
+ ).toHaveAttribute("rowspan", "2");
92
+ }
93
+ },
94
+ );
@@ -29,7 +29,7 @@ describe("<UserCard />", () => {
29
29
  );
30
30
  expect(rendered.getByText("employee-123")).toHaveAttribute(
31
31
  "title",
32
- "ID externo: employee-123",
32
+ "External ID: employee-123",
33
33
  );
34
34
  expect(rendered.getByText(user.user_name)).toBeInTheDocument();
35
35
  expect(rendered.getByText(user.email)).toBeInTheDocument();
@@ -159,51 +159,6 @@ it("works when storage is unavailable and reuses the delete confirmation", async
159
159
  set.mockRestore();
160
160
  });
161
161
 
162
- it("combines type and group filters and keeps all group options available", async () => {
163
- localStorage.clear();
164
- const user = userEvent.setup({ delay: null });
165
- render(
166
- <UserCards
167
- users={[
168
- { id: 1, user_name: "Alpha", role: "user", groups: ["Data"], acls: [] },
169
- {
170
- id: 2,
171
- user_name: "Beta",
172
- role: "admin",
173
- groups: [{ name: "quality", alias: "Quality" }],
174
- },
175
- {
176
- id: 3,
177
- user_name: "Gamma",
178
- role: "user",
179
- groups: ["Data"],
180
- acls: [{ role: {} }],
181
- },
182
- ]}
183
- />,
184
- );
185
- await user.click(screen.getByRole("button", { name: "users.view.table" }));
186
- await user.click(screen.getByText("Filters", { exact: true }));
187
- await user.click(screen.getByRole("option", { name: "type", exact: true }));
188
- await user.click(screen.getByRole("option", { name: "user.type.user" }));
189
- await user.keyboard("{Escape}");
190
- await user.click(screen.getByText("Filters", { exact: true }));
191
- await user.click(screen.getByRole("option", { name: "groups", exact: true }));
192
- expect(screen.getByRole("option", { name: "Quality" })).toBeInTheDocument();
193
- await user.click(screen.getByRole("option", { name: "Data" }));
194
- await user.keyboard("{Escape}");
195
- expect(screen.getAllByRole("row")).toHaveLength(3);
196
- expect(screen.getAllByText("users.table.unknown")).toHaveLength(1);
197
- await user.type(
198
- screen.getByPlaceholderText(/user.search.placeholder/),
199
- "alpha",
200
- );
201
- expect(screen.getAllByRole("row")).toHaveLength(2);
202
- await user.click(screen.getByRole("button", { name: "users.filter.clear" }));
203
- expect(screen.getAllByRole("row")).toHaveLength(4);
204
- localStorage.clear();
205
- });
206
-
207
162
  describe("<UserCards /> role sections", () => {
208
163
  beforeEach(() => localStorage.clear());
209
164
  const users = [
@@ -31,9 +31,7 @@ describe("User modal shell", () => {
31
31
  fireEvent.change(document.getElementById("full_name"), {
32
32
  target: { value: "Alice Updated" },
33
33
  });
34
- await keyboard.click(
35
- screen.getByRole("button", { name: "Eliminar usuario" }),
36
- );
34
+ await keyboard.click(screen.getByRole("button", { name: "Delete user" }));
37
35
  expect(screen.getAllByRole("dialog")).toHaveLength(1);
38
36
  expect(
39
37
  screen.getByRole("button", { name: "user.delete.submit" }),
@@ -43,9 +41,7 @@ describe("User modal shell", () => {
43
41
  screen.getByRole("button", { name: "actions.cancel" }),
44
42
  );
45
43
  expect(document.getElementById("full_name")).toHaveValue("Alice Updated");
46
- await keyboard.click(
47
- screen.getByRole("button", { name: "Eliminar usuario" }),
48
- );
44
+ await keyboard.click(screen.getByRole("button", { name: "Delete user" }));
49
45
  await keyboard.click(
50
46
  screen.getByRole("button", { name: "user.delete.submit" }),
51
47
  );
@@ -445,7 +441,7 @@ describe("User form interactions and content", () => {
445
441
  screen.queryByRole("heading", { name: /^Grupos/ }),
446
442
  ).not.toBeInTheDocument();
447
443
  expect(screen.getByText("Sin roles asignados")).toBeVisible();
448
- expect(screen.getByText("Sin roles heredados de grupos")).toBeVisible();
444
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
449
445
  });
450
446
 
451
447
  it("validates the edit form like the creation form and focuses the first invalid field", async () => {
@@ -505,3 +501,54 @@ describe("User form interactions and content", () => {
505
501
  expect(screen.getByText("¿Confirmas la eliminación?").tagName).toBe("P");
506
502
  });
507
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
+ });
@@ -57,6 +57,7 @@ exports[`<UserCard /> matches the latest snapshot 1`] = `
57
57
  class="uc-action-btn"
58
58
  data-discover="true"
59
59
  href="/users/1/edit"
60
+ title="users.actions.edit"
60
61
  >
61
62
  <i
62
63
  aria-hidden="true"
@@ -73,6 +74,7 @@ exports[`<UserCard /> matches the latest snapshot 1`] = `
73
74
  class="uc-action-btn uc-action-btn--key"
74
75
  data-discover="true"
75
76
  href="/users/1/password"
77
+ title="user.actions.edit_password"
76
78
  >
77
79
  <i
78
80
  aria-hidden="true"