src/app/core/admin/admin-role-permissions/role-details/admin-role-details.component.ts

Description

Details of one user role, showing its permission rules as an editable matrix. Also used to create a new role (route data newRole).

Implements

OnInit

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

cancel
cancel()
Returns : void
Async deleteRole
deleteRole()
Returns : any
Async loadRole
loadRole()
Returns : any
onModelChange
onModelChange(updated: MatrixModel)
Parameters :
Name Type Optional
updated MatrixModel No
Returns : void
Async save
save()
Returns : unknown
startEditing
startEditing()
Returns : void

Properties

Readonly canManageRoles
Type : unknown
Default value : this.rolePermissionsService.canManageRoles

whether the user may create/delete/update roles in the authentication server (reactive)

Readonly deleteDisabledTooltip
Type : unknown
Default value : $localize`Your account does not have permission to delete roles in the user account server.`
Readonly descriptionControl
Type : unknown
Default value : new FormControl("")
Readonly descriptionEditable
Type : unknown
Default value : computed( () => this.canManageRoles() && !this.isProtected() && (this.isNew() || (this.editing() && !!this.role()?.keycloakRole)), )

Description is stored in the authentication server, so it can only be edited for existing realm-backed roles when the user is allowed to manage roles. Protected roles keep their description read-only.

Readonly editing
Type : unknown
Default value : signal(false)
Readonly inheritedRules
Type : unknown
Default value : signal<DatabaseRule[]>([])

Rules of the shared "_default" role, which every logged-in user has on top of their own roles. Empty for roles that do not inherit them (see inheritsDefaultRules).

Readonly isNew
Type : unknown
Default value : signal(false)
Readonly isProtected
Type : unknown
Default value : computed(() => !!this.role()?.isProtected)

protected roles (reserved + technical) cannot be deleted or have their description edited

