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
+37 -24
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -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. */