src/app/core/admin/admin-role-permissions/permission-matrix/permission-matrix.component.ts

Description

Display a role's permission rules as a matrix of record types (rows) and actions (columns). Conditions restricting an action are shown as a readable summary under the record type.

Example

Metadata

Relationships

Depends on

Index

Properties
Methods
Inputs
Outputs

Inputs

editable
Type : boolean
Default value : false
inheritedRules
Type : DatabaseRule[]
Default value : []

Rules of the shared "_default" role, which apply to every logged-in user on top of their own roles. Shown as already granted here, because revoking them for a single role would require an inverted rule.

model
Type : MatrixModel
Required :  true
roleName
Type : string
Default value : ""

Outputs

modelChange
Type : MatrixModel

Methods

Async addSubject
addSubject(selected: string | string[])
Parameters :
Name Type Optional
selected string | string[] No
Returns : any
openConditionDialog
openConditionDialog(rowIndex: number, action: EntityActionPermission)
Parameters :
Name Type Optional
rowIndex number No
action EntityActionPermission No
Returns : void
removeCondition
removeCondition(rowIndex: number, action: EntityActionPermission)

clear the condition of an action, keeping the action itself allowed

Parameters :
Name Type Optional
rowIndex number No
action EntityActionPermission No
Returns : void
removeRow
removeRow(rowIndex: number)
Parameters :
Name Type Optional
rowIndex number No
Returns : void
setCellAllowed
setCellAllowed(rowIndex: number, action: EntityActionPermission, allowed: boolean)
Parameters :
Name Type Optional
rowIndex number No
action EntityActionPermission No
allowed boolean No
Returns : void
setManage
setManage(rowIndex: number, checked: boolean)

"Manage (all)" is the CASL wildcard action: it grants every action (including any beyond the four listed). It is a permission of its own, not derived from the individual action checkboxes, so toggling it does not add or remove the individual action rules.

Parameters :
Name Type Optional
rowIndex number No
checked boolean No
Returns : void

Properties

Readonly addPickerOpen
Type : unknown
Default value : signal(false)

whether the record-type picker is shown instead of the "Add Permission" button

Readonly addSelectVisible
Type : unknown
Default value : signal(true)

Toggled off-then-on after each selection to force Angular to destroy and re-create the record-type dropdown. app-entity-type-select keeps its chosen value in internal state that rebinding [value] does not clear, so a remount is the reliable way to reset it to empty for the next add.

Readonly crudActions
Type : CrudAction[]
Default value : this.crudColumns.map((c) => c.key)
Readonly crudColumns
Type : { key: CrudAction; label: string }[]
Default value : [ { key: "read", label: $localize`Read` }, { key: "create", label: $localize`Create` }, { key: "update", label: $localize`Update` }, { key: "delete", label: $localize`Delete` }, ]

CRUD columns with their headers baked in, so the template needs no per-cell method call

Readonly defaultRole
Type : unknown
Default value : DEFAULT_ROLE

name, description and icon of the "_default" role, shared with the other permission views

Readonly defaultRoleLink
Type : []
Default value : [ROLES_ADMIN_ROUTE, DEFAULT_ROLE.key]

details link of the "_default" role, named in the note below the matrix

Readonly displayedColumns
Type : []
Default value : [ "subject", "read", "create", "update", "delete", "manage", "rowActions", ]
Readonly existingSubjects
Type : unknown
Default value : computed(() => this.model().rows.map((r) => r.subject), )

record types already listed, so the add dropdown can omit them

Readonly hasAllSubject
Type : unknown
Default value : computed(() => this.model().rows.some((r) => r.subject === "all"), )

whether the "all" wildcard row is already present (drives the add options)

Readonly inheritsFromDefaultRole
Type : unknown
Default value : computed(() => inheritsDefaultRules(this.roleName()), )

Whether users of this role also receive the shared "_default" rules. Also drives the note below the matrix, which must not appear on roles that do not inherit them.

Readonly manageColLabel
Type : unknown
Default value : $localize`Manage (all)`
Readonly viewRows
Type : unknown
Default value : computed(() => { const rows = this.model().rows.map((row, modelIndex) => this.toViewRow(row, modelIndex), ); const defaultRow = this.defaultViewRow(); return defaultRow ? [defaultRow, ...rows] : rows; })

rows with subject label/icon and per-action cell states, resolved once per model change. The shared "_default" role is prepended as a read-only row, so it is visible what every logged-in user may do on top of this role.

