src/app/core/permissions/feature-permission/feature-permission-dialog/feature-permission-dialog.component.ts
Dialog to review and edit which user roles can add, read, update or delete the records of a single feature (internal entity type), writing the changes back to the central permissions config via FeaturePermissionService.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-feature-permission-dialog |
| standalone | true |
| imports |
MatDialogModule
MatButtonModule
MatTooltipModule
MatProgressBarModule
|
| styleUrls | ./feature-permission-dialog.component.scss |
| templateUrl | ./feature-permission-dialog.component.html |
| styleUrl | ./feature-permission-dialog.component.scss |
No results matching.
MatDialogModule
MatButtonModule
MatTooltipModule
MatProgressBarModule
RouterLink
FaIconComponent
DialogCloseComponent
HintBoxComponent
PermissionCheckboxComponent
Properties |
|
Methods |
| Async confirm |
confirm()
|
|
Returns :
Promise<void>
|
| setAction | ||||||||||||
setAction(role: string, action: CrudAction, checked: boolean)
|
||||||||||||
|
Update a single checkbox, replacing the array so OnPush re-renders.
Parameters :
Returns :
void
|
| Readonly actionColumns |
Type : unknown
|
Default value : CRUD_ACTION_COLUMNS
|
|
the checkbox columns, shared with the permission matrix of the role administration |
| Readonly entityLabel |
Type : unknown
|
Default value : this.data.entityLabel ?? this.data.entityType
|
| Readonly entityType |
Type : unknown
|
Default value : this.data.entityType
|
| Readonly loadFailed |
Type : unknown
|
Default value : computed(
() => this.permissionRows.value()?.loadFailed === true,
)
|
|
whether the roles and their permissions could not be loaded |
| Readonly rolesAdminRoute |
Type : unknown
|
Default value : ROLES_ADMIN_ROUTE
|
|
where to manage the roles and their rules in full |
| Readonly rows |
Type : unknown
|
Default value : linkedSignal(() => this.permissionRows.value()?.rows ?? [])
|
|
The edited state, one row per |
| Readonly saving |
Type : unknown
|
Default value : signal(false)
|
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
linkedSignal,
resource,
signal,
} from "@angular/core";
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef,
} from "@angular/material/dialog";
import { MatButtonModule } from "@angular/material/button";
import { MatTooltipModule } from "@angular/material/tooltip";
import { MatProgressBarModule } from "@angular/material/progress-bar";
import { MatSnackBar } from "@angular/material/snack-bar";
import { RouterLink } from "@angular/router";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { firstValueFrom } from "rxjs";
import { DialogCloseComponent } from "../../../common-components/dialog-close/dialog-close.component";
import { HintBoxComponent } from "../../../common-components/hint-box/hint-box.component";
import { UserAdminService } from "../../../user/user-admin-service/user-admin.service";
import { Logging } from "../../../logging/logging.service";
import { ROLES_ADMIN_ROUTE } from "../../../admin/admin-role-permissions/role-permissions.service";
import {
CRUD_ACTION_COLUMNS,
CrudActionColumn,
grantedByAdvancedRuleTooltip,
grantedByDefaultRoleTooltip,
} from "../../permission-action-labels";
import { CRUD_ACTIONS, CrudAction } from "../../permission-types";
import {
PermissionCellState,
PermissionCheckboxComponent,
} from "../../permission-checkbox/permission-checkbox.component";
import { DEFAULT_ROLE } from "../../reserved-roles";
import { PermissionsConfigService } from "../../permissions-config.service";
import {
FeatureActionPermission,
FeaturePermissionService,
RoleFeaturePermission,
} from "../feature-permission.service";
/**
* Input passed to the {@link FeaturePermissionDialogComponent} when opened.
*/
export interface FeaturePermissionDialogData {
/** the feature's ENTITY_TYPE (e.g. "EmailTemplate") */
entityType: string;
/** human-readable (plural) label of the feature, e.g. "Email Templates" */
entityLabel?: string;
}
/** one checkbox of a row, with everything the template needs precomputed */
interface PermissionCell extends PermissionCellState {
action: CrudAction;
/**
* What the checkbox shows: the effective access where another rule decides it,
* and otherwise the row's own grant as edited here - with what the `_default`
* row adds on top layered in by
* {@link FeaturePermissionDialogComponent.displayRows}.
*
* This is therefore *not* {@link FeatureActionPermission.allowed}, which
* always includes what `_default` grants as stored.
*/
allowed: boolean;
/**
* The row's own grant as stored, taken over from
* {@link FeatureActionPermission.ownAllowed} and not changed while editing:
* only `allowed` follows the checkboxes. It is what a row saves for a
* checkbox an advanced rule decides - where `allowed` shows that rule's
* effect - so that saving does not turn such a rule into the row's own.
*/
ownAllowed: boolean;
/**
* In `rows` whether an advanced rule leaves this checkbox editable at all; in
* `displayRows` additionally `false` while `_default` grants the action.
*
* Together with `lockTooltip` this is what the grid makes of the service's
* {@link FeatureActionPermission.lockedByAdvancedRule}.
*/
editable: boolean;
ariaLabel: string;
}
/** per-role row shown in the dialog grid */
interface RolePermissionRow {
/** the role's technical name, or the reserved key of the shared default section */
role: string;
/** what to show in the first column */
label: string;
/** human-readable description from the auth server, if available */
description?: string;
/** true for the shared `_default` row shown above the roles */
isDefaultRow: boolean;
/**
* Whether any checkbox of the row can be changed, i.e. whether the row is
* saved at all (see {@link RoleFeaturePermission.editable}). A single
* checkbox can still be locked on an editable row - that is
* {@link PermissionCell.editable}.
*/
editable: boolean;
/** why the whole row cannot be changed; empty when it is editable */
lockTooltip: string;
/** one entry per action, in the order of {@link FeaturePermissionDialogComponent.actionColumns} */
cells: PermissionCell[];
}
/** what the dialog loads before it can display anything */
interface FeaturePermissionRows {
rows: RolePermissionRow[];
/** true when the roles or their permissions could not be loaded at all */
loadFailed?: boolean;
}
/**
* Dialog to review and edit which user roles can add, read, update or delete the
* records of a single feature (internal entity type), writing the changes back to
* the central permissions config via {@link FeaturePermissionService}.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-feature-permission-dialog",
templateUrl: "./feature-permission-dialog.component.html",
styleUrl: "./feature-permission-dialog.component.scss",
imports: [
MatDialogModule,
MatButtonModule,
MatTooltipModule,
MatProgressBarModule,
RouterLink,
FaIconComponent,
DialogCloseComponent,
HintBoxComponent,
PermissionCheckboxComponent,
],
})
export class FeaturePermissionDialogComponent {
private readonly dialogRef = inject(
MatDialogRef<FeaturePermissionDialogComponent>,
);
private readonly data = inject<FeaturePermissionDialogData>(MAT_DIALOG_DATA);
private readonly permissionService = inject(FeaturePermissionService);
private readonly permissionsConfig = inject(PermissionsConfigService);
private readonly userAdminService = inject(UserAdminService);
private readonly snackBar = inject(MatSnackBar);
/** where to manage the roles and their rules in full */
readonly rolesAdminRoute = ROLES_ADMIN_ROUTE;
readonly entityType = this.data.entityType;
readonly entityLabel = this.data.entityLabel ?? this.data.entityType;
/** the checkbox columns, shared with the permission matrix of the role administration */
readonly actionColumns = CRUD_ACTION_COLUMNS;
readonly permissionRows = resource<FeaturePermissionRows, unknown>({
// A failure is reported as a value rather than through the resource's own
// error state: the ProxyZone the specs run in re-reports an error thrown
// inside a loader as an uncaught exception, failing the test run, even
// though `resource` itself stores it and keeps rendering.
loader: async () => {
try {
return await this.loadPermissionRows();
} catch (error) {
Logging.error("Failed to load feature permissions", error);
return { rows: [], loadFailed: true };
}
},
});
/** whether the roles and their permissions could not be loaded */
readonly loadFailed = computed(
() => this.permissionRows.value()?.loadFailed === true,
);
/**
* The edited state, one row per `_default` section and user role, holding each
* row's *own* grants - i.e. what will be written for it. What a role inherits
* from the `_default` row is layered on top in {@link displayRows} only.
*/
readonly rows = linkedSignal(() => this.permissionRows.value()?.rows ?? []);
/** the actions the `_default` row grants, as currently ticked in this dialog */
private readonly grantedByDefault = computed(() => {
const defaultRow = this.rows().find((row) => row.isDefaultRow);
return new Set(
defaultRow?.cells
.filter((cell) => cell.allowed)
.map((cell) => cell.action) ?? [],
);
});
/**
* The rows as displayed: on a role row an action granted by the `_default` row
* shows as ticked and locked, and follows changes to the `_default` row
* immediately, so that removing a shared grant does not silently leave the
* role rows claiming access.
*/
readonly displayRows = computed(() => {
const grantedByDefault = this.grantedByDefault();
return this.rows().map((row) =>
row.isDefaultRow ? row : this.applyDefaultGrants(row, grantedByDefault),
);
});
private applyDefaultGrants(
row: RolePermissionRow,
grantedByDefault: ReadonlySet<CrudAction>,
): RolePermissionRow {
if (!row.editable) {
// an advanced rule decides this row as a whole, `_default` changes nothing
return row;
}
return {
...row,
cells: row.cells.map((cell) =>
cell.editable && grantedByDefault.has(cell.action)
? {
...cell,
allowed: true,
editable: false,
lockTooltip: grantedByDefaultRoleTooltip(),
}
: cell,
),
};
}
readonly saving = signal(false);
private async loadPermissionRows(): Promise<FeaturePermissionRows> {
const descriptions = await this.loadRoles();
const roleNames = [...descriptions.keys()];
if (roleNames.length === 0) {
throw new Error("no user roles available to configure");
}
const state = await this.permissionService.getPermissions(
this.entityType,
roleNames,
);
return {
rows: [
this.toRow(
state.defaultRules,
DEFAULT_ROLE.label,
true,
DEFAULT_ROLE.appliesTo,
),
...state.roles.map((role) =>
this.toRow(role, role.role, false, descriptions.get(role.role)),
),
],
};
}
private toRow(
permission: RoleFeaturePermission,
label: string,
isDefaultRow: boolean,
description?: string,
): RolePermissionRow {
return {
role: permission.role,
label,
description,
isDefaultRow,
editable: permission.editable,
// a whole row is only ever read-only because of an advanced rule - what
// `_default` grants locks single checkboxes, never the `_default` row itself
lockTooltip: permission.editable ? "" : grantedByAdvancedRuleTooltip(),
cells: this.actionColumns.map((column) =>
this.toCell(column, permission.actions[column.action], label),
),
};
}
private toCell(
column: CrudActionColumn,
permission: FeatureActionPermission,
rowLabel: string,
): PermissionCell {
const { lockedByAdvancedRule } = permission;
return {
action: column.action,
// a checkbox an uneditable rule decides shows that rule's effect; an
// editable one shows the row's own grant, so that unticking `_default`
// in `displayRows` reveals it again instead of discarding it
allowed: lockedByAdvancedRule
? permission.allowed
: permission.ownAllowed,
ownAllowed: permission.ownAllowed,
editable: !lockedByAdvancedRule,
lockTooltip: lockedByAdvancedRule ? grantedByAdvancedRuleTooltip() : "",
ariaLabel: $localize`:Permission checkbox aria label:${column.label} ${this.entityLabel} as ${rowLabel}`,
};
}
/**
* Collect the roles to display as a name -> description map. Roles known to the
* auth server (Keycloak) provide a human-readable description; roles already
* present in the permissions config are merged in (without a description) so the
* dialog still works when the Keycloak admin API is not reachable.
*/
private async loadRoles(): Promise<Map<string, string | undefined>> {
const roles = new Map<string, string | undefined>();
try {
const allRoles = await firstValueFrom(
this.userAdminService.getAllRoles(),
);
for (const role of allRoles) {
roles.set(role.name, role.description || undefined);
}
} catch (error) {
Logging.debug(
"Could not load roles from auth server, using config",
error,
);
}
const configuredRoles =
await this.permissionService.getConfiguredRoleNames();
for (const role of configuredRoles) {
if (!roles.has(role)) {
roles.set(role, undefined);
}
}
return roles;
}
/** Update a single checkbox, replacing the array so OnPush re-renders. */
setAction(role: string, action: CrudAction, checked: boolean): void {
this.rows.update((rows) =>
rows.map((row) =>
row.role === role
? {
...row,
cells: row.cells.map((cell) =>
cell.action === action ? { ...cell, allowed: checked } : cell,
),
}
: row,
),
);
}
async confirm(): Promise<void> {
this.saving.set(true);
try {
// only editable rows are persisted (the `_default` row included); rows
// decided by an advanced rule are display-only
const updates = this.rows()
.filter((row) => row.editable)
.map((row) => ({
role: row.role,
actions: Object.fromEntries(
CRUD_ACTIONS.map((action) => [
action,
row.cells.some(
(cell) =>
cell.action === action &&
// a checkbox decided by an uneditable rule keeps whatever the
// row itself grants, rather than what that rule displays
(cell.editable ? cell.allowed : cell.ownAllowed),
),
]),
) as Record<CrudAction, boolean>,
}));
const backup = await this.permissionService.setPermissions(
this.entityType,
updates,
);
this.permissionsConfig.offerUndo(
backup,
$localize`Permissions for "${this.entityLabel}" updated`,
);
this.dialogRef.close(true);
} catch (error) {
Logging.error("Failed to save feature permissions", error);
this.snackBar.open(
$localize`Could not save permissions. Please try again.`,
undefined,
{ duration: 5000 },
);
} finally {
this.saving.set(false);
}
}
}
<h2 mat-dialog-title i18n="Feature permission dialog title">
Permissions for {{ entityLabel }}
</h2>
<app-dialog-close mat-dialog-close></app-dialog-close>
<mat-dialog-content>
@if (permissionRows.isLoading()) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else if (loadFailed()) {
<app-hint-box type="warning">
<span i18n>
Could not load the list of user roles. Please try again later or edit
permissions in the user role administration.
</span>
</app-hint-box>
} @else {
<app-hint-box class="permission-hint">
<p i18n="Feature permission dialog explanation">
Select which user roles can use or manage this feature.
</p>
<p i18n="Feature permission dialog read hint">
Give at least <strong>Read</strong> permissions, if users should be able
to use the feature (i.e. create files).
</p>
<a
mat-button
color="accent"
[routerLink]="rolesAdminRoute"
mat-dialog-close
i18n="Manage user roles link"
>Manage user roles</a
>
</app-hint-box>
<table class="permission-table full-width">
<thead>
<tr>
<th i18n="Role column header">Role</th>
@for (column of actionColumns; track column.action) {
<th class="checkbox-col">{{ column.label }}</th>
}
</tr>
</thead>
<tbody>
@for (row of displayRows(); track row.role) {
<tr
[class.readonly-row]="!row.editable"
[class.default-row]="row.isDefaultRow"
>
<td>
<strong>{{ row.label }}</strong>
@if (!row.editable) {
<fa-icon
icon="lock"
class="readonly-icon"
[matTooltip]="row.lockTooltip"
></fa-icon>
}
@if (row.description) {
<span class="role-description">{{ row.description }}</span>
}
</td>
@for (cell of row.cells; track cell.action) {
<td class="checkbox-col">
<app-permission-checkbox
[state]="cell"
[disabled]="saving()"
[ariaLabel]="cell.ariaLabel"
(toggled)="setAction(row.role, cell.action, $event)"
></app-permission-checkbox>
</td>
}
</tr>
} @empty {
<tr>
<td
[attr.colspan]="actionColumns.length + 1"
class="text-secondary"
i18n
>
No user roles available to configure.
</td>
</tr>
}
</tbody>
</table>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button mat-dialog-close i18n="Cancel button">Cancel</button>
<button
mat-raised-button
color="accent"
(click)="confirm()"
[disabled]="permissionRows.isLoading() || loadFailed() || saving()"
i18n="Confirm button"
>
Confirm
</button>
</mat-dialog-actions>
./feature-permission-dialog.component.scss
@use "variables/colors";
.permission-hint p {
margin: 0;
}
.permission-hint a {
margin-top: 8px;
}
.permission-table {
border-collapse: collapse;
margin-top: 8px;
th,
td {
padding: 8px 12px;
text-align: start;
}
thead th {
border-bottom: 1px solid colors.$border-color;
}
tbody tr:not(:last-child) td {
border-bottom: 1px solid colors.$border-color;
}
.checkbox-col {
text-align: center;
width: 90px;
}
// the shared `_default` section is listed above the roles it applies to
.default-row td {
border-bottom: 2px solid colors.$border-color;
}
.default-row strong {
font-style: italic;
}
.readonly-row {
opacity: 0.65;
}
.readonly-icon {
margin-inline-start: 6px;
font-size: 0.85em;
opacity: 0.7;
}
.role-description {
display: block;
font-size: 0.8em;
opacity: 0.7;
}
}