Skip to content

feat(users): Profile tab, dialog shell & list CRUD wiring (#36717) - #36990

Open
AP2300 wants to merge 6 commits into
mainfrom
issue-36717-users-portlet-profile-tab
Open

feat(users): Profile tab, dialog shell & list CRUD wiring (#36717)#36990
AP2300 wants to merge 6 commits into
mainfrom
issue-36717-users-portlet-profile-tab

Conversation

@AP2300

@AP2300 AP2300 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #36717

Summary

  • Ships the Users portlet Create/Edit dialog shell (4-tab strip, header, footer) with the Profile tab fully wired against the backend and inline "Coming soon" placeholders for the Roles, Permissions, and API Tokens tabs (delivered by sibling issues Users portlet: Roles tab (dual-list) #36718, Users portlet: Permissions tab (iframe embed) #36719, Users portlet: API Tokens tab #36720)
  • Adds the missing user CRUD to the list flow: GET /api/v1/users/{userId} hydrates the edit dialog, POST /api/v1/users creates, PUT /api/v1/users updates (roles field intentionally omitted so backend preserves membership — see UserResource#processRoles)
  • Delete flow — both from the dialog footer and the list bulk toolbar — now includes a required replacement-user picker (server-backed autocomplete over /api/v1/users/filter, excludes the users being deleted). Previously deletes silently reassigned ownership to the logged-in admin.
  • Fixes /users routing so the beta portlet actually lands on this UI instead of bouncing to Getting Started (PORTLET_ID_RESOLVERS.usersusers-beta).

Notable non-obvious calls

  • Access section is display-only in this PR: CMS Admin, Back-end, Front-end, Can Login map 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).
  • Password stays optional on update. Empty password field means "keep current" — matches how UserForm handles password already; no client-side hack needed.
  • Show Getting Started toggle has no backend field; kept in the UI as informational, not persisted. To be revisited if the value is needed elsewhere.

Test plan

  • Create user via the "New" button — required fields validated, password ≥ 6 chars, confirm-match enforced; on Save a new row appears in the list
  • Edit user via row click — dialog hydrates from list-item data instantly, then background-fetches additionalInfo (prefix/suffix/title/company/website); on Save, changes persist and the list reloads
  • Toggle Active on/off via the Profile field — persists on save
  • Delete user via the dialog footer — replacement picker required, cannot pick the same user; typed-email confirmation required; on confirm, list reloads
  • Bulk-delete N users from the toolbar — replacement picker required, cannot pick any of the selected; on confirm, list reloads
  • Placeholder tabs render "Coming soon" and don't interfere with Profile Save
  • pnpm nx test portlets-dot-users-portlet — 56 passing
  • pnpm nx test data-access --testPathPatterns=dot-router — 46 passing
  • pnpm nx lint portlets-dot-users-portlet — clean

Follow-ups (each on its own branch/PR already prepared)

🤖 Generated with Claude Code

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>
@github-actions github-actions Bot added the Area : Frontend PR changes Angular/TypeScript frontend code label Aug 10, 2026
AP2300 and others added 2 commits August 11, 2026 14:00
…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>
oidacra added a commit that referenced this pull request Aug 11, 2026
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.
oidacra added a commit that referenced this pull request Aug 12, 2026
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.
oidacra added a commit that referenced this pull request Aug 13, 2026
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.
oidacra added a commit that referenced this pull request Aug 13, 2026
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.
AP2300 and others added 2 commits August 17, 2026 10:36
…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>
oidacra added a commit that referenced this pull request Aug 18, 2026
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.
oidacra added a commit that referenced this pull request Aug 18, 2026
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);

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two bigger ones, both worth an actual decision rather than leaving as-is:

* `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 {

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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[]> {

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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']();

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = {

@hmoreras hmoreras Aug 19, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Users portlet: Profile tab (Create and Edit dialog)

2 participants