import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  input,
  output,
  signal,
} from "@angular/core";
import { MatButtonModule } from "@angular/material/button";
import { MatCheckboxModule } from "@angular/material/checkbox";
import { MatDialog } from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatTableModule } from "@angular/material/table";
import { MatTooltipModule } from "@angular/material/tooltip";
import { RouterLink } from "@angular/router";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";

import { asArray } from "#src/app/utils/asArray";

import { ConfirmationDialogService } from "../../../common-components/confirmation-dialog/confirmation-dialog.service";
import { FaDynamicIconComponent } from "../../../common-components/fa-dynamic-icon/fa-dynamic-icon.component";
import { describeConditionFragment } from "../../../common-components/entity-form/dynamic-form-validators/permission-condition-validators";
import { HintBoxComponent } from "../../../common-components/hint-box/hint-box.component";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { EntityTypeSelectComponent } from "../../../entity/entity-type-select/entity-type-select.component";
import {
  PermissionConditionDialogComponent,
  PermissionConditionDialogData,
} from "../condition-dialog/permission-condition-dialog.component";
import {
  DatabaseRule,
  DEFAULT_SECTION_KEY,
  EntityActionPermission,
  inheritsDefaultRules,
} from "../../../permissions/permission-types";
import { DEFAULT_ROLE } from "../../../permissions/reserved-roles";
import { MatrixModel, MatrixRow, RuleConditions } from "../permission-matrix";
import { ROLES_ADMIN_ROUTE } from "../role-permissions.service";

/** the four individual CRUD actions shown as their own matrix columns ("manage" is separate) */
type CrudAction = "read" | "create" | "update" | "delete";

/**
 * Where an action is granted from, if not by an own rule of its row:
 * the row's own "manage", the "all record types" row of this same role,
 * or the shared "_default" role that applies to every logged-in user.
 */
type GrantedBy = "manage" | "wildcard" | "default";

/** display state of one action cell */
interface CellState {
  /** shown as granted, either by an own rule of this row or by a broader one */
  allowed: boolean;
  /** granted by an own rule of this row, so a condition can be attached to it */
  ownAllowed: boolean;
  /** whether the checkbox may be changed on this row */
  editable: boolean;
  hasCondition: boolean;
  /** readable summary of the condition, empty when none */
  summary: string;
  /** why the checkbox cannot be changed; empty when it is editable */
  lockTooltip: string;
  /**
   * id of the hidden element repeating {@link lockTooltip} for screen readers,
   * which do not announce the tooltip of a checkbox they cannot change.
   * Empty when the cell is editable.
   */
  lockDescriptionId: string;
}

/**
 * Stable id of the hidden element describing why a cell's checkbox is locked.
 * Derived from subject and action rather than a row index, so it stays the same
 * when rows are added or removed.
 */
function lockDescriptionId(
  subject: string,
  action: EntityActionPermission,
): string {
  return `perm-lock-${subject}-${action}`;
}

