src/app/core/admin/admin-role-permissions/role-details/admin-role-details.component.ts
Details of one user role, showing its permission rules as an editable matrix.
Also used to create a new role (route data newRole).
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-admin-role-details |
| standalone | true |
| imports |
MatButtonModule
MatFormFieldModule
MatInputModule
MatTooltipModule
|
| templateUrl | ./admin-role-details.component.html |
No results matching.
ViewTitleComponent
PermissionMatrixComponent
MatButtonModule
MatFormFieldModule
MatInputModule
MatTooltipModule
ReactiveFormsModule
FaIconComponent
OnInit
Properties |
|
Methods |
|
constructor()
|
| cancel |
cancel()
|
|
Returns :
void
|
| Async deleteRole |
deleteRole()
|
|
Returns :
any
|
| Async loadRole |
loadRole()
|
|
Returns :
any
|
| onModelChange | ||||||
onModelChange(updated: MatrixModel)
|
||||||
|
Parameters :
Returns :
void
|
| Async save |
save()
|
|
Returns :
unknown
|
| startEditing |
startEditing()
|
|
Returns :
void
|
| 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 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 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>