Add role control
This commit is contained in:
+37
-24
@@ -3,7 +3,9 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo
|
||||
import { LoginPage } from './features/login-page/login-page';
|
||||
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
||||
import { AuthGuard } from './core/guards/auth.guard';
|
||||
import { RoleGuard } from './core/guards/role.guard';
|
||||
import { PermissionGuard } from './core/guards/permission.guard';
|
||||
import { PermissionModules } from './core/models/permission.model';
|
||||
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
|
||||
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||||
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
||||
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
|
||||
@@ -38,73 +40,84 @@ export const routes: Routes = [
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: 'admin/members', component: MembersPageComponent },
|
||||
{
|
||||
path: 'admin/members',
|
||||
component: MembersPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Members, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
component: UsersPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Users, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/permissions',
|
||||
component: PermissionsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/dashboard',
|
||||
component: FinanceDashboardPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/giving-categories',
|
||||
component: GivingCategoriesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/givings',
|
||||
component: GivingsPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/offering-session',
|
||||
component: OfferingSessionPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||
},
|
||||
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
|
||||
{
|
||||
path: 'finance/expenses',
|
||||
component: ExpensesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/expense-categories',
|
||||
component: ExpenseCategoriesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/monthly-statement',
|
||||
component: MonthlyStatementPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/disbursements',
|
||||
component: DisbursementPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/check-register',
|
||||
component: CheckRegisterPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/church-profile',
|
||||
component: ChurchProfilePageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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>
|
||||
|
||||
+76
@@ -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>
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -32,50 +32,56 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Quick search / filter -->
|
||||
<div class="nav-search" *ngIf="!sidebarCollapsed">
|
||||
<kendo-svgicon [icon]="searchIcon" class="nav-search-icon"></kendo-svgicon>
|
||||
<input type="text" class="nav-search-input" placeholder="Search pages..."
|
||||
[(ngModel)]="searchQuery" aria-label="Search pages">
|
||||
<button type="button" class="nav-search-clear" *ngIf="searchQuery" (click)="clearSearch()"
|
||||
title="Clear search" aria-label="Clear search">
|
||||
<kendo-svgicon [icon]="clearIcon"></kendo-svgicon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Main</h4>
|
||||
<a *ngFor="let item of mainNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let item of mainNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Personal</h4>
|
||||
<a *ngFor="let item of personalNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Management</h4>
|
||||
<a *ngFor="let item of managementNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let item of personalNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||
<a *ngFor="let item of memberAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngIf="showUserAdminSection">
|
||||
<a *ngFor="let item of userAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||
<ng-container *ngFor="let item of memberAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let item of userAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
@@ -87,13 +93,46 @@
|
||||
|
||||
<div class="nav-section" *ngIf="showFinanceSection">
|
||||
<h4 *ngIf="!sidebarCollapsed">Finance</h4>
|
||||
<a *ngFor="let item of financeNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Collapsed sidebar: flat icon-only list, no group headers -->
|
||||
<ng-container *ngIf="sidebarCollapsed">
|
||||
<ng-container *ngFor="let group of financeGroups">
|
||||
<ng-container *ngFor="let item of group.items">
|
||||
<a *ngIf="canShow(item)" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Expanded sidebar: collapsible groups -->
|
||||
<ng-container *ngIf="!sidebarCollapsed">
|
||||
<ng-container *ngFor="let group of financeGroups">
|
||||
<div class="nav-group" *ngIf="groupVisible(group)">
|
||||
<button type="button" class="nav-group-header"
|
||||
[class.expanded]="group.expanded || searchQuery"
|
||||
(click)="toggleGroup(group)">
|
||||
<span class="nav-group-title">{{ group.text }}</span>
|
||||
<kendo-svgicon class="nav-group-chevron" [icon]="chevronDownIcon"></kendo-svgicon>
|
||||
</button>
|
||||
<div class="nav-group-items" *ngIf="group.expanded || searchQuery">
|
||||
<ng-container *ngFor="let item of group.items">
|
||||
<a class="nav-item nav-item-nested" [class.active]="item.active"
|
||||
*ngIf="isVisible(item)" [title]="item.text"
|
||||
(click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span>{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -200,6 +200,70 @@
|
||||
max-height: calc(100vh - 200px); // Account for header and footer
|
||||
}
|
||||
|
||||
// Quick search / filter box
|
||||
.nav-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1.25rem 1.5rem;
|
||||
|
||||
.nav-search-icon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
font-size: 0.85rem;
|
||||
color: #1f2937;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-search-clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
kendo-svgicon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1e40af;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -213,6 +277,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible finance group
|
||||
.nav-group {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.nav-group-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.nav-group-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-group-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 64, 175, 0.06);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&.expanded .nav-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group-items {
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested item inside a group — indent the icon to show hierarchy
|
||||
.nav-item.nav-item-nested {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IconsModule } from '@progress/kendo-angular-icons';
|
||||
import {
|
||||
SVGIcon,
|
||||
homeIcon,
|
||||
calendarIcon,
|
||||
userIcon,
|
||||
groupIcon,
|
||||
usersOutlineIcon,
|
||||
bedOutlineIcon,
|
||||
pillsOutlineIcon,
|
||||
graphIcon,
|
||||
buildingsOutlineIcon,
|
||||
banknoteOutlineIcon,
|
||||
questionCircleIcon,
|
||||
dollarIcon,
|
||||
categorizeIcon,
|
||||
moneyExchangeIcon,
|
||||
fileReportIcon,
|
||||
walletOutlineIcon,
|
||||
handIcon,
|
||||
searchIcon,
|
||||
xIcon,
|
||||
chevronDownIcon,
|
||||
lockIcon,
|
||||
} from '@progress/kendo-svg-icons';
|
||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||
import { PermissionService } from '../../core/services/permission.service';
|
||||
import { PermissionAction, PermissionModules } from '../../core/models/permission.model';
|
||||
import { Subject, takeUntil, filter } from 'rxjs';
|
||||
|
||||
interface NavItem {
|
||||
@@ -30,6 +32,15 @@ interface NavItem {
|
||||
icon: SVGIcon;
|
||||
path: string;
|
||||
active?: boolean;
|
||||
/** When set, the item is shown only if the user has this permission. */
|
||||
permission?: { module: string; action: PermissionAction };
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
text: string;
|
||||
icon?: SVGIcon;
|
||||
items: NavItem[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -37,6 +48,7 @@ interface NavItem {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
RouterOutlet,
|
||||
IconsModule,
|
||||
@@ -49,57 +61,76 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
isMobile = false;
|
||||
currentUser: UserInfo | null = null;
|
||||
currentPageTitle = 'Dashboard';
|
||||
unreadMessages = 3;
|
||||
unreadNotifications = 2;
|
||||
|
||||
public searchQuery = '';
|
||||
|
||||
public homeIcon: SVGIcon = homeIcon;
|
||||
public calendarIcon: SVGIcon = calendarIcon;
|
||||
public peopleIcon: SVGIcon = usersOutlineIcon;
|
||||
public bedIcon: SVGIcon = bedOutlineIcon;
|
||||
public userIcon: SVGIcon = userIcon;
|
||||
public pillIcon: SVGIcon = pillsOutlineIcon;
|
||||
public chartIcon: SVGIcon = graphIcon;
|
||||
public buildingIcon: SVGIcon = buildingsOutlineIcon;
|
||||
public creditCardIcon: SVGIcon = banknoteOutlineIcon;
|
||||
public supportIcon: SVGIcon = questionCircleIcon;
|
||||
public searchIcon: SVGIcon = searchIcon;
|
||||
public clearIcon: SVGIcon = xIcon;
|
||||
public chevronDownIcon: SVGIcon = chevronDownIcon;
|
||||
|
||||
public mainNavItems: NavItem[] = [
|
||||
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
|
||||
// { text: 'Schedule', icon: this.calendarIcon, path: '/user-portal/schedule' },
|
||||
// { text: 'Patients', icon: this.peopleIcon, path: '/user-portal/patients' },
|
||||
];
|
||||
|
||||
public managementNavItems: NavItem[] = [
|
||||
// { text: 'Staff', icon: this.userIcon, path: '/user-portal/staff' },
|
||||
// { text: 'Pharmacy', icon: this.pillIcon, path: '/user-portal/pharmacy' },
|
||||
// { text: 'Reports', icon: this.chartIcon, path: '/user-portal/reports' },1124
|
||||
// { text: 'Departments', icon: this.buildingIcon, path: '/user-portal/departments' },
|
||||
// { text: 'Payments', icon: this.creditCardIcon, path: '/user-portal/payments' },
|
||||
];
|
||||
|
||||
public supportNavItems: NavItem[] = [
|
||||
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' },
|
||||
];
|
||||
|
||||
public memberAdminNavItems: NavItem[] = [
|
||||
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' },
|
||||
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members',
|
||||
permission: { module: PermissionModules.Members, action: 'read' } },
|
||||
];
|
||||
|
||||
public userAdminNavItems: NavItem[] = [
|
||||
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' },
|
||||
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users',
|
||||
permission: { module: PermissionModules.Users, action: 'read' } },
|
||||
{ text: 'Role Permissions', icon: lockIcon, path: '/user-portal/admin/permissions',
|
||||
permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
];
|
||||
|
||||
public financeNavItems: NavItem[] = [
|
||||
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard' },
|
||||
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session' },
|
||||
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings' },
|
||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' },
|
||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' },
|
||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' },
|
||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' },
|
||||
public financeGroups: NavGroup[] = [
|
||||
{
|
||||
text: 'Overview',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard',
|
||||
permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement',
|
||||
permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Income',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session',
|
||||
permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings',
|
||||
permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories',
|
||||
permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Expenses',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses',
|
||||
permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
|
||||
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile',
|
||||
permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
public personalNavItems: NavItem[] = [
|
||||
@@ -114,6 +145,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private permissions: PermissionService,
|
||||
private router: Router
|
||||
) { }
|
||||
|
||||
@@ -148,13 +180,22 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(user => {
|
||||
this.currentUser = user;
|
||||
const roles = user?.roles ?? [];
|
||||
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
|
||||
this.showUserAdminSection = roles.includes('super_admin');
|
||||
this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin');
|
||||
// Section visibility is derived from effective permissions (super_admin → all).
|
||||
this.showMemberAdminSection = this.memberAdminNavItems.some(item => this.canShow(item));
|
||||
this.showUserAdminSection = this.userAdminNavItems.some(item => this.canShow(item));
|
||||
this.showFinanceSection = this.financeGroups
|
||||
.some(group => group.items.some(item => this.canShow(item)));
|
||||
});
|
||||
}
|
||||
|
||||
/** True if a nav item should be shown — items without a permission are always visible. */
|
||||
public canShow(item: NavItem): boolean {
|
||||
if (!item.permission) {
|
||||
return true;
|
||||
}
|
||||
return this.permissions.can(item.permission.module, item.permission.action);
|
||||
}
|
||||
|
||||
private setupRouteSubscription(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
@@ -176,13 +217,13 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private updateActiveStates(currentUrl: string): void {
|
||||
const financeItems: NavItem[] = [];
|
||||
this.financeGroups.forEach(group => financeItems.push(...group.items));
|
||||
const allItems = [
|
||||
...this.mainNavItems,
|
||||
...this.managementNavItems,
|
||||
...this.supportNavItems,
|
||||
...this.memberAdminNavItems,
|
||||
...this.userAdminNavItems,
|
||||
...this.financeNavItems,
|
||||
...financeItems,
|
||||
...this.personalNavItems,
|
||||
];
|
||||
allItems.forEach(item => (item.active = false));
|
||||
@@ -191,6 +232,45 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
if (activeItem) {
|
||||
activeItem.active = true;
|
||||
}
|
||||
|
||||
// Auto-expand the finance group that contains the active page so the
|
||||
// current location is visible on load/navigation.
|
||||
const activeGroup = this.financeGroups.find(group =>
|
||||
group.items.some(item => item.active)
|
||||
);
|
||||
if (activeGroup) {
|
||||
activeGroup.expanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public toggleGroup(group: NavGroup): void {
|
||||
group.expanded = !group.expanded;
|
||||
}
|
||||
|
||||
public matchesSearch(text: string): boolean {
|
||||
const query = this.searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return text.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
public groupHasMatch(group: NavGroup): boolean {
|
||||
return group.items.some(item => this.matchesSearch(item.text));
|
||||
}
|
||||
|
||||
/** Combined search + permission filter for a single nav item. */
|
||||
public isVisible(item: NavItem): boolean {
|
||||
return this.matchesSearch(item.text) && this.canShow(item);
|
||||
}
|
||||
|
||||
/** True if a finance group has at least one visible (permitted + matching) item. */
|
||||
public groupVisible(group: NavGroup): boolean {
|
||||
return group.items.some(item => this.isVisible(item));
|
||||
}
|
||||
|
||||
public clearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
|
||||
private updatePageTitle(): void {
|
||||
@@ -222,6 +302,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
'settings': 'Settings',
|
||||
'admin/members': 'Member Management',
|
||||
'admin/users': 'User Management',
|
||||
'admin/permissions': 'Role Permissions',
|
||||
'finance/dashboard': 'Finance Dashboard',
|
||||
'finance/offering-session': 'Sunday Offering Entry',
|
||||
'finance/givings': 'Givings',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
||||
import { ApiConfigService } from '../../core/services/api-config.service';
|
||||
import { ModuleActions } from '../../core/models/permission.model';
|
||||
|
||||
// ── Public interfaces ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +13,11 @@ export interface UserInfo {
|
||||
email: string;
|
||||
roles: string[];
|
||||
languagePreference: string;
|
||||
/**
|
||||
* Effective permissions, keyed by camelCased module name (server uses a
|
||||
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
|
||||
*/
|
||||
permissions?: Record<string, ModuleActions>;
|
||||
}
|
||||
|
||||
/** Matches the C# LoginResponse DTO exactly. */
|
||||
|
||||
Reference in New Issue
Block a user