/**
 * Display a role's permission rules as a matrix of
 * record types (rows) and actions (columns).
 * Conditions restricting an action are shown as a readable summary under the record type.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-permission-matrix",
  imports: [
    MatTableModule,
    MatCheckboxModule,
    MatButtonModule,
    MatTooltipModule,
    MatFormFieldModule,
    FaIconComponent,
    FaDynamicIconComponent,
    HintBoxComponent,
    EntityTypeSelectComponent,
    RouterLink,
  ],
  templateUrl: "./permission-matrix.component.html",
  styleUrl: "./permission-matrix.component.scss",
})
export class PermissionMatrixComponent {
  private readonly entityRegistry = inject(EntityRegistry);
  private readonly dialog = inject(MatDialog);
  private readonly confirmation = inject(ConfirmationDialogService);

  readonly model = input.required<MatrixModel>();
  readonly editable = input(false);
  readonly roleName = input("");

  /**
   * Rules of the shared "_default" role, which apply to every logged-in user on
   * top of their own roles. Shown as already granted here, because revoking them
   * for a single role would require an inverted rule.
   */
  readonly inheritedRules = input<DatabaseRule[]>([]);
  readonly modelChange = output<MatrixModel>();

  /** CRUD columns with their headers baked in, so the template needs no per-cell method call */
  readonly crudColumns: { key: CrudAction; label: string }[] = [
    { key: "read", label: $localize`Read` },
    { key: "create", label: $localize`Create` },
    { key: "update", label: $localize`Update` },
    { key: "delete", label: $localize`Delete` },
  ];
  readonly crudActions: CrudAction[] = this.crudColumns.map((c) => c.key);
  readonly manageColLabel = $localize`Manage (all)`;

  // rowActions column is always present (empty in view mode)
  // so that column positions do not shift when toggling edit mode
  readonly displayedColumns = [
    "subject",
    "read",
    "create",
    "update",
    "delete",
    "manage",
    "rowActions",
  ];

  /** the "all record types" row of this role, which grants its actions for every type */
  private readonly wildcardRow = computed(() =>
    this.model().rows.find((row) => row.subject === "all"),
  );

  /**
   * rows with subject label/icon and per-action cell states, resolved once per
   * model change. The shared "_default" role is prepended as a read-only row,
   * so it is visible what every logged-in user may do on top of this role.
   */
  readonly viewRows = computed(() => {
    const rows = this.model().rows.map((row, modelIndex) =>
      this.toViewRow(row, modelIndex),
    );
    const defaultRow = this.defaultViewRow();
    return defaultRow ? [defaultRow, ...rows] : rows;
  });

  private toViewRow(row: MatrixRow, modelIndex: number) {
    // "manage" grants every action, so the individual actions are shown as
    // covered (checked, not individually editable) when it is set
    const manageAllowed = !!row.cells.manage?.allowed;
    return {
      row,
      /** index in the edited model; -1 for the read-only "Default" row */
      modelIndex,
      isDefaultRow: false,
      label: this.subjectLabel(row.subject),
      icon: this.subjectIcon(row.subject),
      isInternal: this.isInternalSubject(row.subject),
      conditionsEditable: this.canHaveConditions(row.subject),
      manageAllowed,
      manageState: this.cellState(row, "manage", manageAllowed),
      actionStates: Object.fromEntries(
        this.crudActions.map((action) => [
          action,
          this.cellState(row, action, manageAllowed),
        ]),
      ) as Record<CrudAction, CellState>,
    };
  }

  /**
   * The shared "_default" role as a read-only row, listing what it grants for
   * every record type. Absent while editing that role itself and when it grants
   * nothing across all record types.
   */
  private readonly defaultViewRow = computed(() => {
    if (!this.inheritsFromDefaultRole()) {
      return undefined;
    }

    // only what the default role grants for every record type is shown here;
    // grants for a single type lock that type's row instead
    const cells: MatrixRow["cells"] = {};
    for (const action of [...this.crudActions, "manage" as const]) {
      if (this.inheritedGrantsForAllTypes(action)) {
        cells[action] = { allowed: true };
      }
    }
    if (Object.keys(cells).length === 0) {
      // nothing granted to everyone, so the row would only add noise
      return undefined;
    }

    const row: MatrixRow = { subject: DEFAULT_SECTION_KEY, cells };
    const manageAllowed = !!cells.manage?.allowed;
    return {
      row,
      modelIndex: -1,
      isDefaultRow: true,
      label: this.defaultRole.label,
      icon: undefined,
      isInternal: false,
      conditionsEditable: false,
      manageAllowed,
      manageState: this.defaultRowCellState("manage", manageAllowed),
      actionStates: Object.fromEntries(
        this.crudActions.map((action) => [
          action,
          this.defaultRowCellState(
            action,
            manageAllowed || !!cells[action]?.allowed,
          ),
        ]),
      ) as Record<CrudAction, CellState>,
    };
  });

  private defaultRowCellState(
    action: EntityActionPermission,
    allowed: boolean,
  ): CellState {
    return {
      allowed,
      ownAllowed: false,
      editable: false,
      hasCondition: false,
      summary: "",
      lockTooltip: $localize`:Default permissions row tooltip:These permissions apply to every logged-in user, in addition to their roles. They can only be changed in the "${this.defaultRole.label}" role.`,
      lockDescriptionId: lockDescriptionId(DEFAULT_SECTION_KEY, action),
    };
  }

  private cellState(
    row: MatrixRow,
    action: EntityActionPermission,
    manageAllowed: boolean,
  ): CellState {
    const cell = row.cells[action];
    const grantedBy = this.grantedBy(row, action, manageAllowed);
    return {
      allowed: !!cell?.allowed || !!grantedBy,
      ownAllowed: !!cell?.allowed,
      editable: !grantedBy,
      hasCondition: !!cell?.conditions,
      summary: cell?.conditions
        ? this.describeConditions(cell.conditions, row.subject)
        : "",
      lockTooltip: grantedBy ? this.grantedByTooltip(grantedBy) : "",
      lockDescriptionId: grantedBy
        ? lockDescriptionId(row.subject, action)
        : "",
    };
  }

  /**
   * Whether a broader rule already grants this action, so it cannot be taken
   * away on this row: the row's own "manage", the role's "all record types"
   * row, or the shared "_default" role.
   */
  private grantedBy(
    row: MatrixRow,
    action: EntityActionPermission,
    manageAllowed: boolean,
  ): GrantedBy | undefined {
    if (manageAllowed && action !== "manage") {
      return "manage";
    }
    if (row.subject !== "all" && this.rowGrants(this.wildcardRow(), action)) {
      return "wildcard";
    }
    if (this.inheritedGrants(row.subject, action)) {
      return "default";
    }
    return undefined;
  }

  private grantedByTooltip(grantedBy: GrantedBy): string {
    switch (grantedBy) {
      case "manage":
        return $localize`Already granted by "Manage (all)" for this record type.`;
      case "wildcard":
        return $localize`Already granted by the "All record types" row of this role.`;
      case "default":
        return $localize`Already granted to every logged-in user by the "${this.defaultRole.label}" role, so it cannot be revoked for a single role here.`;
    }
  }

  /** whether a matrix row grants the action, directly or through its "manage" */
  private rowGrants(
    row: MatrixRow | undefined,
    action: EntityActionPermission,
  ): boolean {
    if (!row) return false;
    return !!row.cells.manage?.allowed || !!row.cells[action]?.allowed;
  }

  /** whether the shared "_default" rules grant the action for every record type */
  private inheritedGrantsForAllTypes(action: EntityActionPermission): boolean {
    return this.inheritedGrants("all", action);
  }

  /** whether the shared "_default" rules grant the action for this record type */
  private inheritedGrants(
    subject: string,
    action: EntityActionPermission,
  ): boolean {
    if (!this.inheritsFromDefaultRole()) {
      return false;
    }
    return this.inheritedRules().some((rule) => {
      if (rule.inverted) return false;
      // a conditional default rule only grants the action for some records, so
      // it must not lock the checkbox as if the action were granted outright
      if (rule.conditions && Object.keys(rule.conditions).length > 0) {
        return false;
      }
      const subjects = asArray(rule.subject);
      // the wildcard row itself is only covered by a default rule that applies
      // to every record type, not by one for a single type
      const matchesSubject =
        subjects.includes("all") ||
        (subject !== "all" && subjects.includes(subject));
      const actions = asArray(rule.action);
      return (
        matchesSubject &&
        (actions.includes(action) || actions.includes("manage"))
      );
    });
  }

  /** whether the "all" wildcard row is already present (drives the add options) */
  readonly hasAllSubject = computed(() =>
    this.model().rows.some((r) => r.subject === "all"),
  );

  /** record types already listed, so the add dropdown can omit them */
  readonly existingSubjects = computed(() =>
    this.model().rows.map((r) => r.subject),
  );

  /**
   * Whether users of this role also receive the shared "_default" rules.
   * Also drives the note below the matrix, which must not appear on roles
   * that do not inherit them.
   */
  readonly inheritsFromDefaultRole = computed(() =>
    inheritsDefaultRules(this.roleName()),
  );

  /** name, description and icon of the "_default" role, shared with the other permission views */
  readonly defaultRole = DEFAULT_ROLE;

  /** details link of the "_default" role, named in the note below the matrix */
  readonly defaultRoleLink = [ROLES_ADMIN_ROUTE, DEFAULT_ROLE.key];

  /** human-readable summary of a CASL conditions object, e.g. "Center: Alipore and Gender: male" */
  private describeConditions(
    conditions: RuleConditions,
    subject: string,
  ): string {
    if (!conditions || typeof conditions !== "object") return "";
    const ctor = this.entityRegistry.has(subject)
      ? this.entityRegistry.get(subject)
      : undefined;
    const fieldLabel = (key: string) => ctor?.schema.get(key)?.label ?? key;
    const describeObject = (obj: unknown): string =>
      typeof obj === "object" && obj !== null
        ? Object.entries(obj)
            .map(
              ([key, value]) =>
                `${fieldLabel(key)}: ${describeConditionFragment(value)}`,
            )
            .join($localize` and `)
        : "";

    const query = conditions as Record<string, unknown>;
    if (Array.isArray(query.$or)) {
      return query.$or.map(describeObject).join($localize` or `);
    }
    if (Array.isArray(query.$and)) {
      return query.$and.map(describeObject).join($localize` and `);
    }
    return describeObject(conditions);
  }

  private subjectLabel(subject: string): string {
    if (subject === "all") {
      return $localize`All record types`;
    }
    if (this.entityRegistry.has(subject)) {
      // internal types have no user-facing label; prettify their raw key
      // (e.g. "ConfigurableEnum" -> "Configurable Enum") so it stays readable
      return (
        this.entityRegistry.get(subject).label ?? this.prettifyKey(subject)
      );
    }
    return subject;
  }

  private prettifyKey(key: string): string {
    return key
      .replace(/[_-]+/g, " ")
      .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
      .replace(/\s+/g, " ")
      .trim();
  }

  private subjectIcon(subject: string): string | undefined {
    return this.entityRegistry.has(subject)
      ? this.entityRegistry.get(subject).icon
      : undefined;
  }

  /**
   * Internal/system entity types are only defined in code to store system data
   * and are not meant for user customization (they carry no user-facing label).
   * They are shown greyed out to signal that permissions on them are advanced.
   */
  private isInternalSubject(subject: string): boolean {
    if (subject === "all" || !this.entityRegistry.has(subject)) {
      return false;
    }
    const type = this.entityRegistry.get(subject);
    return !!type.isInternalEntity || !type.label;
  }

  setCellAllowed(
    rowIndex: number,
    action: EntityActionPermission,
    allowed: boolean,
  ) {
    this.emitUpdated((m) => {
      const cells = m.rows[rowIndex].cells;
      if (allowed) {
        cells[action] = { allowed: true };
      } else {
        delete cells[action];
      }
    });
  }

  /**
   * "Manage (all)" is the CASL wildcard action: it grants every action
   * (including any beyond the four listed). It is a permission of its own,
   * not derived from the individual action checkboxes, so toggling it does
   * not add or remove the individual action rules.
   */
  setManage(rowIndex: number, checked: boolean) {
    if (!checked) {
      this.setCellAllowed(rowIndex, "manage", false);
      return;
    }

    this.emitUpdated((m) => {
      const cells = m.rows[rowIndex].cells;
      cells.manage = { allowed: true };
      // "manage" already implies the individual actions, so drop the plain ones:
      // otherwise they silently stay behind when "manage" is removed again.
      // Cells carrying a condition or properties this matrix does not model
      // (e.g. a rule managed by the backend) are kept untouched.
      for (const action of this.crudActions) {
        const cell = cells[action];
        if (cell && !cell.conditions && !cell.extra) {
          delete cells[action];
        }
      }
    });
  }

  removeRow(rowIndex: number) {
    this.emitUpdated((m) => m.rows.splice(rowIndex, 1));
  }

  /** clear the condition of an action, keeping the action itself allowed */
  removeCondition(rowIndex: number, action: EntityActionPermission) {
    this.emitUpdated((m) => {
      const cell = m.rows[rowIndex].cells[action];
      if (cell) delete cell.conditions;
    });
  }

  /**
   * Conditions can only be edited visually for entity types
   * that have user-facing fields to define conditions on.
   */
  private canHaveConditions(subject: string): boolean {
    if (subject === "all" || !this.entityRegistry.has(subject)) {
      return false;
    }
    const schema = this.entityRegistry.get(subject).schema;
    return [...schema.values()].some(
      (field) => !field.isInternalField && !!field.label,
    );
  }

  openConditionDialog(rowIndex: number, action: EntityActionPermission) {
    const row = this.model().rows[rowIndex];
    this.dialog
      .open(PermissionConditionDialogComponent, {
        width: "600px",
        data: {
          roleName: this.roleName(),
          action,
          subject: row.subject,
          conditions: row.cells[action]?.conditions,
        } satisfies PermissionConditionDialogData,
      })
      .afterClosed()
      .subscribe((result) => {
        // the dialog only returns a real result on Apply (a conditions object)
        // or "Remove condition" (null); cancelling / closing (undefined or the
        // shared close button's empty string) must leave the cell untouched
        if (result !== null && typeof result !== "object") return;
        this.emitUpdated((m) => {
          // keep any unmodelled properties (e.g. reason) that the cell carried
          const { extra } = m.rows[rowIndex].cells[action] ?? {};
          m.rows[rowIndex].cells[action] = {
            allowed: true,
            ...(result ? { conditions: result } : {}),
            ...(extra ? { extra } : {}),
          };
        });
      });
  }

  /** whether the record-type picker is shown instead of the "Add Permission" button */
  readonly addPickerOpen = signal(false);

  /**
   * Toggled off-then-on after each selection to force Angular to destroy and
   * re-create the record-type dropdown. `app-entity-type-select` keeps its
   * chosen value in internal state that rebinding `[value]` does not clear,
   * so a remount is the reliable way to reset it to empty for the next add.
   */
  readonly addSelectVisible = signal(true);

  async addSubject(selected: string | string[]) {
    const subject = Array.isArray(selected) ? selected[0] : selected;
    this.addPickerOpen.set(false);
    this.resetAddSelect();
    if (!subject || this.model().rows.some((r) => r.subject === subject)) {
      return;
    }

    if (subject === "all") {
      const confirmed = await this.confirmation.getConfirmation(
        $localize`Add permissions for all record types?`,
        $localize`Whatever you allow here applies to every record type, also to types added later. Those actions can then no longer be revoked for an individual record type.`,
      );
      if (confirmed !== true) {
        return;
      }
    }
    // every new row starts with read only, including the "all" wildcard row:
    // a wildcard may grant just some actions, with the remaining ones added
    // per record type
    const cells: MatrixRow["cells"] = { read: { allowed: true } };
    this.emitUpdated((m) => m.rows.push({ subject, cells }));
  }

  private resetAddSelect() {
    this.addSelectVisible.set(false);
    setTimeout(() => this.addSelectVisible.set(true));
  }

  private emitUpdated(mutate: (model: MatrixModel) => void) {
    const updated = structuredClone(this.model());
    mutate(updated);
    this.modelChange.emit(updated);
  }
}
@if (model().unsupportedRules.length > 0) {
  <app-hint-box i18n>
    {model().unsupportedRules.length, plural,
      one {1 advanced rule is}
      other {{{ model().unsupportedRules.length }} advanced rules are}
    }
    not shown in this matrix and only editable via JSON.
  </app-hint-box>
}

