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

Description

Dialog to visually edit the conditions restricting one permission (e.g. "user_app can read Children only where center is X").

Closes with the new conditions object, null to remove all conditions or undefined when cancelled.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Methods

apply
apply()
Returns : void
cancel
cancel()
Returns : void
onConditionsChange
onConditionsChange(conditions: any)
Parameters :
Name Type Optional
conditions any No
Returns : void
removeConditions
removeConditions()
Returns : void

Properties

Readonly combinator
Type : unknown
Default value : signal<"any" | "all">( Array.isArray(this.data.conditions?.$or) ? "any" : "all", )

whether all rows must match ("all", mongo implicit and) or any row ("any", $or)

Readonly combinatorHint
Type : unknown
Default value : computed(() => this.combinator() === "any" ? $localize`Records match if any one of the conditions applies ("or" conditions).` : $localize`Records match only if all conditions apply ("and" conditions).`, )
Readonly conditionSentence
Type : string
Default value : this.buildConditionSentence()

Full " can only where…" sentence as a single localized message per action, so translators can reorder role/entity.

Readonly data
Type : PermissionConditionDialogData
Default value : inject(MAT_DIALOG_DATA)
editorConditions
Type : any
Default value : toEditorFormat(this.data.conditions)

Working state in the { $or: [...] } row format of the conditions editor. Deliberately not a signal: the conditions editor mutates this object in place and the template does not need to react to its changes.

Readonly entityConstructor
Type : EntityConstructor | undefined
Default value : this.entityRegistry.has(this.data.subject) ? this.entityRegistry.get(this.data.subject) : undefined
Readonly entityLabel
Type : string
Default value : this.entityConstructor?.label ?? this.data.subject
Readonly hadConditions
Type : unknown
Default value : !!this.data.conditions
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  inject,
  signal,
} from "@angular/core";
import { MatButtonModule } from "@angular/material/button";
import { MatButtonToggleModule } from "@angular/material/button-toggle";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import { MatTooltipModule } from "@angular/material/tooltip";

import { ConditionsEditorComponent } from "../../../common-components/conditions-editor/conditions-editor.component";
import { DialogCloseComponent } from "../../../common-components/dialog-close/dialog-close.component";
import { EntityRegistry } from "../../../entity/database-entity.decorator";
import { EntityConstructor } from "../../../entity/model/entity";
import { EntityActionPermission } from "../../../permissions/permission-types";
import { roleDisplayName } from "../../../permissions/reserved-roles";

export interface PermissionConditionDialogData {
  roleName: string;
  action: EntityActionPermission;
  subject: string;
  conditions?: any;
}

/**
 * Dialog to visually edit the conditions restricting one permission
 * (e.g. "user_app can read Children only where center is X").
 *
 * Closes with the new conditions object,
 * null to remove all conditions or undefined when cancelled.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-permission-condition-dialog",
  imports: [
    MatDialogModule,
    MatButtonModule,
    MatButtonToggleModule,
    MatTooltipModule,
    ConditionsEditorComponent,
    DialogCloseComponent,
  ],
  templateUrl: "./permission-condition-dialog.component.html",
})
export class PermissionConditionDialogComponent {
  private readonly dialogRef = inject(
    MatDialogRef<PermissionConditionDialogComponent>,
  );
  private readonly entityRegistry = inject(EntityRegistry);

  readonly data: PermissionConditionDialogData = inject(MAT_DIALOG_DATA);

  readonly entityConstructor: EntityConstructor | undefined =
    this.entityRegistry.has(this.data.subject)
      ? this.entityRegistry.get(this.data.subject)
      : undefined;

  /** whether all rows must match ("all", mongo implicit and) or any row ("any", $or) */
  readonly combinator = signal<"any" | "all">(
    Array.isArray(this.data.conditions?.$or) ? "any" : "all",
  );

  readonly hadConditions = !!this.data.conditions;

  /**
   * Working state in the { $or: [...] } row format of the conditions editor.
   * Deliberately not a signal: the conditions editor mutates this object in place
   * and the template does not need to react to its changes.
   */
  editorConditions: any = toEditorFormat(this.data.conditions);

  readonly entityLabel: string =
    this.entityConstructor?.label ?? this.data.subject;

  readonly combinatorHint = computed(() =>
    this.combinator() === "any"
      ? $localize`Records match if any one of the conditions applies ("or" conditions).`
      : $localize`Records match only if all conditions apply ("and" conditions).`,
  );

  /**
   * Full "<role> can <action> <entity> only where…" sentence as a single
   * localized message per action, so translators can reorder role/entity.
   */
  readonly conditionSentence: string = this.buildConditionSentence();

  private buildConditionSentence(): string {
    // reserved roles are referred to by their readable name (e.g. "Default")
    const role = roleDisplayName(this.data.roleName);
    const entity = this.entityLabel;
    switch (this.data.action) {
      case "read":
        return $localize`:permission condition sentence:${role} can read ${entity} only where…`;
      case "create":
        return $localize`:permission condition sentence:${role} can create ${entity} only where…`;
      case "update":
        return $localize`:permission condition sentence:${role} can update ${entity} only where…`;
      case "delete":
        return $localize`:permission condition sentence:${role} can delete ${entity} only where…`;
      case "manage":
        return $localize`:permission condition sentence:${role} can manage ${entity} only where…`;
    }
  }

  onConditionsChange(conditions: any) {
    this.editorConditions = conditions;
  }

  apply() {
    const rows: any[] = (this.editorConditions?.$or ?? []).filter(
      (row: any) =>
        row &&
        typeof row === "object" &&
        Object.keys(row).length > 0 &&
        Object.values(row).every((v) => v !== null && v !== undefined),
    );

    if (rows.length === 0) {
      this.dialogRef.close(null);
    } else if (this.combinator() === "any") {
      this.dialogRef.close({ $or: rows });
    } else {
      this.dialogRef.close(mergeToAllConditions(rows));
    }
  }

  removeConditions() {
    this.dialogRef.close(null);
  }

  cancel() {
    this.dialogRef.close(undefined);
  }
}

