src/app/core/admin/admin-role-permissions/permission-matrix/permission-matrix.component.ts
display state of one action cell with additional details required for permission conditions.
No results matching.
Properties |
| hasCondition |
hasCondition:
|
Type : boolean
|
| ownAllowed |
ownAllowed:
|
Type : boolean
|
|
Granted by this row's own cell for the action, ignoring what the row's own
"manage", the role's "all record types" row or the shared "_default" role
add on top - all of which this grid's |
| summary |
summary:
|
Type : string
|
|
readable summary of the condition, empty when none |
| allowed |
allowed:
|
Type : boolean
|
|
Whether the checkbox is shown as ticked. Each grid resolves this for itself, so it may well include access that a broader or inherited rule grants on top of what the row itself stores. |
| editable |
editable:
|
Type : boolean
|
|
whether the checkbox may be changed on this row |
| lockTooltip |
lockTooltip:
|
Type : string
|
|
why the checkbox cannot be changed; empty when it is editable |
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
} from "@angular/core";
import { MatButtonModule } from "@angular/material/button";
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 { 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 {
ConditionEditorDialogComponent,
ConditionEditorDialogData,
} from "../../../common-components/condition-editor-dialog/condition-editor-dialog.component";
import {
CRUD_ACTIONS,
CrudAction,
DatabaseRule,
DEFAULT_SECTION_KEY,
EntityActionPermission,
inheritsDefaultRules,
ruleCoversAction,
} from "../../../permissions/permission-types";
import {
CRUD_ACTION_COLUMNS,
grantedByDefaultRoleTooltip,
MANAGE_ALL_LABEL,
} from "../../../permissions/permission-action-labels";
import {
PermissionCellState,
PermissionCheckboxComponent,
} from "../../../permissions/permission-checkbox/permission-checkbox.component";
import {
DEFAULT_ROLE,
roleDisplayName,
} from "../../../permissions/reserved-roles";
import { MatrixModel, MatrixRow, RuleConditions } from "../permission-matrix";
import { ROLES_ADMIN_ROUTE } from "../role-permissions.service";
/**
* 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
* with additional details required for permission conditions.
*/
interface CellState extends PermissionCellState {
/**
* Granted by this row's own cell for the action, ignoring what the row's own
* "manage", the role's "all record types" row or the shared "_default" role
* add on top - all of which this grid's `allowed` does include. Only an own
* grant can carry a condition, which is what the "only where ..." link is
* offered for.
*/
ownAllowed: boolean;
hasCondition: boolean;
/** readable summary of the condition, empty when none */
summary: string;
}
/**
* 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,
MatButtonModule,
MatTooltipModule,
MatFormFieldModule,
FaIconComponent,
FaDynamicIconComponent,
HintBoxComponent,
EntityTypeSelectComponent,
RouterLink,
PermissionCheckboxComponent,
],
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 = CRUD_ACTION_COLUMNS.map((column) => ({
key: column.action,
label: column.label,
}));
readonly crudActions: CrudAction[] = [...CRUD_ACTIONS];
readonly manageColLabel = MANAGE_ALL_LABEL;
// 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(manageAllowed),
actionStates: Object.fromEntries(
this.crudActions.map((action) => [
action,
this.defaultRowCellState(manageAllowed || !!cells[action]?.allowed),
]),
) as Record<CrudAction, CellState>,
};
});
private defaultRowCellState(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.`,
};
}
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) : "",
};
}
/**
* 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 grantedByDefaultRoleTooltip();
}
}
/** 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;
}
// the wildcard row is only covered by a rule that applies to every record
// type, which is what `ruleCoversAction` resolves for the "all" subject
return ruleCoversAction(rule, subject, action);
});
}
/** 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];
const entityConstructor = this.entityRegistry.has(row.subject)
? this.entityRegistry.get(row.subject)
: undefined;
const entityLabel = this.subjectLabel(row.subject);
const conditions = row.cells[action]?.conditions;
this.dialog
.open(ConditionEditorDialogComponent, {
width: "780px",
data: {
entityConstructor,
conditions,
explanation: this.buildConditionSentence(action, entityLabel),
showInternalIdField: true,
} satisfies ConditionEditorDialogData,
})
.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 } : {}),
};
});
});
}
/**
* Full "<role> can <action> <entity> only where…" sentence as a single
* localized message per action, so translators can reorder role/entity.
*/
private buildConditionSentence(
action: EntityActionPermission,
entityLabel: string,
): string {
const role = roleDisplayName(this.roleName());
switch (action) {
case "read":
return $localize`:permission condition sentence:${role} can read ${entityLabel} only where…`;
case "create":
return $localize`:permission condition sentence:${role} can create ${entityLabel} only where…`;
case "update":
return $localize`:permission condition sentence:${role} can update ${entityLabel} only where…`;
case "delete":
return $localize`:permission condition sentence:${role} can delete ${entityLabel} only where…`;
case "manage":
return $localize`:permission condition sentence:${role} can manage ${entityLabel} only where…`;
}
}
/** 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);
}
}