Add role control

This commit is contained in:
Chris Chen
2026-06-23 07:19:08 -07:00
parent deff2264a6
commit 870eeec82a
45 changed files with 1923 additions and 165 deletions
@@ -85,10 +85,16 @@
<!-- Reimbursement mode: receipt file input -->
<ng-container *ngIf="mode === 'reimbursement'">
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
<!--
Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed)
from bubbling up to the host, where it would collide with this component's
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
-->
<input
type="file"
accept="image/*,application/pdf"
(change)="onFileSelected($event)"
(cancel)="$event.stopPropagation()"
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
</label>
</ng-container>
@@ -0,0 +1,76 @@
<div class="k-p-4">
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
<h2 class="k-m-0">Role Permissions</h2>
<button kendoButton themeColor="primary"
[disabled]="!selectedRole || isSuperAdminSelected || isSaving"
(click)="save()">
{{ isSaving ? 'Saving...' : 'Save Changes' }}
</button>
</div>
<p class="k-mb-4" style="color:#666">
Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply
immediately after saving — no re-login required. <strong>super_admin</strong> always has
full access and cannot be edited.
</p>
<div *ngIf="savedMessage" class="k-mb-3 k-p-2"
style="background:#e8f5e9;border-radius:4px;color:#2e7d32">
{{ savedMessage }}
</div>
<div *ngIf="isLoading" class="k-mb-3">
<kendo-loader type="infinite-spinner"></kendo-loader> Loading...
</div>
<!-- Role selector -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-1 k-mb-4">
<label class="flex flex-col gap-1">
<span style="font-weight:600">Role</span>
<kendo-dropdownlist
[data]="roleNames"
[value]="selectedRole"
(valueChange)="selectRole($event)"
[valuePrimitive]="true">
</kendo-dropdownlist>
</label>
<div class="flex flex-col gap-1" *ngIf="selectedDescription">
<span style="font-weight:600">Description</span>
<span style="padding-top:6px;color:#555">{{ selectedDescription }}</span>
</div>
</div>
<div *ngIf="isSuperAdminSelected" class="k-mb-3 k-p-2"
style="background:#fff3e0;border-radius:4px;color:#e65100">
super_admin bypasses all permission checks — every module is shown as fully granted and is read-only.
</div>
<!-- Module × action matrix for the selected role -->
<kendo-grid [data]="rows" [height]="520" *ngIf="selectedRole">
<kendo-grid-column field="module" title="Module" [width]="220"></kendo-grid-column>
<kendo-grid-column title="Read" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<input type="checkbox" [(ngModel)]="dataItem.canRead" [disabled]="isSuperAdminSelected" />
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Write" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<input type="checkbox" [(ngModel)]="dataItem.canWrite" [disabled]="isSuperAdminSelected" />
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Delete" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<input type="checkbox" [(ngModel)]="dataItem.canDelete" [disabled]="isSuperAdminSelected" />
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Approve" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<input type="checkbox" [(ngModel)]="dataItem.canApprove" [disabled]="isSuperAdminSelected" />
</ng-template>
</kendo-grid-column>
</kendo-grid>
</div>
@@ -0,0 +1,13 @@
:host {
display: block;
}
input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
input[type='checkbox']:disabled {
cursor: not-allowed;
}
@@ -0,0 +1,99 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { PermissionApiService } from '../../services/permission-api.service';
import { PermissionService } from '../../../../core/services/permission.service';
import {
ModulePermissionDto,
PermissionMatrixDto,
RolePermissionRow,
} from '../../../../core/models/permission.model';
@Component({
selector: 'app-permissions-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, IndicatorsModule,
],
templateUrl: './permissions-page.component.html',
styleUrls: ['./permissions-page.component.scss'],
})
export class PermissionsPageComponent implements OnInit {
matrix: PermissionMatrixDto | null = null;
roleNames: string[] = [];
selectedRole: string | null = null;
selectedDescription: string | null = null;
isSuperAdminSelected = false;
/** Editable copy of the selected role's per-module grants. */
rows: ModulePermissionDto[] = [];
isLoading = false;
isSaving = false;
savedMessage: string | null = null;
constructor(
private api: PermissionApiService,
private permissions: PermissionService
) { }
ngOnInit(): void {
this.loadMatrix();
}
loadMatrix(): void {
this.isLoading = true;
this.api.getMatrix().subscribe({
next: matrix => {
this.matrix = matrix;
this.roleNames = matrix.roles.map(role => role.roleName);
// Preserve the current selection across reloads, else pick the first editable role.
const keep = this.selectedRole && this.roleNames.includes(this.selectedRole)
? this.selectedRole
: matrix.roles.find(role => !role.isSuperAdmin)?.roleName ?? this.roleNames[0] ?? null;
this.selectRole(keep);
this.isLoading = false;
},
error: () => { this.isLoading = false; },
});
}
selectRole(roleName: string | null): void {
this.selectedRole = roleName;
this.savedMessage = null;
const row = this.findRow(roleName);
this.selectedDescription = row?.description ?? null;
this.isSuperAdminSelected = row?.isSuperAdmin ?? false;
// Clone so edits aren't committed until Save.
this.rows = (row?.modules ?? []).map(module => ({ ...module }));
}
save(): void {
if (!this.selectedRole || this.isSuperAdminSelected) {
return;
}
this.isSaving = true;
this.api.updateRole(this.selectedRole, { modules: this.rows }).subscribe({
next: () => {
this.isSaving = false;
this.savedMessage = `Saved permissions for "${this.selectedRole}".`;
// Refresh the matrix and the current user's own permissions (in case they edited their effect).
this.permissions.refresh().subscribe();
this.loadMatrix();
},
error: () => { this.isSaving = false; },
});
}
private findRow(roleName: string | null): RolePermissionRow | undefined {
if (!roleName || !this.matrix) {
return undefined;
}
return this.matrix.roles.find(role => role.roleName === roleName);
}
}
@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
PermissionMatrixDto,
UpdateRolePermissionsRequest,
} from '../../../core/models/permission.model';
/** Admin API for the configurable RBAC matrix (super_admin only on the server). */
@Injectable({ providedIn: 'root' })
export class PermissionApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('permissions');
}
/** GET /api/permissions — the full role × module matrix. */
getMatrix(): Observable<PermissionMatrixDto> {
return this.http.get<PermissionMatrixDto>(this.endpoint);
}
/** PUT /api/permissions/{roleName} — replaces a role's grants. */
updateRole(roleName: string, request: UpdateRolePermissionsRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${encodeURIComponent(roleName)}`, request);
}
}