Skip to content

Commit 8d93b62

Browse files
committed
Merge branch 'nicobytes/assetpicker-new-sidebar-ui' of github.com:dotCMS/core into nicobytes/assetpicker-new-sidebar-ui
2 parents aa5646e + c54dd3a commit 8d93b62

23 files changed

Lines changed: 2366 additions & 57 deletions

File tree

‎.gitignore‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,8 +231,11 @@ specs/*/research.md
231231
specs/*/tasks.md
232232
specs/*/quickstart.md
233233
specs/*/checklists/
234-
# Which feature the local checkout is working on. Per-branch, per-developer state that every
235-
# feature rewrites — committing it guarantees a conflict on each merge and leaves `main` pointing
236-
# at whichever feature landed last. SPEC_KIT_QUICK_START.md says it is "local and untracked, never
237-
# committed"; it had been committed by accident since #36950.
234+
235+
# Machine-local pointer to the active feature dir: which feature this checkout is working on,
236+
# rewritten by every /speckit-specify run. Per-branch, per-developer state — committing it
237+
# guarantees a conflict on each merge and leaves `main` pointing at whichever feature landed
238+
# last. SPEC_KIT_QUICK_START.md says it is "local and untracked, never committed"; it had been
239+
# committed by accident since #36950. Spec-Kit 0.16.1 ships this rule in a managed
240+
# .specify/.gitignore; this repo is pinned to 0.12.4, so it lives here until the upgrade.
238241
.specify/feature.json

‎core-web/libs/portlets/dot-users/src/lib/dot-users-create/dot-users-create.component.html‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,22 @@ <h2 class="m-0 truncate text-xl font-semibold" data-testid="users-dialog-title">
7979
</div>
8080
</p-tabpanel>
8181
<p-tabpanel [value]="1" class="block min-h-0 flex-1 overflow-auto">
82-
<div
83-
class="text-color-secondary px-7 pt-6 pb-7 text-sm"
84-
data-testid="users-dialog-roles-tab-placeholder">
85-
{{ 'users.dialog.tabs.coming-soon' | dm }}
82+
<div class="px-7 pt-6 pb-7">
83+
<!--
84+
Defer the roles tab until the user actually
85+
opens it — otherwise `loadRoles()` fires on
86+
every dialog open (including plain profile
87+
edits) since PrimeNG keeps every p-tabpanel
88+
in the DOM. `@defer (when …)` triggers once
89+
and doesn't retract, so subsequent tab
90+
switches keep the loaded tree.
91+
-->
92+
@defer (when $activeTab() === 1) {
93+
<dot-users-roles-tab
94+
[initialGrantedKeys]="initialGrantedRoleKeys()"
95+
(grantedChange)="onGrantedRolesChange($event)"
96+
data-testid="users-dialog-roles-tab" />
97+
}
8698
</div>
8799
</p-tabpanel>
88100
<p-tabpanel [value]="2" class="block min-h-0 flex-1 overflow-auto">

