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

Description

one checkbox of a row, with everything the template needs precomputed

Extends

PermissionCellState

Relationships

Used by

No results matching.

Depends on

Index

Properties

Properties

action
action: CrudAction
Type : CrudAction
allowed
allowed: boolean
Type : boolean

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 FeaturePermissionDialogComponent.displayRows.

This is therefore not FeatureActionPermission.allowed, which always includes what _default grants as stored.

ariaLabel
ariaLabel: string
Type : string
editable
editable: boolean
Type : 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 FeatureActionPermission.lockedByAdvancedRule.

ownAllowed
ownAllowed: boolean
Type : boolean

The row's own grant as stored, taken over from 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.

lockTooltip
lockTooltip: string
Type : string

why the checkbox cannot be changed; empty when it is editable

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);
    }
  }
}

results matching ""

    No results matching ""