feat(users): Profile tab, dialog shell & list CRUD wiring (#36717) - #36990
feat(users): Profile tab, dialog shell & list CRUD wiring (#36717)#36990AP2300 wants to merge 6 commits into
Conversation
Ships the Users portlet Create/Edit dialog shell with the Profile tab fully wired and placeholders for the three sibling tabs (delivered by #36718, #36719, #36720). Dialog / Profile tab: - 4-tab strip with Profile as the only functional tab; Roles, Permissions, and API Tokens render "Coming soon" placeholders - Header with avatar + name + Active status chip - Account section: first/last name, email, password + confirm, Active - Additional Info section: prefix/suffix/title/company/website - Access section: disabled (values informational only), shows admin / backend / frontend / hasConsoleAccess pulled from the loaded user - Delete User section (edit mode) with required replacement-user picker and email-typed confirmation List CRUD: - DotUsersService gains getUser/createUser/updateUser using POST/PUT/GET /api/v1/users; roles field intentionally omitted on update so backend preserves role membership (see UserResource#processRoles) - DotUsersListStore gains createUser / updateUser / deleteSingleUser - Bulk delete on the list toolbar now shows the same replacement picker instead of the old p-confirmDialog - /users route now resolves to the new users-beta portlet id Shared: - DotUsersReplacementPickerComponent — server-backed autocomplete used by both delete flows; excludes the users being deleted from suggestions client-side Test coverage: 56 tests in the portlet + 46 in data-access all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ints
Extends the Profile tab so the Access toggles and Show Getting Started
now round-trip against the backend, and polishes a couple of visual
loose ends caught during review.
Access section:
- CMS Administrator / Back-end User / Front-end User toggles hydrate
from `GET /api/v1/roles/users/{userId}` and persist through the
`roles` field on `PUT /api/v1/users`. The load response is cached
as the full role-key list so save can strip only the three
access-role keys and preserve every other membership (personal
role, project roles, etc.).
- Save button stays disabled until roles + gettingStarted have
finished loading, so an accidental click cannot ship an incomplete
role list.
- Can Login to Admin UI stays disabled but its value is now derived
live from `cmsAdmin || backend` — reactively updated via
valueChanges. Matches the legacy behavior of read-only display
driven by role membership.
Show Getting Started:
- New GET /api/v1/toolgroups/gettingstarted/_userHasLayout on load,
and PUT _addtouser / _removefromuser on save when the toggle
diverged from the initial state. Chained off the primary save via
switchMap in the store — a toolgroup failure surfaces via the
shared error manager but does not fail the user save.
Password field:
- Adds a masked `••••••••` placeholder on the password / confirm
fields in edit mode so the empty state visually communicates
"a password is set". Leave-blank-to-keep-current behavior is
unchanged.
Password field UX cleanup:
- Header subtitle in create mode ("Fill in the details below to
create this user") removed along with its i18n key; the avatar
+ name are enough context in the header.
List polish:
- Empty Roles and Last-login cells now render blank instead of the
`—` placeholder.
Test coverage: 61 portlet tests + 46 data-access tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses M. Rojas's review thread (ts 1786391221.355219) on the Profile tab and delete flows. Delete User section (Profile tab): - Drops the red-tinted border + background; the section now uses the same neutral treatment as the other Account / Access cards. The Delete button itself keeps `severity="danger"` since that's the actual destructive action. Delete confirm dialogs (both single-user + bulk): - Replacement picker now fills the field width via `styleClass="w-full"` + `inputStyleClass="w-full"` on the shared component. - Removes the disabled-Delete pattern per the design convention of keeping buttons enabled. Clicking with an invalid form now surfaces field-level errors — red outline via PrimeNG's `[invalid]` on the picker + `ng-invalid/ng-dirty` classes on the type-to-confirm input, plus inline error text below each field. - Granular error keys (`replacement.required`, `replacement.self`, `confirm.required`, `confirm.mismatch`) replace the earlier footer summary — users see exactly which field needs attention. - Picker gains a passthrough `[invalid]` input so callers can toggle the underlying p-autoComplete error state without reaching into the shared component internals. Profile save form: - Required-field error text under firstName, lastName, email, password, and confirmPassword — chained via `@if / @else if` so each field surfaces the most relevant error (required → format → cross-field mismatch). - Explicit `[invalid]` (p-password) and `[class.ng-invalid]` / `[class.ng-dirty]` (pInputText) bindings driven by helper predicates on the profile tab. `markAllAsTouched()` in save() sets touched without dirty, so PrimeNG's default `.ng-invalid.ng-dirty` styling wasn't picking up the touched-only state. Users list empty state: - New SCSS override forces the p-datatable scroll body, table, and tbody to `height: 100%` so the empty message row can actually stretch to the tbody's computed height. Previously the tr/td auto- sized to content, leaving the icon+title+description pinned to the top instead of centered. Test coverage: 61 tests + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read the design project directly rather than working from the issue summary. The list row was missing two controls it specifies. - New Experiment button in the toolbar. Creation lands with its own screen (#36990+), so it renders disabled with a tooltip rather than being absent. - Primary row action leading the Actions cell: "View Results" where results exist, "Configure" otherwise. Derived from `AllowedActionsByExperimentStatus.results` (RUNNING, ENDED) rather than restating the statuses. Disabled for now: AC10 forbids routing into the legacy UVE screens until Screens 2 and 3 land, so the row matches the design without being able to navigate. - Variants centred and Actions right-aligned, per the design. Column order already matched: the design has no Modified column, and the issue asked for "the design's columns + Modified", which is what ships. Also drops the `dot-experiment-status-tag` wrapper: a component whose only job was to render one `p-tag` earned nothing over inlining the tag and deriving severity, icon and label on the row. Its severity coverage moved to the list spec, so the mapping is still asserted for all five statuses. Reuses `experiments.action.view.results`; adds keys only where the design's copy genuinely differs from an existing string.
The design leads the cell with a "View Results" / "Configure" button, but both screens land with #36990+. Rendering it disabled matched the design on paper and read as noise in practice — a prominent control that cannot go anywhere. The cell now exposes only actions the row can perform: archive/restore where the transitions exist, and the kebab. This is also AC10's original reading. Drops the `experiments.list.action.configure` key, now unreferenced.
Read the design project directly rather than working from the issue summary. The list row was missing two controls it specifies. - New Experiment button in the toolbar. Creation lands with its own screen (#36990+), so it renders disabled with a tooltip rather than being absent. - Primary row action leading the Actions cell: "View Results" where results exist, "Configure" otherwise. Derived from `AllowedActionsByExperimentStatus.results` (RUNNING, ENDED) rather than restating the statuses. Disabled for now: AC10 forbids routing into the legacy UVE screens until Screens 2 and 3 land, so the row matches the design without being able to navigate. - Variants centred and Actions right-aligned, per the design. Column order already matched: the design has no Modified column, and the issue asked for "the design's columns + Modified", which is what ships. Also drops the `dot-experiment-status-tag` wrapper: a component whose only job was to render one `p-tag` earned nothing over inlining the tag and deriving severity, icon and label on the row. Its severity coverage moved to the list spec, so the mapping is still asserted for all five statuses. Reuses `experiments.action.view.results`; adds keys only where the design's copy genuinely differs from an existing string.
The design leads the cell with a "View Results" / "Configure" button, but both screens land with #36990+. Rendering it disabled matched the design on paper and read as noise in practice — a prominent control that cannot go anywhere. The cell now exposes only actions the row can perform: archive/restore where the transitions exist, and the kebab. This is also AC10's original reading. Drops the `experiments.list.action.configure` key, now unreferenced.
…lish - List Roles column now shows first two role names comma-separated followed by "and N more" (the store fans out getUserRoles per row and filters out the personal role). - Removed the Can Login toggle from the Access section; the header now derives a read-only "Can login to Admin UI" chip from the CMS Admin or Back-end toggles, positioned under the user name. - Access column chips wrap cleanly and stay unbroken at the hyphen. - Scoped the empty-state height:100% chain so data rows keep their natural height when few results are present. - Personal role (roleKey === userId) is filtered from the save payload to sidestep the backend "Cannot alter users on this role" guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the user header (avatar, display name, Active + Can Login chips) out of the Profile tab pane so it sits above the tab bar and stays visible on Roles, Permissions, and API Tokens too. Matches the new design where identity is dialog-global, not tab-local. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two files were tripping `nx format:check` in CI even though the source was semantically fine — the local commits landed via lint-staged in a version that formatted line-wraps slightly differently. Applying `nx format:write` collapses the wraps so `format-test` passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read the design project directly rather than working from the issue summary. The list row was missing two controls it specifies. - New Experiment button in the toolbar. Creation lands with its own screen (#36990+), so it renders disabled with a tooltip rather than being absent. - Primary row action leading the Actions cell: "View Results" where results exist, "Configure" otherwise. Derived from `AllowedActionsByExperimentStatus.results` (RUNNING, ENDED) rather than restating the statuses. Disabled for now: AC10 forbids routing into the legacy UVE screens until Screens 2 and 3 land, so the row matches the design without being able to navigate. - Variants centred and Actions right-aligned, per the design. Column order already matched: the design has no Modified column, and the issue asked for "the design's columns + Modified", which is what ships. Also drops the `dot-experiment-status-tag` wrapper: a component whose only job was to render one `p-tag` earned nothing over inlining the tag and deriving severity, icon and label on the row. Its severity coverage moved to the list spec, so the mapping is still asserted for all five statuses. Reuses `experiments.action.view.results`; adds keys only where the design's copy genuinely differs from an existing string.
The design leads the cell with a "View Results" / "Configure" button, but both screens land with #36990+. Rendering it disabled matched the design on paper and read as noise in practice — a prominent control that cannot go anywhere. The cell now exposes only actions the row can perform: archive/restore where the transitions exist, and the kebab. This is also AC10's original reading. Drops the `experiments.list.action.configure` key, now unreferenced.
| payload.additionalInfo = additionalInfo; | ||
| } | ||
|
|
||
| payload.roles = this.mergeRoleKeysForSave(access); |
There was a problem hiding this comment.
We're sending roles on every save and the backend replaces the whole list instead of merging — it removes every direct role, then re-adds only what we sent. So anything we don't send gets dropped: roles with no roleKey (filtered out at L440), and inherited roles come back as direct grants. If any role in that set has editUsers = false, addRoleToUser throws and the whole PUT fails.
The description says we deliberately omit roles so the backend preserves membership — that's the right call, we just need to actually not send it here.
There was a problem hiding this comment.
sending all to update the roles of the user is ok
| next: ({ user, userRoles, gettingStarted }) => { | ||
| const roleKeys = userRoles | ||
| .map((role) => role.roleKey) | ||
| .filter((key): key is string => !!key); |
There was a problem hiding this comment.
roleKey is optional and roles created from the Roles portlet often don't have one. Those get dropped here, never reach the payload, and the backend then deletes them (see L535). A user with custom roles loses all of them on a plain save.
| export class DotUsersCreateComponent { | ||
| private readonly dialogRef = inject(DynamicDialogRef); | ||
| private readonly config = inject<DynamicDialogConfig<DialogData>>(DynamicDialogConfig); | ||
| private readonly fb = inject(FormBuilder); |
There was a problem hiding this comment.
Two bigger ones, both worth an actual decision rather than leaving as-is:
- New forms should be Signal Forms. This one is brand new and Users portlet: Roles tab (dual-list) #36718–Users portlet: API Tokens tab #36720 will all build on it, so it's a good moment to pick.
- HTTP belongs in the store, not the dialog — this component injects the service and runs a three-call
forkJoin. A small dialog-scoped store would keep it out of here.
| * `null` while the per-user role fetch is still in flight so the | ||
| * cell renders empty instead of a misleading `and -2 more`. | ||
| */ | ||
| formatRoles(userId: string): { visible: string; more: number } | null { |
There was a problem hiding this comment.
Called from the template, so it runs per row per CD pass and allocates a new object each time. A computed() map keyed by userId — or formatting it into the store's userRoles shape — makes it a single derivation.
| * we must send back every role key the user already had, minus | ||
| * the access-role keys that are now toggled off. | ||
| */ | ||
| getUserRoles(userIdOrEmail: string): Observable<DotRoleView[]> { |
There was a problem hiding this comment.
Worth noting on this method: the endpoint uses the includeImplicitRoles = true overload, so the response includes inherited roles alongside directly-granted ones. That's what turns the echo-back at dot-users-create.component.ts:535 into inherited-becomes-direct.
| * Contract notes: | ||
| * - Create requires `password`; update leaves it optional and skips the | ||
| * password mutation when omitted. | ||
| * - Omitting `roles` on update means "do not touch role membership". |
There was a problem hiding this comment.
This note is right and it's the behaviour we want — but dot-users-create.component.ts:535 sets payload.roles on every save, so the code does the opposite. Once that's fixed it'd be worth saying outright that the FE never sends roles on update.
| }); | ||
|
|
||
| it('should not close on save when the form is invalid', () => { | ||
| spectator.component['save'](); |
There was a problem hiding this comment.
These reach into protected members (save, canConfirmDelete, onReplacementSelect, plus bulkReplacementUser in the list spec). Every button already has a data-testid, so spectator.click(byTestId('users-dialog-save-btn')) gets the same assertions and covers the disabled state and markAllAsTouched too.
|
|
||
| import { DotUserDetail, DotUserListItem, DotUsersService } from '../services/dot-users.service'; | ||
|
|
||
| const MOCK_USER: DotUserListItem = { |
There was a problem hiding this comment.
MOCK_USER / MOCK_USER_DETAIL are now duplicated across this spec, the list spec and the store spec. There's no createFakeUser in @dotcms/utils-testing yet — adding one would cover all three plus the dot-roles user tab.
Closes #36717
Summary
GET /api/v1/users/{userId}hydrates the edit dialog,POST /api/v1/userscreates,PUT /api/v1/usersupdates (roles field intentionally omitted so backend preserves membership — seeUserResource#processRoles)/api/v1/users/filter, excludes the users being deleted). Previously deletes silently reassigned ownership to the logged-in admin./usersrouting so the beta portlet actually lands on this UI instead of bouncing to Getting Started (PORTLET_ID_RESOLVERS.users→users-beta).Notable non-obvious calls
CMS Admin,Back-end,Front-end,Can Loginmap to backend roles (not UserForm fields), and safely editing them requires knowing the user's full role list. That's a follow-up (DWR wrapper vs. small REST endpoint decision).UserFormhandles password already; no client-side hack needed.Show Getting Startedtoggle has no backend field; kept in the UI as informational, not persisted. To be revisited if the value is needed elsewhere.Test plan
additionalInfo(prefix/suffix/title/company/website); on Save, changes persist and the list reloadspnpm nx test portlets-dot-users-portlet— 56 passingpnpm nx test data-access --testPathPatterns=dot-router— 46 passingpnpm nx lint portlets-dot-users-portlet— cleanFollow-ups (each on its own branch/PR already prepared)
🤖 Generated with Claude Code