@@ -26,6 +26,7 @@ import { DotMessagePipe } from '@dotcms/ui';
2626import { DotUsersFormGroup , passwordsMatchValidator } from './dot-users-form.model' ;
2727import { DotUsersCreateStore } from './store/dot-users-create.store' ;
2828import { 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
3031import { DotUsersReplacementPickerComponent } from '../components/dot-users-replacement-picker/dot-users-replacement-picker.component' ;
3132import { 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