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
@@ -0,0 +1,66 @@
import { Directive, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { PermissionService } from '../services/permission.service';
import { PermissionAction, PermissionRequirement } from '../models/permission.model';
/**
* Structural directive that renders its content only if the current user has the
* required permission. Re-evaluates when the current user changes (e.g. after a
* matrix edit + refresh). Usage:
*
* <button *appHasPermission="{ module: 'Expenses', action: 'write' }">Edit</button>
* <button *appHasPermission="['Expenses', 'approve']">Approve</button>
*/
@Directive({
selector: '[appHasPermission]',
standalone: true,
})
export class HasPermissionDirective implements OnInit, OnDestroy {
private requirement: PermissionRequirement | null = null;
private hasView = false;
private destroy$ = new Subject<void>();
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private permissions: PermissionService,
private auth: AuthService
) { }
@Input()
set appHasPermission(value: PermissionRequirement | [string, PermissionAction]) {
if (Array.isArray(value)) {
this.requirement = { module: value[0], action: value[1] };
} else {
this.requirement = value;
}
this.updateView();
}
ngOnInit(): void {
// React to login/logout/refresh so visibility stays in sync with permissions.
this.auth.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.updateView());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private updateView(): void {
const allowed = this.requirement
? this.permissions.can(this.requirement.module, this.requirement.action)
: false;
if (allowed && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!allowed && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
import { AuthService } from '../../shared/services/auth.service';
import { PermissionService } from '../services/permission.service';
import { PermissionRequirement } from '../models/permission.model';
/**
* Route guard for the configurable permission system. Reads
* route.data['permission'] = { module, action } and blocks navigation if the
* current user lacks it (redirecting to the dashboard). The parent route's
* AuthGuard guarantees the session is restored before children activate.
*/
@Injectable({ providedIn: 'root' })
export class PermissionGuard implements CanActivate {
constructor(
private permissions: PermissionService,
private auth: AuthService,
private router: Router
) { }
canActivate(route: ActivatedRouteSnapshot): boolean {
const required = route.data['permission'] as PermissionRequirement | undefined;
if (!required) {
return true;
}
const allowed = this.permissions.can(required.module, required.action);
if (!allowed) {
this.router.navigate(['/user-portal/dashboard']);
}
return allowed;
}
}
@@ -0,0 +1,65 @@
/** Effective action flags for one module — mirrors the C# ModuleActions DTO. */
export interface ModuleActions {
read: boolean;
write: boolean;
delete: boolean;
approve: boolean;
/** Computed server-side (true if any flag is set). */
any?: boolean;
}
export type PermissionAction = 'read' | 'write' | 'delete' | 'approve';
/**
* Canonical module names — must match the C# ROLAC.API.Authorization.Modules constants
* (PascalCase). Used by the permission directive, guard, nav, and admin page.
*/
export const PermissionModules = {
Members: 'Members',
Users: 'Users',
Givings: 'Givings',
GivingCategories: 'GivingCategories',
Expenses: 'Expenses',
ExpenseCategories: 'ExpenseCategories',
OfferingSessions: 'OfferingSessions',
Ministries: 'Ministries',
FinanceDashboard: 'FinanceDashboard',
MonthlyStatements: 'MonthlyStatements',
ChurchProfile: 'ChurchProfile',
Disbursements: 'Disbursements',
MealAttendance: 'MealAttendance',
Permissions: 'Permissions',
} as const;
/** A required permission, used in route data and the *appHasPermission directive. */
export interface PermissionRequirement {
module: string;
action: PermissionAction;
}
// ── Admin matrix DTOs (mirror C# DTOs.Permissions) ────────────────────────────
export interface ModulePermissionDto {
module: string;
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
canApprove: boolean;
}
export interface RolePermissionRow {
roleName: string;
description?: string;
isSuperAdmin: boolean;
modules: ModulePermissionDto[];
}
export interface PermissionMatrixDto {
allModules: string[];
allActions: string[];
roles: RolePermissionRow[];
}
export interface UpdateRolePermissionsRequest {
modules: ModulePermissionDto[];
}
@@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { AuthService, UserInfo } from '../../shared/services/auth.service';
import { ApiConfigService } from './api-config.service';
import { PermissionAction } from '../models/permission.model';
const SUPER_ADMIN = 'super_admin';
/**
* Reads the current user's effective permissions (delivered on the UserInfo payload)
* and answers can(module, action). super_admin always passes. This is a UX mirror —
* the backend remains the authoritative permission boundary.
*/
@Injectable({ providedIn: 'root' })
export class PermissionService {
constructor(
private auth: AuthService,
private http: HttpClient,
private apiConfig: ApiConfigService
) { }
/** True if the current user may perform <action> on <module>. */
can(module: string, action: PermissionAction): boolean {
const user = this.auth.getCurrentUser();
if (!user) {
return false;
}
if (user.roles?.includes(SUPER_ADMIN)) {
return true;
}
const moduleActions = user.permissions?.[this.normalizeKey(module)];
return !!moduleActions && !!moduleActions[action];
}
canRead(module: string): boolean { return this.can(module, 'read'); }
canWrite(module: string): boolean { return this.can(module, 'write'); }
canDelete(module: string): boolean { return this.can(module, 'delete'); }
canApprove(module: string): boolean { return this.can(module, 'approve'); }
/**
* Re-fetches the current user (with fresh permissions) from GET /api/auth/me.
* Call after an admin edits the matrix so the UI reflects the change without
* a re-login. Returns the updated user, or null on failure.
*/
refresh(): Observable<UserInfo | null> {
return this.http.get<UserInfo>(`${this.apiConfig.authUrl}/me`).pipe(
tap(user => this.auth.setCurrentUser(user)),
map(user => user),
catchError(() => of(null))
);
}
/**
* Module names are stored PascalCase in code but arrive as camelCase dictionary
* keys (server's DictionaryKeyPolicy). Lowercase the first character to match.
*/
private normalizeKey(module: string): string {
if (!module) {
return module;
}
return module.charAt(0).toLowerCase() + module.slice(1);
}
}