/**
 * Convert any stored conditions shape into the { $or: [...] } row format
 * that the conditions editor works with.
 *
 * Deep-copies the input so the editor (which mutates rows in place) cannot
 * touch the matrix model: cancelling the dialog must leave the model untouched.
 */
function toEditorFormat(conditions: any): any {
  if (!conditions || typeof conditions !== "object") {
    return {};
  }
  const copy = structuredClone(conditions);
  if (Array.isArray(copy.$or)) {
    return { $or: copy.$or };
  }
  if (Array.isArray(copy.$and)) {
    return { $or: copy.$and };
  }
  // merged plain object: one row per key
  return {
    $or: Object.entries(copy).map(([key, value]) => ({ [key]: value })),
  };
}

/**
 * Combine rows into "all must match" conditions:
 * a single merged object if keys are unique, otherwise an explicit $and.
 */
function mergeToAllConditions(rows: any[]): any {
  const keys = rows.flatMap((row) => Object.keys(row));
  if (new Set(keys).size === keys.length) {
    return Object.assign({}, ...rows);
  }
  return { $and: rows };
}
<h2 mat-dialog-title>
  @if (hadConditions) {
    <span i18n>Edit Condition</span>
  } @else {
    <span i18n>Add Condition</span>
  }
  <app-dialog-close mat-dialog-close></app-dialog-close>
</h2>

<mat-dialog-content>
  <p>{{ conditionSentence }}</p>

  <!-- the combinator comes before the conditions it applies to, so the dialog
       reads as one sentence: "<role> can read <type> only where …
       [Any|All] of the following conditions match: …" -->
  <div class="flex-row align-center gap-regular margin-bottom-regular">
    <mat-button-toggle-group
      [value]="combinator()"
      (change)="combinator.set($event.value)"
      hideSingleSelectionIndicator
    >
      <mat-button-toggle
        value="any"
        matTooltip='A record matches as soon as one of the conditions below applies ("or").'
        i18n-matTooltip
        i18n
      >
        Any
      </mat-button-toggle>
      <mat-button-toggle
        value="all"
        matTooltip='A record matches only if every one of the conditions below applies ("and").'
        i18n-matTooltip
        i18n
      >
        All
      </mat-button-toggle>
    </mat-button-toggle-group>
    <span i18n>of the following conditions match</span>
  </div>

  <app-conditions-editor
    [conditions]="editorConditions"
    [entityConstructor]="entityConstructor"
    [hint]="combinatorHint()"
    [showInternalIdField]="true"
    (conditionsChange)="onConditionsChange($event)"
  ></app-conditions-editor>
</mat-dialog-content>

<mat-dialog-actions align="end">
  @if (hadConditions) {
    <button mat-button color="warn" (click)="removeConditions()" i18n>
      Remove condition
    </button>
  }
  <button mat-stroked-button (click)="cancel()" i18n>Cancel</button>
  <button mat-raised-button color="accent" (click)="apply()" i18n>
    Apply Condition
  </button>
</mat-dialog-actions>
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""