<div class="table-container">
  <div class="table-scroll">
    <table mat-table [dataSource]="viewRows()" class="full-width table">
      <ng-container matColumnDef="subject">
        <th mat-header-cell *matHeaderCellDef class="subject-col" i18n>
          Record type
        </th>
        <td mat-cell *matCellDef="let v" class="subject-col">
          @if (v.isDefaultRow) {
            <div class="flex-row align-center gap-regular">
              <div class="wildcard-badge">
                <fa-icon [icon]="defaultRole.icon"></fa-icon>
              </div>
              <div>
                <div>{{ v.label }}</div>
                <div class="text-secondary wildcard-subtitle">
                  {{ defaultRole.appliesTo }}
                </div>
              </div>
            </div>
          } @else if (v.row.subject === "all") {
            <div class="flex-row align-center gap-regular">
              <div class="wildcard-badge">
                <fa-icon icon="asterisk"></fa-icon>
              </div>
              <div>
                <div>{{ v.label }}</div>
                <div class="text-secondary wildcard-subtitle" i18n>
                  applies to every record type, also to types added later
                </div>
              </div>
            </div>
          } @else {
            <div
              class="flex-row align-center gap-small"
              [class.text-secondary]="v.isInternal"
            >
              @if (v.icon) {
                <app-fa-dynamic-icon [icon]="v.icon"></app-fa-dynamic-icon>
              }
              <span>{{ v.label }}</span>
              @if (v.isInternal) {
                <fa-icon
                  icon="lock"
                  class="internal-type-icon"
                  i18n-matTooltip
                  matTooltip="Internal system type, not meant for regular customization"
                ></fa-icon>
              }
            </div>
          }
        </td>
      </ng-container>

      @for (col of crudColumns; track col.key) {
        <ng-container [matColumnDef]="col.key">
          <th mat-header-cell *matHeaderCellDef class="action-col">
            {{ col.label }}
          </th>
          <td mat-cell *matCellDef="let v" class="action-col">
            <div class="cell-stack">
              <!-- the tooltip sits on the checkbox itself (not a wrapper), so
                   "disabledInteractive" keeps it hoverable and focusable while
                   disabled; the hidden description makes the same reason
                   available to screen readers -->
              <mat-checkbox
                [checked]="v.actionStates[col.key].allowed"
                [disabled]="!editable() || !v.actionStates[col.key].editable"
                [disabledInteractive]="!!v.actionStates[col.key].lockTooltip"
                [matTooltip]="v.actionStates[col.key].lockTooltip"
                [matTooltipDisabled]="!v.actionStates[col.key].lockTooltip"
                [attr.aria-label]="col.label + ' – ' + v.label"
                [aria-describedby]="
                  v.actionStates[col.key].lockDescriptionId || null
                "
                (change)="setCellAllowed(v.modelIndex, col.key, $event.checked)"
              ></mat-checkbox>
              @if (v.actionStates[col.key].lockTooltip) {
                <span
                  class="visually-hidden"
                  [id]="v.actionStates[col.key].lockDescriptionId"
                  >{{ v.actionStates[col.key].lockTooltip }}</span
                >
              }

              @if (v.actionStates[col.key].hasCondition) {
                <!-- condition chip: editable only for subjects whose conditions
                     can be edited visually, read-only otherwise -->
                <span
                  class="cell-condition-chip"
                  [class.readonly]="
                    !(
                      editable() &&
                      v.conditionsEditable &&
                      v.actionStates[col.key].editable
                    )
                  "
                  [attr.role]="
                    editable() &&
                    v.conditionsEditable &&
                    v.actionStates[col.key].editable
                      ? 'button'
                      : null
                  "
                  [attr.tabindex]="
                    editable() &&
                    v.conditionsEditable &&
                    v.actionStates[col.key].editable
                      ? 0
                      : null
                  "
                  [matTooltip]="v.actionStates[col.key].summary"
                  (click)="
                    editable() &&
                      v.conditionsEditable &&
                      openConditionDialog(v.modelIndex, col.key)
                  "
                  (keydown.enter)="
                    editable() &&
                      v.conditionsEditable &&
                      openConditionDialog(v.modelIndex, col.key)
                  "
                  (keydown.space)="
                    $event.preventDefault();
                    editable() &&
                      v.conditionsEditable &&
                      openConditionDialog(v.modelIndex, col.key)
                  "
                >
                  <fa-icon icon="filter"></fa-icon>
                  <span class="cell-condition-text">{{
                    v.actionStates[col.key].summary
                  }}</span>
                  @if (editable()) {
                    <fa-icon
                      icon="xmark"
                      class="cell-condition-remove"
                      role="button"
                      tabindex="0"
                      i18n-matTooltip
                      matTooltip="Remove condition"
                      (click)="
                        $event.stopPropagation();
                        removeCondition(v.modelIndex, col.key)
                      "
                      (keydown.enter)="
                        $event.stopPropagation();
                        removeCondition(v.modelIndex, col.key)
                      "
                      (keydown.space)="
                        $event.preventDefault();
                        $event.stopPropagation();
                        removeCondition(v.modelIndex, col.key)
                      "
                    ></fa-icon>
                  }
                </span>
              } @else if (
                editable() &&
                v.actionStates[col.key].ownAllowed &&
                v.actionStates[col.key].editable &&
                v.conditionsEditable
              ) {
                <button
                  class="cell-condition-link"
                  (click)="openConditionDialog(v.modelIndex, col.key)"
                >
                  <fa-icon icon="filter"></fa-icon>
                  <span i18n>only where&hellip;</span>
                </button>
              }
            </div>
          </td>
        </ng-container>
      }

      <ng-container matColumnDef="manage">
        <th mat-header-cell *matHeaderCellDef class="action-col manage-col">
          {{ manageColLabel }}
        </th>
        <td mat-cell *matCellDef="let v" class="action-col manage-col">
          <mat-checkbox
            [checked]="v.manageState.allowed"
            [disabled]="!editable() || !v.manageState.editable"
            [disabledInteractive]="!!v.manageState.lockTooltip"
            [matTooltip]="v.manageState.lockTooltip"
            [matTooltipDisabled]="!v.manageState.lockTooltip"
            [attr.aria-label]="manageColLabel + ' – ' + v.label"
            [aria-describedby]="v.manageState.lockDescriptionId || null"
            (change)="setManage(v.modelIndex, $event.checked)"
          ></mat-checkbox>
          @if (v.manageState.lockTooltip) {
            <span
              class="visually-hidden"
              [id]="v.manageState.lockDescriptionId"
              >{{ v.manageState.lockTooltip }}</span
            >
          }
        </td>
      </ng-container>

      <ng-container matColumnDef="rowActions">
        <th mat-header-cell *matHeaderCellDef class="row-actions-col"></th>
        <td mat-cell *matCellDef="let v" class="row-actions-col">
          @if (editable() && !v.isDefaultRow) {
            <button
              mat-icon-button
              (click)="removeRow(v.modelIndex)"
              i18n-matTooltip
              matTooltip="Remove all permissions for this record type"
            >
              <fa-icon icon="trash"></fa-icon>
            </button>
          }
        </td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
      <tr
        mat-row
        *matRowDef="let row; columns: displayedColumns"
        [class.wildcard-row]="row.row.subject === 'all'"
        [class.default-row]="row.isDefaultRow"
      ></tr>
    </table>
  </div>

  @if (viewRows().length === 0) {
    <div class="empty-state text-secondary padding-regular">
      @if (editable()) {
        <span i18n>No permissions yet. Add one below.</span>
      } @else {
        <!-- what such a role still inherits is stated in the note below,
             which is shown whether or not the matrix has any rows -->
        <span i18n>No permissions defined for this role yet.</span>
      }
    </div>
  }

  @if (editable()) {
    <div class="flex-row align-center gap-regular padding-regular">
      @if (!addPickerOpen()) {
        <button mat-button color="accent" (click)="addPickerOpen.set(true)">
          <fa-icon icon="plus" class="standard-icon-with-text"></fa-icon>
          <span i18n>Add Permission</span>
        </button>
      } @else {
        @if (addSelectVisible()) {
          <mat-form-field class="add-permission-field">
            <mat-label i18n>Add permission for record type</mat-label>
            <app-entity-type-select
              [value]="undefined"
              [showInternalTypes]="true"
              [hiddenTypes]="existingSubjects()"
              (valueChange)="addSubject($event)"
            ></app-entity-type-select>
          </mat-form-field>
        }

        @if (!hasAllSubject()) {
          <button mat-stroked-button (click)="addSubject('all')">
            <fa-icon icon="asterisk" class="standard-icon-with-text"></fa-icon>
            <span i18n>All record types (wildcard)</span>
          </button>
        }
      }
    </div>
  }

  @if (inheritsFromDefaultRole()) {
    <div class="table-footer-note text-secondary" i18n>
      Logged-in users also always have the permissions defined in the
      <a [routerLink]="defaultRoleLink">{{ defaultRole.key }}</a> role.
    </div>
  }
