src/app/core/admin/admin-role-permissions/permission-matrix/permission-matrix.component.ts
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.
| changeDetection | ChangeDetectionStrategy.OnPush |
| selector | app-permission-matrix |
| standalone | true |
| imports |
MatTableModule
MatCheckboxModule
MatButtonModule
MatTooltipModule
MatFormFieldModule
|
| styleUrls | ./permission-matrix.component.scss |
| templateUrl | ./permission-matrix.component.html |
| styleUrl | ./permission-matrix.component.scss |
MatTableModule
MatCheckboxModule
MatButtonModule
MatTooltipModule
MatFormFieldModule
FaIconComponent
FaDynamicIconComponent
HintBoxComponent
EntityTypeSelectComponent
RouterLink
Properties |
|
Methods |
Inputs |
Outputs |
| 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 : ""
|
|
| modelChange | |
Type : MatrixModel
|
|
| Async addSubject | ||||||
addSubject(selected: string | string[])
|
||||||
|
Parameters :
Returns :
any
|
| openConditionDialog | |||||||||
openConditionDialog(rowIndex: number, action: EntityActionPermission)
|
|||||||||
|
Parameters :
Returns :
void
|
| removeCondition | |||||||||
removeCondition(rowIndex: number, action: EntityActionPermission)
|
|||||||||
|
clear the condition of an action, keeping the action itself allowed
Parameters :
Returns :
void
|
| removeRow | ||||||
removeRow(rowIndex: number)
|
||||||
|
Parameters :
Returns :
void
|
| setCellAllowed | ||||||||||||
setCellAllowed(rowIndex: number, action: EntityActionPermission, allowed: boolean)
|
||||||||||||
|
Parameters :
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.
Returns :
void
|
| Readonly addPickerOpen |
Type : unknown
|
Default value : signal(false)
|
|
whether the record-type picker is shown instead of the "Add Permission" button |
| 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 manageColLabel |
Type : unknown
|
Default value : $localize`Manage (all)`
|
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…</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);
}