‎core-web/libs/portlets/dot-users/src/lib/dot-users-create/dot-users-create.component.spec.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ describe('DotUsersCreateComponent', () => {
5656
{ id: 'role-personal', roleKey: 'user-42' }
5757
])
5858
),
59+
getAllRoles: jest.fn().mockReturnValue(of([])),
5960
getGettingStartedState: jest.fn().mockReturnValue(of(false)),
6061
setGettingStarted: jest.fn().mockReturnValue(of({})),
6162
getUsersPaginated: jest.fn().mockReturnValue(
@@ -238,6 +239,28 @@ describe('DotUsersCreateComponent', () => {
238239
expect(call.payload.roles).not.toContain('user-42');
239240
});
240241

242+
it('should send an empty `roles: []` when the Roles tab clears every grant and access toggles are off', () => {
243+
// Simulate the Roles tab clearing everything.
244+
spectator.component['onGrantedRolesChange']([]);
245+
spectator.component.form.controls.access.patchValue({
246+
cmsAdmin: false,
247+
backend: false,
248+
frontend: false
249+
});
250+
251+
spectator.detectChanges();
252+
spectator.click(saveButton(spectator));
253+
254+
const call = (dialogRef.close as jest.Mock).mock.calls[0][0] as {
255+
payload: { roles: string[] };
256+
};
257+
// Backend fix #37109 lets us send `roles: []` to actually
258+
// clear the user's membership. The FE relies on that being
259+
// included in the payload (not omitted) — otherwise the
260+
// backend still reads it as "don't touch".
261+
expect(call.payload.roles).toEqual([]);
262+
});
263+
241264
it('should emit `gettingStartedChange: add` when the toggle flips ON', () => {
242265
spectator.component.form.controls.access.patchValue({ showGettingStarted: true });
243266

‎core-web/libs/portlets/dot-users/src/lib/dot-users-create/dot-users-create.component.ts‎

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { DotMessagePipe } from '@dotcms/ui';
2626
import { DotUsersFormGroup, passwordsMatchValidator } from './dot-users-form.model';
2727
import { DotUsersCreateStore } from './store/dot-users-create.store';
2828
import { DotUsersProfileTabComponent } from './tabs/dot-users-profile-tab/dot-users-profile-tab.component';
29+
import { DotUsersRolesTabComponent } from './tabs/dot-users-roles-tab/dot-users-roles-tab.component';
2930

3031
import { DotUsersReplacementPickerComponent } from '../components/dot-users-replacement-picker/dot-users-replacement-picker.component';
3132
import { DotUserFormPayload, DotUserListItem } from '../services/dot-users.service';
@@ -78,10 +79,9 @@ const ACCESS_ROLE_KEYS = {
7879
* (create vs edit), and orchestrates each tab as a standalone
7980
* presentational sub-component.
8081
*
81-
* Scope for issue #36717 — only the Profile tab is real. Roles,
82-
* Permissions, and API Tokens render "Coming soon" placeholders and
83-
* are delivered by their sibling issues (#36718, #36719, #36720),
84-
* each of which swaps its placeholder for the real tab component.
82+
* Scope for issue #36718 — Profile + Roles tabs are real; Permissions
83+
* and API Tokens render "Coming soon" placeholders and are delivered
84+
* by #36719 and #36720.
8585
*/
8686
@Component({
8787
selector: 'dot-users-create',
@@ -98,7 +98,8 @@ const ACCESS_ROLE_KEYS = {
9898
TagModule,
9999
DotMessagePipe,
100100
DotUsersReplacementPickerComponent,
101-
DotUsersProfileTabComponent
101+
DotUsersProfileTabComponent,
102+
DotUsersRolesTabComponent
102103
],
103104
templateUrl: './dot-users-create.component.html',
104105
styleUrl: './dot-users-create.component.scss',
@@ -238,6 +239,20 @@ export class DotUsersCreateComponent {
238239

239240
protected readonly $isSaveDisabled = computed(() => !this.$dataReady());
240241

242+
/**
243+
* Role KEYS that hydrate the Roles tab's Granted panel. Sourced
244+
* from `getUserRoles` on edit-mode open; stays empty in create
245+
* mode.
246+
*/
247+
protected readonly initialGrantedRoleKeys = signal<string[]>([]);
248+
249+
/**
250+
* Latest snapshot from the Roles tab. Populated on every
251+
* `grantedChange` emission and used to build the outbound
252+
* `roles` field on save.
253+
*/
254+
private readonly currentRoleKeys = signal<string[] | null>(null);
255+
241256
/**
242257
* ID list handed to the replacement picker so the user being
243258
* deleted is filtered out of the suggestion pool. Backed by a
@@ -414,7 +429,8 @@ export class DotUsersCreateComponent {
414429
if (!detail) {
415430
return;
416431
}
417-
const roleKeySet = new Set(this.#store.roleKeys());
432+
const roleKeys = this.#store.roleKeys();
433+
const roleKeySet = new Set(roleKeys);
418434
const additionalInfo = this.#store.additionalInfo();
419435

420436
this.form.patchValue({
@@ -439,6 +455,16 @@ export class DotUsersCreateComponent {
439455
}
440456
});
441457
this.form.markAsPristine();
458+
459+
// Seed the Roles-tab integration signals so the Granted panel
460+
// opens with the user's current membership, and save picks up
461+
// the same list if the user never touches the tab.
462+
this.initialGrantedRoleKeys.set(roleKeys);
463+
this.currentRoleKeys.set(roleKeys);
464+
}
465+
466+
protected onGrantedRolesChange(keys: string[]): void {
467+
this.currentRoleKeys.set(keys);
442468
}
443469

444470
private enableCreatePasswordValidators(): void {
@@ -455,11 +481,12 @@ export class DotUsersCreateComponent {
455481
* Builds the backend {@link DotUserFormPayload} plus a
456482
* `gettingStartedChange` instruction for the store to chain.
457483
*
458-
* Roles are computed as: cached role-key list → strip the three
459-
* access-role keys → add back whichever access toggles are ON.
460-
* The result replaces the user's full role membership on the
461-
* backend (`UserResource#processRoles`), so leaving out the
462-
* non-access role keys would silently wipe them.
484+
* When the Roles tab has taken ownership of role membership (via
485+
* `grantedChange`, tracked in `currentRoleKeys`), its snapshot is
486+
* the source of truth for `payload.roles`. Otherwise we fall back
487+
* to `mergeRoleKeysForSave`, which composes the outbound list
488+
* from cached role keys + access-toggle deltas — safe when the
489+
* user never opened the Roles tab.
463490
*
464491
* Password and additionalInfo are omitted when empty so the
465492
* backend keeps their existing values.
@@ -501,6 +528,13 @@ export class DotUsersCreateComponent {
501528

502529
payload.additionalInfo = additionalInfo;
503530

531+
// Compose the outbound role list from the "base" role keys
532+
// (Roles tab's Granted snapshot when the user touched it,
533+
// otherwise the store's fetched roleKeys) with the current
534+
// access-toggle deltas applied on top. `mergeRoleKeysForSave`
535+
// strips the three access-role keys from the base and re-adds
536+
// whichever toggles are ON, so an Access flip is always
537+
// reflected in the payload regardless of Roles-tab state.
504538
payload.roles = this.mergeRoleKeysForSave(access);
505539

506540
let gettingStartedChange: 'add' | 'remove' | undefined;
@@ -512,42 +546,46 @@ export class DotUsersCreateComponent {
512546
}
513547

514548
/**
515-
* Merges the cached role KEYS with the current Access toggles into
516-
* the list the backend expects. In create mode the cache is empty,
517-
* so the outbound list is just whichever access toggles are ON —
518-
* safe because create semantics ADD roles instead of replacing.
519-
*
520-
* The user's implicit personal role (roleKey === userId) is
521-
* filtered out because `UserResource#processRoles` first calls
549+
* Strips the user's implicit personal role (roleKey === userId)
550+
* from the outbound list. `UserResource#processRoles` first calls
522551
* `removeAllRolesFromUser` (no `editUsers` guard) and then tries
523552
* to re-add every key in the payload. Re-adding the personal role
524553
* fails at `RoleAPIImpl.addRoleToUser` because it has
525554
* `editUsers=false`, and the exception rolls the whole save back
526555
* with `"Cannot alter users on this role"`. Leaving that key out
527556
* of the payload keeps the save from tripping the guard.
528-
*
529-
* The trade-off: the backend still removes the personal role in
530-
* step 1, so after the save the user is missing that link. In
531-
* practice most permissions live on the well-known roles above,
532-
* not the personal role — but this needs a proper backend fix
533-
* (add an `editUsers` guard to `removeAllRolesFromUser`).
557+
*/
558+
private filterOutgoingRoleKeys(keys: readonly string[]): string[] {
559+
const personalRoleKey = this.user?.userId ?? '';
560+
if (!personalRoleKey) {
561+
return [...keys];
562+
}
563+
564+
return keys.filter((key) => key !== personalRoleKey);
565+
}
566+
567+
/**
568+
* Merges the "base" role KEYS with the current Access toggles.
569+
* The base is the Roles tab's Granted snapshot (`currentRoleKeys`)
570+
* when the user touched it — otherwise the store's fetched keys.
571+
* We strip the three access-role slots from the base list and
572+
* add back whichever toggles are ON, so an Access flip is always
573+
* captured on save even when the user visited the Roles tab.
534574
*/
535575
private mergeRoleKeysForSave(access: {
536576
cmsAdmin: boolean;
537577
backend: boolean;
538578
frontend: boolean;
539579
}): string[] {
580+
const base = this.currentRoleKeys() ?? this.#store.roleKeys();
540581
const accessKeys = new Set<string>(Object.values(ACCESS_ROLE_KEYS));
541-
const personalRoleKey = this.user?.userId ?? '';
542-
const nonAccess = this.#store
543-
.roleKeys()
544-
.filter((key) => !accessKeys.has(key) && key !== personalRoleKey);
582+
const nonAccess = base.filter((key) => !accessKeys.has(key));
545583
const merged = new Set(nonAccess);
546584

547585
if (access.cmsAdmin) merged.add(ACCESS_ROLE_KEYS.cmsAdmin);
548586
if (access.backend) merged.add(ACCESS_ROLE_KEYS.backend);
549587
if (access.frontend) merged.add(ACCESS_ROLE_KEYS.frontend);
550588

551-
return Array.from(merged);
589+
return this.filterOutgoingRoleKeys(Array.from(merged));
552590
}
553591
}

0 commit comments

Comments
 (0)