Readonly model
Type : unknown
Default value : signal<MatrixModel>(emptyModel())
Readonly nameControl
Type : unknown
Default value : new FormControl("", [ Validators.required, // leading "_" is reserved for the virtual roles (_default / _public) Validators.pattern(/^(?!_)[a-zA-Z0-9_-]+$/), (control) => this.existingRoleNames?.has(control.value) ? { duplicate: true } : null, ])
Readonly role
Type : unknown
Default value : signal<RoleWithPermissions | undefined>(undefined)
Readonly roleName
Type : unknown
Default value : signal("")
import {
  ChangeDetectionStrategy,
  Component,
  DestroyRef,
  OnInit,
  computed,
  effect,
  inject,
  signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { MatButtonModule } from "@angular/material/button";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatSnackBar } from "@angular/material/snack-bar";
import { MatTooltipModule } from "@angular/material/tooltip";
import { ActivatedRoute, Router } from "@angular/router";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";

import { Logging } from "../../../logging/logging.service";
import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";
import { ViewTitleComponent } from "../../../common-components/view-title/view-title.component";
import { UnsavedChangesService } from "../../../entity-details/form/unsaved-changes.service";
import {
  MatrixModel,
  matrixToRules,
  rulesToMatrix,
} from "../permission-matrix";
import { PermissionMatrixComponent } from "../permission-matrix/permission-matrix.component";
import {
  RolePermissionsService,
  RoleWithPermissions,
} from "../role-permissions.service";
import { UserAdminApiError } from "../../../user/user-admin-service/user-admin.service";
import {
  DatabaseRule,
  DEFAULT_SECTION_KEY,
  inheritsDefaultRules,
} from "../../../permissions/permission-types";

/** fresh empty matrix model; a factory (not a shared const) so callers can never alias a mutable object */
const emptyModel = (): MatrixModel => ({ rows: [], unsupportedRules: [] });

/**
 * Details of one user role, showing its permission rules as an editable matrix.
 * Also used to create a new role (route data `newRole`).
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-admin-role-details",
  imports: [
    ViewTitleComponent,
    PermissionMatrixComponent,
    MatButtonModule,
    MatFormFieldModule,
    MatInputModule,
    MatTooltipModule,
    ReactiveFormsModule,
    FaIconComponent,
  ],
  templateUrl: "./admin-role-details.component.html",
})
export class AdminRoleDetailsComponent implements OnInit {
  private readonly rolePermissionsService = inject(RolePermissionsService);
  private readonly confirmationDialog = inject(ConfirmationDialogService);
  private readonly snackBar = inject(MatSnackBar);
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  private readonly unsavedChanges = inject(UnsavedChangesService);
  private readonly destroyRef = inject(DestroyRef);

  readonly roleName = signal("");
  readonly role = signal<RoleWithPermissions | undefined>(undefined);
  readonly model = signal<MatrixModel>(emptyModel());
  readonly editing = signal(false);
  readonly isNew = signal(false);

  readonly nameControl = new FormControl("", [
    Validators.required,
    // leading "_" is reserved for the virtual roles (_default / _public)
    Validators.pattern(/^(?!_)[a-zA-Z0-9_-]+$/),
    (control) =>
      this.existingRoleNames?.has(control.value) ? { duplicate: true } : null,
  ]);
  readonly descriptionControl = new FormControl("");

  private originalModel: MatrixModel = emptyModel();
  private existingRoleNames = new Set<string>();

  constructor() {
    // keep the description form state in sync with the edit mode
    effect(() => {
      if (this.descriptionEditable()) {
        this.descriptionControl.enable();
      } else {
        this.descriptionControl.disable();
      }
    });

    this.destroyRef.onDestroy(() =>
      this.unsavedChanges.setUnsavedChanges(this, false),
    );
  }

  ngOnInit() {
    if (this.route.snapshot.data["newRole"]) {
      void this.initNewRole();
    } else {
      this.nameControl.disable();
      this.route.paramMap
        .pipe(takeUntilDestroyed(this.destroyRef))
        .subscribe((params) => {
          this.roleName.set(params.get("role") ?? "");
          this.nameControl.setValue(this.roleName());
          void this.loadRole();
        });
    }
  }

  private async initNewRole() {
    this.isNew.set(true);
    this.editing.set(true);
    this.model.set(emptyModel());
    const roles = await this.rolePermissionsService.loadRoles();
    this.setInheritedRules(roles);
    this.existingRoleNames = new Set(roles.map((r) => r.name));
  }

  async loadRole() {
    const roles = await this.rolePermissionsService.loadRoles();
    const role = roles.find((r) => r.name === this.roleName());
    this.role.set(role);
    this.setInheritedRules(roles);
    this.model.set(rulesToMatrix(role?.rules ?? []));
    this.descriptionControl.setValue(role?.description ?? "");
    this.descriptionControl.markAsPristine();
  }

  /**
   * Rules of the shared "_default" role, which every logged-in user has on top
   * of their own roles. Empty for roles that do not inherit them
   * (see {@link inheritsDefaultRules}).
   */
  readonly inheritedRules = signal<DatabaseRule[]>([]);

  private setInheritedRules(roles: RoleWithPermissions[]) {
    if (!inheritsDefaultRules(this.roleName())) {
      this.inheritedRules.set([]);
      return;
    }
    const defaultRole = roles.find((r) => r.name === DEFAULT_SECTION_KEY);
    this.inheritedRules.set(defaultRole?.rules ?? []);
  }

  /** whether the user may create/delete/update roles in the authentication server (reactive) */
  readonly canManageRoles = this.rolePermissionsService.canManageRoles;

  readonly deleteDisabledTooltip = $localize`Your account does not have permission to delete roles in the user account server.`;

  /** protected roles (reserved + technical) cannot be deleted or have their description edited */
  readonly isProtected = computed(() => !!this.role()?.isProtected);

  /**
   * Description is stored in the authentication server, so it can only be edited
   * for existing realm-backed roles when the user is allowed to manage roles.
   * Protected roles keep their description read-only.
   */
  readonly descriptionEditable = computed(
    () =>
      this.canManageRoles() &&
      !this.isProtected() &&
      (this.isNew() || (this.editing() && !!this.role()?.keycloakRole)),
  );

  startEditing() {
    this.originalModel = structuredClone(this.model());
    this.editing.set(true);
  }

  onModelChange(updated: MatrixModel) {
    this.model.set(updated);
    this.unsavedChanges.setUnsavedChanges(this, true);
  }

  cancel() {
    if (this.isNew()) {
      this.unsavedChanges.setUnsavedChanges(this, false);
      this.router.navigate([".."], { relativeTo: this.route });
      return;
    }
    this.model.set(this.originalModel);
    this.descriptionControl.setValue(this.role()?.description ?? "");
    this.descriptionControl.markAsPristine();
    this.editing.set(false);
    this.unsavedChanges.setUnsavedChanges(this, false);
  }

  async save() {
    if (this.isNew()) {
      return this.saveNewRole();
    }

    try {
      await this.rolePermissionsService.saveRules(
        this.roleName(),
        matrixToRules(this.model()),
      );
    } catch (err) {
      // keep the user in edit mode with their unsaved changes so they can retry
      this.showError(
        $localize`Could not save the permissions. Please try again.`,
        err,
      );
      return;
    }
    if (this.descriptionControl.dirty && this.role()?.keycloakRole) {
      try {
        await this.rolePermissionsService.updateRoleDescription(
          this.roleName(),
          this.descriptionControl.value ?? "",
        );
      } catch (err) {
        this.showError(
          $localize`Permissions saved, but the role description could not be updated.`,
          err,
        );
      }
    }
    this.editing.set(false);
    this.unsavedChanges.setUnsavedChanges(this, false);
    await this.loadRole();
  }

  private async saveNewRole() {
    this.nameControl.markAsTouched();
    if (this.nameControl.invalid) return;

    const name = this.nameControl.value;
    try {
      await this.rolePermissionsService.createRole(
        name,
        this.descriptionControl.value ?? "",
        matrixToRules(this.model()),
      );
    } catch (err) {
      // surface the specific server message (e.g. the localized 409 "role
      // already exists") instead of a generic permissions guess
      const message =
        err instanceof UserAdminApiError
          ? err.message
          : $localize`Could not create the role. Your account may not have permission to create roles in the user account server.`;
      this.showError(message, err);
      return;
    }

    this.unsavedChanges.setUnsavedChanges(this, false);
    await this.router.navigate(["..", name], {
      relativeTo: this.route,
      replaceUrl: true,
    });
  }

  async deleteRole() {
    // the disabled Delete button stays interactive (so its tooltip can explain
    // why), which means it still emits clicks
    if (!this.canManageRoles()) return;

    const confirmed = await this.confirmationDialog.getConfirmation(
      $localize`Delete role?`,
      $localize`This permanently removes the role "${this.roleName()}" and its permissions. Users currently having this role lose the access it granted. This cannot be undone.`,
    );
    if (!confirmed) return;

    try {
      await this.rolePermissionsService.deleteRole(this.roleName());
    } catch (err) {
      this.showError(
        $localize`Could not delete the role. Your account may not have permission to delete roles in the user account server.`,
        err,
      );
      return;
    }
    await this.router.navigate([".."], { relativeTo: this.route });
  }

  private showError(message: string, error: unknown) {
    Logging.error("Role management action failed", error);
    this.snackBar.open(message, undefined, { duration: 8000 });
  }
}
<div class="flex-row flex-wrap">
  <div class="flex-grow">
    @if (isNew()) {
      <app-view-title i18n>New Role</app-view-title>
    } @else {
      <app-view-title i18n>Role Permissions</app-view-title>
    }
  </div>

  <div class="flex-row gap-small align-center">
    @if (editing()) {
      <button
        mat-raised-button
        color="accent"
        [disabled]="isNew() && nameControl.invalid"
        (click)="save()"
        i18n
      >
        Save
      </button>
      <button mat-stroked-button (click)="cancel()" i18n>Cancel</button>
    } @else {
      <button mat-raised-button color="accent" (click)="startEditing()">
        <fa-icon icon="pen" class="standard-icon-with-text"></fa-icon>
        <span i18n>Edit</span>
      </button>
      @if (role() && !isProtected()) {
        <!-- tooltip on the button itself, kept hoverable while disabled via
             "disabledInteractive"; the hidden description states the same
             reason for screen readers -->
        <button
          mat-stroked-button
          [disabled]="!canManageRoles()"
          [disabledInteractive]="!canManageRoles()"
          [matTooltip]="deleteDisabledTooltip"
          [matTooltipDisabled]="canManageRoles()"
          [attr.aria-describedby]="
            canManageRoles() ? null : 'role-delete-disabled-reason'
          "
          (click)="deleteRole()"
        >
          <fa-icon icon="trash" class="standard-icon-with-text"></fa-icon>
          <span i18n>Delete</span>
        </button>
        @if (!canManageRoles()) {
          <span class="visually-hidden" id="role-delete-disabled-reason">{{
            deleteDisabledTooltip
          }}</span>
        }
      }
    }
  </div>
</div>

<div class="flex-row flex-wrap align-start gap-regular margin-bottom-regular">
  <mat-form-field>
    <mat-label i18n>Role name</mat-label>
    <input
      matInput
      [formControl]="nameControl"
      i18n-placeholder
      placeholder="e.g. field_supervisor"
    />
    @if (!isNew()) {
      <fa-icon icon="lock" matSuffix></fa-icon>
    }
    <mat-hint i18n>
      The role is created in the user account server and its name cannot be
      changed afterwards.
    </mat-hint>
    @if (nameControl.hasError("required")) {
      <mat-error i18n>This field is required</mat-error>
    }
    @if (nameControl.hasError("pattern")) {
      <mat-error i18n>
        Only letters, numbers, "_" and "-" are allowed.
      </mat-error>
    }
    @if (nameControl.hasError("duplicate")) {
      <mat-error i18n>A role with this name already exists.</mat-error>
    }
  </mat-form-field>

  <mat-form-field class="flex-grow">
    <mat-label i18n>Description</mat-label>
    <textarea
      matInput
      [formControl]="descriptionControl"
      rows="2"
      i18n-placeholder
      placeholder="What is this role for?"
    ></textarea>
  </mat-form-field>
</div>

<app-permission-matrix
  [model]="model()"
  [inheritedRules]="inheritedRules()"
  [editable]="editing()"
  [roleName]="roleName()"
  (modelChange)="onModelChange($event)"
></app-permission-matrix>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""