src/app/core/admin/admin-role-permissions/condition-dialog/permission-condition-dialog.component.ts
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 };
}

results matching ""

    No results matching ""