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,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Moq;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Authorization;
public class PermissionAuthorizationHandlerTests
{
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = roles.Select(role => new Claim("role", role));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static async Task<bool> EvaluateAsync(
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
{
var handler = new PermissionAuthorizationHandler(permissions);
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
await handler.HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
{
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
Assert.True(succeeded);
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RoleWithPermission_Succeeds()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
.ReturnsAsync(true);
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
Assert.True(succeeded);
}
[Fact]
public async Task RoleWithoutPermission_Fails()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
Assert.False(succeeded);
}
}
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
@@ -72,11 +73,20 @@ public class AuthServiceTests
return svc;
}
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
private static Mock<IPermissionService> BuildPermissionService()
{
var svc = new Mock<IPermissionService>();
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, ModuleActions>());
return svc;
}
private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock,
AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig());
// -----------------------------------------------------------------------
// Login tests
@@ -0,0 +1,183 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class PermissionServiceTests
{
// -----------------------------------------------------------------------
// Harness: a real PermissionService backed by an in-memory EF database.
// -----------------------------------------------------------------------
private sealed class Harness
{
public required ServiceProvider Provider { get; init; }
public required PermissionService Service { get; init; }
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
{
using var scope = Provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
db.Roles.Add(role);
foreach (var permission in permissions)
{
permission.RoleId = role.Id;
db.RolePermissions.Add(permission);
}
await db.SaveChangesAsync();
}
}
private static Harness BuildHarness()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
return new Harness
{
Provider = provider,
Service = new PermissionService(scopeFactory, cache),
};
}
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
// -----------------------------------------------------------------------
// HasPermissionAsync
// -----------------------------------------------------------------------
[Fact]
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
}
[Fact]
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
{
var h = BuildHarness();
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
// User holds both roles — should get the union.
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
}
// -----------------------------------------------------------------------
// GetEffectivePermissionsAsync
// -----------------------------------------------------------------------
[Fact]
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
{
var h = BuildHarness(); // no rows seeded at all
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
Assert.Equal(Modules.All.Count, effective.Count);
foreach (var module in Modules.All)
{
Assert.True(effective[module].Read);
Assert.True(effective[module].Write);
Assert.True(effective[module].Delete);
Assert.True(effective[module].Approve);
}
}
[Fact]
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
{
var h = BuildHarness();
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
Assert.True(effective[Modules.Expenses].Read);
Assert.True(effective[Modules.Expenses].Write);
Assert.True(effective[Modules.Expenses].Approve);
Assert.False(effective[Modules.Expenses].Delete);
}
[Fact]
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
{
var h = BuildHarness();
await h.SeedRoleAsync("member"); // role exists but no grants
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
Assert.Empty(effective);
}
// -----------------------------------------------------------------------
// Caching / invalidation via UpsertRoleAsync
// -----------------------------------------------------------------------
[Fact]
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
// Prime the cache with the original snapshot.
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
// Grant write; UpsertRoleAsync must invalidate the cache.
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
{
Module = Modules.Givings, CanRead = true, CanWrite = true,
}]);
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task UpsertRole_SuperAdmin_Throws()
{
var h = BuildHarness();
await h.SeedRoleAsync("super_admin");
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
[Fact]
public async Task UpsertRole_UnknownRole_Throws()
{
var h = BuildHarness();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Gates an action/controller on a configurable permission. Usage:
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
/// Encodes the policy name <c>PERM:&lt;module&gt;:&lt;action&gt;</c>, which
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class HasPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "PERM:";
public HasPermissionAttribute(string module, string action)
=> Policy = $"{PolicyPrefix}{module}:{action}";
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
public static (string Module, string Action)? Parse(string policyName)
{
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
return null;
var body = policyName[PolicyPrefix.Length..];
var split = body.IndexOf(':');
if (split <= 0 || split == body.Length - 1)
return null;
return (body[..split], body[(split + 1)..]);
}
}
+62
View File
@@ -0,0 +1,62 @@
namespace ROLAC.API.Authorization;
/// <summary>
/// Canonical list of permission-controlled modules. The names are stored verbatim
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
/// attributes, so changing a string here is a breaking change requiring a data update.
/// </summary>
public static class Modules
{
public const string Members = "Members";
public const string Users = "Users";
public const string Givings = "Givings";
public const string GivingCategories = "GivingCategories";
public const string Expenses = "Expenses";
public const string ExpenseCategories = "ExpenseCategories";
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
[
Members,
Users,
Givings,
GivingCategories,
Expenses,
ExpenseCategories,
OfferingSessions,
Ministries,
FinanceDashboard,
MonthlyStatements,
ChurchProfile,
Disbursements,
MealAttendance,
Permissions,
];
public static bool IsValid(string module) => All.Contains(module);
}
/// <summary>
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
/// </summary>
public static class PermissionActions
{
public const string Read = "Read";
public const string Write = "Write";
public const string Delete = "Delete";
public const string Approve = "Approve";
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
public static bool IsValid(string action) => All.Contains(action);
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Authorization;
/// <summary>
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
/// ANY of the user's roles grants the requested module/action (union across roles).
/// </summary>
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
public const string SuperAdminRole = "super_admin";
private readonly IPermissionService _permissions;
public PermissionAuthorizationHandler(IPermissionService permissions)
=> _permissions = permissions;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
if (roles.Contains(SuperAdminRole))
{
context.Succeed(requirement);
return;
}
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
context.Succeed(requirement);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Authorization;
/// <summary>
/// Materializes <c>PERM:&lt;module&gt;:&lt;action&gt;</c> policies on demand so we never
/// have to register every module/action combination at startup. Any other policy name
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
/// </summary>
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var parsed = HasPermissionAttribute.Parse(policyName);
if (parsed is null)
return _fallback.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Authorization requirement carrying the module + action a request needs.
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
/// name of the form <c>PERM:&lt;module&gt;:&lt;action&gt;</c>.
/// </summary>
public class PermissionRequirement : IAuthorizationRequirement
{
public string Module { get; }
public string Action { get; }
public PermissionRequirement(string module, string action)
{
Module = module;
Action = action;
}
}
+39 -7
View File
@@ -1,6 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService, IWebHostEnvironment env)
public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
{
_authService = authService;
_userManager = userManager;
_env = env;
}
@@ -79,17 +85,43 @@ public class AuthController : ControllerBase
}
// -------------------------------------------------------------------------
// GET /api/auth/me (dev-only diagnostic — remove before production)
// GET /api/auth/me
// -------------------------------------------------------------------------
/// <summary>
/// Returns the claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
/// fine; if you then get 403 on /api/users the role claim isn't matching.
/// Returns the current user's identity, roles, and effective permissions.
/// The SPA calls this on startup and after an admin edits the permission matrix
/// to refresh what the UI shows — without forcing a re-login.
/// </summary>
[HttpGet("me")]
[Authorize] // no role restriction — just needs a valid JWT
public IActionResult GetMe()
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId);
if (user is null || !user.IsActive)
return Unauthorized();
var roles = await _userManager.GetRolesAsync(user);
return Ok(await _authService.BuildUserInfoAsync(user, roles));
}
// -------------------------------------------------------------------------
// GET /api/auth/claims (dev-only diagnostic)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
/// </summary>
[HttpGet("claims")]
[Authorize]
public IActionResult GetClaims()
{
var claims = User.Claims
.Select(c => new { c.Type, c.Value })
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/church-profile")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class ChurchProfileController : ControllerBase
{
private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut]
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{
await _svc.UpdateAsync(r);
@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/disbursements")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class DisbursementsController : ControllerBase
{
private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")]
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{
try { return Ok(await _svc.IssueChecksAsync(r)); }
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
}
[HttpPost("checks/{id:int}/void")]
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetPdf(int id)
{
var result = await _svc.RenderPdfAsync(id);
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/receipt-pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetReceiptPdf(int id)
{
var result = await _svc.RenderReceiptPdfAsync(id);
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
}
[HttpPost("checks/{id:int}/acknowledge")]
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
[RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
}
[HttpGet("checks/{id:int}/signature")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetSignature(int id)
{
try
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost("groups")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
[HttpPut("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateGroup(int id)
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPost("subcategories")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPut("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateSub(int id)
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
}
+30 -13
View File
@@ -1,21 +1,38 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
// Class is [Authorize] only — any authenticated member may submit/view their OWN
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
// against the configurable permission matrix on the "Expenses" module.
[ApiController]
[Route("api/expenses")]
[Authorize]
public class ExpensesController : ControllerBase
{
private readonly IExpenseService _svc;
public ExpensesController(IExpenseService svc) => _svc = svc;
private readonly IPermissionService _perms;
public ExpensesController(IExpenseService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
private async Task<bool> CanViewAllAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() =>
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
{
if (!CanViewAll()) return Forbid();
if (!await CanViewAllAsync()) return Forbid();
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
}
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
{
var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound();
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
{
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/approve")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Approve(int id)
{
try { await _svc.ApproveAsync(id); return NoContent(); }
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/reject")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
{
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
}
[HttpPost("{id:int}/pay")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
{
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
{
try
{
var result = await _svc.OpenReceiptAsync(id, IsFinance());
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/finance-dashboard")]
[Authorize(Roles = "finance,super_admin")]
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
public class FinanceDashboardController : ControllerBase
{
private readonly IFinanceDashboardService _svc;
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class GivingCategoriesController : ControllerBase
{
private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{
var id = await _svc.CreateAsync(request);
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/givings")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class GivingsController : ControllerBase
{
private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{
var id = await _svc.CreateAsync(request);
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _svc.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Members;
using ROLAC.API.Services;
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet]
[Authorize(Roles = "super_admin,secretary,pastor")]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")]
[Authorize(Roles = "super_admin,secretary,pastor")]
[HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _members.GetByIdAsync(id);
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
/// <summary>POST /api/members</summary>
[HttpPost]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{
var id = await _members.CreateAsync(request);
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
/// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{
try { await _members.UpdateAsync(id, request); return NoContent(); }
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
/// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")]
[Authorize(Roles = "super_admin,secretary")]
[HasPermission(Modules.Members, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{
try { await _members.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/monthly-statements")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class MonthlyStatementsController : ControllerBase
{
private readonly IMonthlyStatementService _svc;
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
=> Ok(await _svc.GetAllAsync(year));
[HttpGet("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
{
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
{
try { await _svc.UpdateAsync(id, r); return NoContent(); }
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
}
[HttpPost("{id:int}/finalize")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
public async Task<IActionResult> Finalize(int id)
{
try { await _svc.FinalizeAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services;
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")]
[Authorize]
public class OfferingSessionsController : ControllerBase
{
private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPost]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{
try
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPost("{id:int}/reopen")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
public async Task<IActionResult> Reopen(int id)
{
try { await _svc.ReopenAsync(id); return NoContent(); }
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpPut("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof(int id, IFormFile file)
{
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpGet("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetProof(int id)
{
try
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
}
[HttpDelete("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
public async Task<IActionResult> DeleteProof(int id)
{
try { await _svc.DeleteProofAsync(id); return NoContent(); }
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
/// the role that governs who governs everyone else.
/// </summary>
[ApiController]
[Route("api/permissions")]
[Authorize(Roles = "super_admin")]
public class PermissionsController : ControllerBase
{
private readonly IPermissionService _permissions;
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
[HttpGet]
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
{
Modules = Modules.All,
Actions = PermissionActions.All,
});
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
[HttpPut("{roleName}")]
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
{
try
{
await _permissions.UpsertRoleAsync(roleName, request.Modules);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
+8 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Services;
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/users")]
[Authorize(Roles = "super_admin")]
[Authorize]
public class UsersController : ControllerBase
{
private readonly IUserManagementService _users;
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetById(string id)
{
var dto = await _users.GetByIdAsync(id);
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
/// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
try
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{
try { await _users.UpdateAsync(id, request); return NoContent(); }
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")]
[HasPermission(Modules.Users, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(string id)
{
try { await _users.DeactivateAsync(id); return NoContent(); }
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> ResetPassword(string id)
{
try
+8
View File
@@ -1,3 +1,5 @@
using ROLAC.API.DTOs.Permissions;
namespace ROLAC.API.DTOs.Auth;
public class LoginResponse
@@ -17,4 +19,10 @@ public class UserInfo
public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en";
/// <summary>
/// Effective permissions (union across the user's roles), keyed by module name.
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
}
@@ -0,0 +1,53 @@
namespace ROLAC.API.DTOs.Permissions;
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
public class ModuleActions
{
public bool Read { get; set; }
public bool Write { get; set; }
public bool Delete { get; set; }
public bool Approve { get; set; }
public bool Any => Read || Write || Delete || Approve;
}
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
public class ModulePermissionDto
{
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
}
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
public class RolePermissionRow
{
public string RoleName { get; set; } = null!;
public string? Description { get; set; }
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
public bool IsSuperAdmin { get; set; }
public List<ModulePermissionDto> Modules { get; set; } = [];
}
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
public class PermissionMatrixDto
{
public IReadOnlyList<string> AllModules { get; set; } = [];
public IReadOnlyList<string> AllActions { get; set; } = [];
public List<RolePermissionRow> Roles { get; set; } = [];
}
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
public class PermissionCatalogDto
{
public IReadOnlyList<string> Modules { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
}
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
public class UpdateRolePermissionsRequest
{
public List<ModulePermissionDto> Modules { get; set; } = [];
}
+13
View File
@@ -23,6 +23,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -60,6 +61,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Description).HasMaxLength(500);
});
// ── RolePermission (configurable RBAC matrix) ───────────────────────
builder.Entity<RolePermission>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
// One row per (role, module).
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
entity.HasOne(e => e.Role).WithMany()
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
});
// ── FamilyUnit ──────────────────────────────────────────────────────
builder.Entity<FamilyUnit>(entity =>
{
+56
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Authorization;
using ROLAC.API.Entities;
namespace ROLAC.API.Data;
@@ -62,6 +63,60 @@ public static class DbSeeder
("visitor", "Visitor — public pages only"),
];
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
// existed before the configurable RBAC system, so day-one behavior is unchanged.
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
// later edits via the Permissions UI are never clobbered on restart.
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
[
// Secretary — manages member data.
("secretary", Modules.Members, true, true, true, false),
// Pastor — read-only overview of members and all expenses.
("pastor", Modules.Members, true, false, false, false),
("pastor", Modules.Expenses, true, false, false, false),
// Finance — full control over the finance modules.
("finance", Modules.Givings, true, true, true, false),
("finance", Modules.GivingCategories, true, true, true, false),
("finance", Modules.Expenses, true, true, true, true),
("finance", Modules.ExpenseCategories, true, true, true, false),
("finance", Modules.OfferingSessions, true, true, true, true),
("finance", Modules.FinanceDashboard, true, false, false, false),
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
{
var rolesByName = await db.Roles
.Where(r => r.Name != null)
.ToDictionaryAsync(r => r.Name!, r => r.Id);
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
{
if (!rolesByName.TryGetValue(role, out var roleId))
continue;
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
if (exists)
continue; // never clobber an admin's edit
db.RolePermissions.Add(new RolePermission
{
RoleId = roleId,
Module = module,
CanRead = read,
CanWrite = write,
CanDelete = delete,
CanApprove = approve,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{
foreach (var (name, description) in Roles)
@@ -159,6 +214,7 @@ public static class DbSeeder
await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>();
await SeedRolePermissionsAsync(db);
await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
+25
View File
@@ -0,0 +1,25 @@
namespace ROLAC.API.Entities;
/// <summary>
/// One row per (Role × Module). Stores what the role may do on that module.
/// The effective permission for a user is the boolean OR of these flags across
/// all of the user's roles. <c>super_admin</c> is never stored here — it bypasses
/// permission checks entirely (see PermissionAuthorizationHandler).
/// </summary>
public class RolePermission
{
public int Id { get; set; }
/// <summary>FK to AspNetRoles.Id.</summary>
public string RoleId { get; set; } = null!;
/// <summary>Module constant name (see <see cref="Authorization.Modules"/>).</summary>
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
public AppRole Role { get; set; } = null!;
}
@@ -1310,6 +1310,44 @@ namespace ROLAC.API.Migrations
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("CanApprove")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanRead")
.HasColumnType("boolean");
b.Property<bool>("CanWrite")
.HasColumnType("boolean");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("RoleId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("RoleId", "Module")
.IsUnique();
b.ToTable("RolePermissions");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -1481,6 +1519,17 @@ namespace ROLAC.API.Migrations
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
+13
View File
@@ -135,6 +135,19 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
// ---------------------------------------------------------------------------
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IPermissionService, PermissionService>();
builder.Services.AddAuthorization();
// Dynamic policy provider materializes "PERM:<module>:<action>" policies on demand;
// must be registered AFTER AddAuthorization so it overrides the default provider.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
ROLAC.API.Authorization.PermissionPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
ROLAC.API.Authorization.PermissionAuthorizationHandler>();
// Real-time hub for the live Sunday attendance counter.
builder.Services.AddSignalR();
+13 -5
View File
@@ -12,17 +12,20 @@ public class AuthService : IAuthService
private readonly UserManager<AppUser> _userManager;
private readonly ITokenService _tokenService;
private readonly AppDbContext _db;
private readonly IPermissionService _permissions;
private readonly int _refreshTokenExpiryDays;
public AuthService(
UserManager<AppUser> userManager,
ITokenService tokenService,
AppDbContext db,
IPermissionService permissions,
IConfiguration config)
{
_userManager = userManager;
_tokenService = tokenService;
_db = db;
_permissions = permissions;
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
}
@@ -62,7 +65,7 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
return (BuildResponse(accessToken, user, roles), rawRefresh);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
}
// -------------------------------------------------------------------------
@@ -104,7 +107,7 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync();
return (BuildResponse(newAccess, user, roles), newRaw);
return (await BuildResponseAsync(newAccess, user, roles), newRaw);
}
// -------------------------------------------------------------------------
@@ -128,18 +131,23 @@ public class AuthService : IAuthService
// Private helpers
// -------------------------------------------------------------------------
private static LoginResponse BuildResponse(
private async Task<LoginResponse> BuildResponseAsync(
string accessToken, AppUser user, IList<string> roles)
=> new()
{
AccessToken = accessToken,
ExpiresIn = 15 * 60,
User = new UserInfo
User = await BuildUserInfoAsync(user, roles),
};
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
=> new()
{
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
},
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
};
}
+8
View File
@@ -1,4 +1,5 @@
using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
@@ -28,4 +29,11 @@ public interface IAuthService
/// Silently succeeds if the token is not found.
/// </summary>
Task LogoutAsync(string rawRefreshToken);
/// <summary>
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
/// after an admin edits the matrix, without forcing a re-login.
/// </summary>
Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles);
}
+236
View File
@@ -0,0 +1,236 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public interface IPermissionService
{
/// <summary>True if any of the given roles grants the module/action.</summary>
Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action);
/// <summary>Effective permissions for a user (union across roles). super_admin ⇒ all.</summary>
Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles);
/// <summary>Dense matrix (every role × every module) for the admin UI.</summary>
Task<PermissionMatrixDto> GetMatrixAsync();
/// <summary>Replaces a role's grants. Rejects super_admin. Invalidates the cache.</summary>
Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows);
/// <summary>Drops the cached matrix so the next check rebuilds from the database.</summary>
void Invalidate();
}
/// <summary>
/// Resolves the configurable RBAC matrix. Registered as a singleton; the in-memory
/// snapshot is shared across requests and rebuilt on demand. Database access goes
/// through a scoped <see cref="AppDbContext"/> obtained from <see cref="IServiceScopeFactory"/>.
/// </summary>
public class PermissionService : IPermissionService
{
private const string CacheKey = "rbac:matrix";
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMemoryCache _cache;
public PermissionService(IServiceScopeFactory scopeFactory, IMemoryCache cache)
{
_scopeFactory = scopeFactory;
_cache = cache;
}
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
{
var snapshot = await GetSnapshotAsync();
foreach (var role in roles)
{
if (snapshot.TryGetValue(role, out var modules)
&& modules.TryGetValue(module, out var actions)
&& Grants(actions, action))
{
return true;
}
}
return false;
}
public async Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles)
{
var roleList = roles.ToList();
if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole))
return AllModulesFull();
var snapshot = await GetSnapshotAsync();
var effective = new Dictionary<string, ModuleActions>();
foreach (var role in roleList)
{
if (!snapshot.TryGetValue(role, out var modules))
continue;
foreach (var (module, actions) in modules)
{
if (!effective.TryGetValue(module, out var merged))
effective[module] = merged = new ModuleActions();
merged.Read |= actions.Read;
merged.Write |= actions.Write;
merged.Delete |= actions.Delete;
merged.Approve |= actions.Approve;
}
}
// Only surface modules the user can actually touch.
return effective.Where(kvp => kvp.Value.Any)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
public async Task<PermissionMatrixDto> GetMatrixAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var roles = await db.Roles.OrderBy(r => r.Name).ToListAsync();
var perms = await db.RolePermissions.ToListAsync();
var byRoleId = perms.ToLookup(p => p.RoleId);
var rows = new List<RolePermissionRow>();
foreach (var role in roles)
{
var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole;
var existing = byRoleId[role.Id].ToDictionary(p => p.Module);
var moduleRows = new List<ModulePermissionDto>();
foreach (var module in Modules.All)
{
existing.TryGetValue(module, out var rp);
moduleRows.Add(new ModulePermissionDto
{
Module = module,
// super_admin is always-full (display only — never persisted).
CanRead = isSuperAdmin || (rp?.CanRead ?? false),
CanWrite = isSuperAdmin || (rp?.CanWrite ?? false),
CanDelete = isSuperAdmin || (rp?.CanDelete ?? false),
CanApprove = isSuperAdmin || (rp?.CanApprove ?? false),
});
}
rows.Add(new RolePermissionRow
{
RoleName = role.Name!,
Description = role.Description,
IsSuperAdmin = isSuperAdmin,
Modules = moduleRows,
});
}
return new PermissionMatrixDto
{
AllModules = Modules.All,
AllActions = PermissionActions.All,
Roles = rows,
};
}
public async Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows)
{
if (roleName == PermissionAuthorizationHandler.SuperAdminRole)
throw new InvalidOperationException("super_admin permissions are fixed and cannot be edited.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == roleName)
?? throw new KeyNotFoundException($"Role '{roleName}' not found.");
var existing = await db.RolePermissions
.Where(p => p.RoleId == role.Id)
.ToListAsync();
db.RolePermissions.RemoveRange(existing);
foreach (var row in rows)
{
if (!Modules.IsValid(row.Module))
continue;
if (!(row.CanRead || row.CanWrite || row.CanDelete || row.CanApprove))
continue; // don't store all-false rows
db.RolePermissions.Add(new RolePermission
{
RoleId = role.Id,
Module = row.Module,
CanRead = row.CanRead,
CanWrite = row.CanWrite,
CanDelete = row.CanDelete,
CanApprove = row.CanApprove,
});
}
await db.SaveChangesAsync();
Invalidate();
}
public void Invalidate() => _cache.Remove(CacheKey);
// ── Internals ────────────────────────────────────────────────────────────
/// <summary>roleName → (module → ModuleActions). Cached until invalidated.</summary>
private async Task<Dictionary<string, Dictionary<string, ModuleActions>>> GetSnapshotAsync()
{
if (_cache.TryGetValue(CacheKey, out Dictionary<string, Dictionary<string, ModuleActions>>? cached)
&& cached is not null)
{
return cached;
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var rows = await (
from rp in db.RolePermissions
join r in db.Roles on rp.RoleId equals r.Id
select new { RoleName = r.Name!, rp.Module, rp.CanRead, rp.CanWrite, rp.CanDelete, rp.CanApprove }
).ToListAsync();
var snapshot = new Dictionary<string, Dictionary<string, ModuleActions>>();
foreach (var row in rows)
{
if (!snapshot.TryGetValue(row.RoleName, out var modules))
snapshot[row.RoleName] = modules = new Dictionary<string, ModuleActions>();
modules[row.Module] = new ModuleActions
{
Read = row.CanRead,
Write = row.CanWrite,
Delete = row.CanDelete,
Approve = row.CanApprove,
};
}
_cache.Set(CacheKey, snapshot);
return snapshot;
}
private static bool Grants(ModuleActions actions, string action) => action switch
{
PermissionActions.Read => actions.Read,
PermissionActions.Write => actions.Write,
PermissionActions.Delete => actions.Delete,
PermissionActions.Approve => actions.Approve,
_ => false,
};
private static Dictionary<string, ModuleActions> AllModulesFull()
{
var all = new Dictionary<string, ModuleActions>();
foreach (var module in Modules.All)
all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true };
return all;
}
}
+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"
<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"
<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"
<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 *ngIf="showUserAdminSection">
<a *ngFor="let item of userAdminNavItems" class="nav-item" [class.active]="item.active"
</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"
<!-- 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>
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
</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. */