Add role control
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user