</div>

./permission-matrix.component.scss

@use "variables/colors";
@use "mixins/list-table";

// same container/table styling as the user accounts and roles lists
@include list-table.container-and-table;

// on small screens the table keeps its minimum width and scrolls horizontally
.table-scroll {
  overflow-x: auto;
}

.action-col {
  text-align: center;
}

// give each row vertical breathing room so a stacked condition chip
// does not sit flush against the row divider, and top-align every cell
// so the checkboxes line up across columns even when one column is taller
// (a condition chip / "only where…" affordance stacked under its checkbox)
td.mat-mdc-cell {
  padding-top: 10px;
  padding-bottom: 10px;
  vertical-align: top;
}

// visually separate the manage wildcard column from the individual actions
.manage-col {
  border-left: 1px solid colors.$grey-transparent;
}

// stacked cell: checkbox on top, condition affordance below.
// capped width so a long condition summary cannot overflow across the row.
.cell-stack {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  max-width: 150px;
}

// subtle "only where…" link revealed under a ticked cell
.cell-condition-link {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font-size: 11px;
  color: colors.$muted;
  display: inline-flex;
  align-items: center;
  gap: 4px;

  &:hover {
    color: colors.$accent;
  }
}

// chip showing the configured condition in plain words
.cell-condition-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  max-width: 140px;
  padding: 3px 8px;
  border-radius: 10px;
  font-size: 11px;
  background-color: colors.$background-secondary;
  color: colors.$accent;
  cursor: pointer;

  &:hover {
    filter: brightness(0.95);
  }

  // read-only (view mode): same look, not interactive
  &.readonly {
    cursor: default;

    &:hover {
      filter: none;
    }
  }
}

.cell-condition-text {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 100%;
}

.cell-condition-remove {
  cursor: pointer;
  opacity: 0.7;

  &:hover {
    opacity: 1;
  }
}

// internal/system entity types are greyed out via the global "text-secondary"
// class; only the small lock icon needs a local size tweak
.internal-type-icon {
  font-size: 11px;
  opacity: 0.7;
}

.wildcard-badge {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background-color: colors.$background-secondary;
  color: colors.$primary;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

.wildcard-subtitle {
  font-size: 12px;
}

.wildcard-row {
  background-color: rgba(0, 0, 0, 0.02);
}

.add-permission-field {
  width: 280px;
}

.empty-state {
  text-align: center;
}

// permanent footnote below the matrix, naming what every logged-in user
// additionally receives from the shared "_default" role
.table-footer-note {
  padding: 8px 16px;
  font-size: 12px;
  border-top: 1px solid colors.$grey-transparent;
}

/* the shared "_default" role, listed above the role's own rules */
.default-row {
  background: rgba(0, 0, 0, 0.02);
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""