src/app/core/permissions/feature-permission/feature-permission-dialog/feature-permission-dialog.component.ts

Description

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.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

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 :
Name Type Optional
role string No
action CrudAction No
checked boolean No
Returns : void

Properties

Readonly actionColumns
Type : unknown
Default value : CRUD_ACTION_COLUMNS

the checkbox columns, shared with the permission matrix of the role administration

Readonly displayRows
Type : unknown
Default value : computed(() => { const grantedByDefault = this.grantedByDefault(); return this.rows().map((row) => row.isDefaultRow ? row : this.applyDefaultGrants(row, grantedByDefault), ); })

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 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 permissionRows
Type : unknown
Default value : 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 }; } }, })
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 _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 displayRows only.

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;
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""