diff --git a/API/ROLAC.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs b/API/ROLAC.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs new file mode 100644 index 0000000..fcb5fed --- /dev/null +++ b/API/ROLAC.API.Tests/Authorization/PermissionAuthorizationHandlerTests.cs @@ -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 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(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>(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RoleWithPermission_Succeeds() + { + var permissions = new Mock(); + permissions.Setup(p => p.HasPermissionAsync(It.IsAny>(), 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(); + permissions.Setup(p => p.HasPermissionAsync(It.IsAny>(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write); + + var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object); + + Assert.False(succeeded); + } +} diff --git a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs index 075db9d..9f027f8 100644 --- a/API/ROLAC.API.Tests/Services/AuthServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/AuthServiceTests.cs @@ -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; } + /// IPermissionService mock: returns an empty effective-permission map. + private static Mock BuildPermissionService() + { + var svc = new Mock(); + svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny>())) + .ReturnsAsync(new Dictionary()); + return svc; + } + private static AuthService BuildSut( Mock> umMock, Mock tsMock, AppDbContext db) - => new(umMock.Object, tsMock.Object, db, BuildConfig()); + => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig()); // ----------------------------------------------------------------------- // Login tests diff --git a/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs b/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs new file mode 100644 index 0000000..dd6d115 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/PermissionServiceTests.cs @@ -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(); + + 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(opt => opt.UseInMemoryDatabase(dbName)); + var provider = services.BuildServiceProvider(); + + var scopeFactory = provider.GetRequiredService(); + 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( + () => 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( + () => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }])); + } +} diff --git a/API/ROLAC.API/Authorization/HasPermissionAttribute.cs b/API/ROLAC.API/Authorization/HasPermissionAttribute.cs new file mode 100644 index 0000000..dfe368b --- /dev/null +++ b/API/ROLAC.API/Authorization/HasPermissionAttribute.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ROLAC.API.Authorization; + +/// +/// Gates an action/controller on a configurable permission. Usage: +/// [HasPermission(Modules.Members, PermissionActions.Write)]. +/// Encodes the policy name PERM:<module>:<action>, which +/// turns into a . +/// +[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}"; + + /// Parses a policy name back into (module, action), or null if not a PERM policy. + 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)..]); + } +} diff --git a/API/ROLAC.API/Authorization/Modules.cs b/API/ROLAC.API/Authorization/Modules.cs new file mode 100644 index 0000000..3500194 --- /dev/null +++ b/API/ROLAC.API/Authorization/Modules.cs @@ -0,0 +1,62 @@ +namespace ROLAC.API.Authorization; + +/// +/// Canonical list of permission-controlled modules. The names are stored verbatim +/// in and used in [HasPermission] +/// attributes, so changing a string here is a breaking change requiring a data update. +/// +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"; + + /// All modules, in display order — drives the admin matrix UI. + public static readonly IReadOnlyList All = + [ + Members, + Users, + Givings, + GivingCategories, + Expenses, + ExpenseCategories, + OfferingSessions, + Ministries, + FinanceDashboard, + MonthlyStatements, + ChurchProfile, + Disbursements, + MealAttendance, + Permissions, + ]; + + public static bool IsValid(string module) => All.Contains(module); +} + +/// +/// 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.). +/// +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 All = [Read, Write, Delete, Approve]; + + public static bool IsValid(string action) => All.Contains(action); +} diff --git a/API/ROLAC.API/Authorization/PermissionAuthorizationHandler.cs b/API/ROLAC.API/Authorization/PermissionAuthorizationHandler.cs new file mode 100644 index 0000000..fbfd96a --- /dev/null +++ b/API/ROLAC.API/Authorization/PermissionAuthorizationHandler.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using ROLAC.API.Services; + +namespace ROLAC.API.Authorization; + +/// +/// Evaluates against the user's roles. +/// super_admin always passes (bypass); otherwise the requirement succeeds if +/// ANY of the user's roles grants the requested module/action (union across roles). +/// +public class PermissionAuthorizationHandler : AuthorizationHandler +{ + 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); + } +} diff --git a/API/ROLAC.API/Authorization/PermissionPolicyProvider.cs b/API/ROLAC.API/Authorization/PermissionPolicyProvider.cs new file mode 100644 index 0000000..2859255 --- /dev/null +++ b/API/ROLAC.API/Authorization/PermissionPolicyProvider.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace ROLAC.API.Authorization; + +/// +/// Materializes PERM:<module>:<action> policies on demand so we never +/// have to register every module/action combination at startup. Any other policy name +/// (including the default and Roles= policies) is delegated to the framework's +/// default provider, so existing [Authorize(Roles=...)] usages keep working. +/// +public class PermissionPolicyProvider : IAuthorizationPolicyProvider +{ + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public PermissionPolicyProvider(IOptions options) + => _fallback = new DefaultAuthorizationPolicyProvider(options); + + public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + public Task 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(policy); + } +} diff --git a/API/ROLAC.API/Authorization/PermissionRequirement.cs b/API/ROLAC.API/Authorization/PermissionRequirement.cs new file mode 100644 index 0000000..742c822 --- /dev/null +++ b/API/ROLAC.API/Authorization/PermissionRequirement.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Authorization; + +namespace ROLAC.API.Authorization; + +/// +/// Authorization requirement carrying the module + action a request needs. +/// Materialized on demand by from a policy +/// name of the form PERM:<module>:<action>. +/// +public class PermissionRequirement : IAuthorizationRequirement +{ + public string Module { get; } + public string Action { get; } + + public PermissionRequirement(string module, string action) + { + Module = module; + Action = action; + } +} diff --git a/API/ROLAC.API/Controllers/AuthController.cs b/API/ROLAC.API/Controllers/AuthController.cs index 71a17a5..e718c20 100644 --- a/API/ROLAC.API/Controllers/AuthController.cs +++ b/API/ROLAC.API/Controllers/AuthController.cs @@ -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 _userManager; private readonly IWebHostEnvironment _env; - public AuthController(IAuthService authService, IWebHostEnvironment env) + public AuthController( + IAuthService authService, UserManager 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 // ------------------------------------------------------------------------- /// - /// 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. /// [HttpGet("me")] - [Authorize] // no role restriction — just needs a valid JWT - public IActionResult GetMe() + [Authorize] + [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)] + public async Task 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) + // ------------------------------------------------------------------------- + + /// + /// 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. + /// + [HttpGet("claims")] + [Authorize] + public IActionResult GetClaims() { var claims = User.Claims .Select(c => new { c.Type, c.Value }) diff --git a/API/ROLAC.API/Controllers/ChurchProfileController.cs b/API/ROLAC.API/Controllers/ChurchProfileController.cs index 0355ad2..c972c3b 100644 --- a/API/ROLAC.API/Controllers/ChurchProfileController.cs +++ b/API/ROLAC.API/Controllers/ChurchProfileController.cs @@ -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 Get() => Ok(await _svc.GetAsync()); [HttpPut] + [HasPermission(Modules.ChurchProfile, PermissionActions.Write)] public async Task Update([FromBody] UpdateChurchProfileRequest r) { await _svc.UpdateAsync(r); diff --git a/API/ROLAC.API/Controllers/DisbursementsController.cs b/API/ROLAC.API/Controllers/DisbursementsController.cs index c047fe2..6a83495 100644 --- a/API/ROLAC.API/Controllers/DisbursementsController.cs +++ b/API/ROLAC.API/Controllers/DisbursementsController.cs @@ -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 GetApprovedUnpaid() => Ok(await _svc.GetApprovedUnpaidGroupedAsync()); [HttpPost("issue")] + [HasPermission(Modules.Disbursements, PermissionActions.Write)] public async Task 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 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 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 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 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 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 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 GetSignature(int id) { try diff --git a/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs index e039375..b209987 100644 --- a/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs +++ b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs @@ -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 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 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 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 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 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 DeactivateSub(int id) { try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } } diff --git a/API/ROLAC.API/Controllers/ExpensesController.cs b/API/ROLAC.API/Controllers/ExpensesController.cs index 28ffde6..8075da6 100644 --- a/API/ROLAC.API/Controllers/ExpensesController.cs +++ b/API/ROLAC.API/Controllers/ExpensesController.cs @@ -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 IExpenseService _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 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 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 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 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 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 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 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 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 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); } diff --git a/API/ROLAC.API/Controllers/FinanceDashboardController.cs b/API/ROLAC.API/Controllers/FinanceDashboardController.cs index ac174e1..5a0e06a 100644 --- a/API/ROLAC.API/Controllers/FinanceDashboardController.cs +++ b/API/ROLAC.API/Controllers/FinanceDashboardController.cs @@ -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; diff --git a/API/ROLAC.API/Controllers/GivingCategoriesController.cs b/API/ROLAC.API/Controllers/GivingCategoriesController.cs index 235492e..bb1ddbd 100644 --- a/API/ROLAC.API/Controllers/GivingCategoriesController.cs +++ b/API/ROLAC.API/Controllers/GivingCategoriesController.cs @@ -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 GetAll([FromQuery] bool includeInactive = false) => Ok(await _svc.GetAllAsync(includeInactive)); [HttpPost] + [HasPermission(Modules.GivingCategories, PermissionActions.Write)] public async Task 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 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 Deactivate(int id) { try { await _svc.DeactivateAsync(id); return NoContent(); } diff --git a/API/ROLAC.API/Controllers/GivingsController.cs b/API/ROLAC.API/Controllers/GivingsController.cs index 78d3fcd..7881626 100644 --- a/API/ROLAC.API/Controllers/GivingsController.cs +++ b/API/ROLAC.API/Controllers/GivingsController.cs @@ -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 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 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 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 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 Delete(int id) { try { await _svc.DeleteAsync(id); return NoContent(); } diff --git a/API/ROLAC.API/Controllers/MembersController.cs b/API/ROLAC.API/Controllers/MembersController.cs index 5288c32..952147c 100644 --- a/API/ROLAC.API/Controllers/MembersController.cs +++ b/API/ROLAC.API/Controllers/MembersController.cs @@ -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 /// GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false [HttpGet] - [Authorize(Roles = "super_admin,secretary,pastor")] + [HasPermission(Modules.Members, PermissionActions.Read)] public async Task GetPaged( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, @@ -26,7 +27,7 @@ public class MembersController : ControllerBase /// GET /api/members/{id} [HttpGet("{id:int}")] - [Authorize(Roles = "super_admin,secretary,pastor")] + [HasPermission(Modules.Members, PermissionActions.Read)] public async Task GetById(int id) { var dto = await _members.GetByIdAsync(id); @@ -35,7 +36,7 @@ public class MembersController : ControllerBase /// POST /api/members [HttpPost] - [Authorize(Roles = "super_admin,secretary")] + [HasPermission(Modules.Members, PermissionActions.Write)] public async Task Create([FromBody] CreateMemberRequest request) { var id = await _members.CreateAsync(request); @@ -44,7 +45,7 @@ public class MembersController : ControllerBase /// PUT /api/members/{id} [HttpPut("{id:int}")] - [Authorize(Roles = "super_admin,secretary")] + [HasPermission(Modules.Members, PermissionActions.Write)] public async Task Update(int id, [FromBody] UpdateMemberRequest request) { try { await _members.UpdateAsync(id, request); return NoContent(); } @@ -53,7 +54,7 @@ public class MembersController : ControllerBase /// DELETE /api/members/{id} — soft delete [HttpDelete("{id:int}")] - [Authorize(Roles = "super_admin,secretary")] + [HasPermission(Modules.Members, PermissionActions.Delete)] public async Task Delete(int id) { try { await _members.DeleteAsync(id); return NoContent(); } diff --git a/API/ROLAC.API/Controllers/MonthlyStatementsController.cs b/API/ROLAC.API/Controllers/MonthlyStatementsController.cs index 851c69d..fbbd685 100644 --- a/API/ROLAC.API/Controllers/MonthlyStatementsController.cs +++ b/API/ROLAC.API/Controllers/MonthlyStatementsController.cs @@ -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 GetAll([FromQuery] int? year = null) => Ok(await _svc.GetAllAsync(year)); [HttpGet("{id:int}")] + [HasPermission(Modules.MonthlyStatements, PermissionActions.Read)] public async Task 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 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 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 Finalize(int id) { try { await _svc.FinalizeAsync(id); return NoContent(); } diff --git a/API/ROLAC.API/Controllers/OfferingSessionsController.cs b/API/ROLAC.API/Controllers/OfferingSessionsController.cs index d9d575d..325b587 100644 --- a/API/ROLAC.API/Controllers/OfferingSessionsController.cs +++ b/API/ROLAC.API/Controllers/OfferingSessionsController.cs @@ -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 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 CheckDate([FromQuery] DateOnly date) => Ok(new { exists = await _svc.DateExistsAsync(date) }); [HttpGet("{id:int}")] + [HasPermission(Modules.OfferingSessions, PermissionActions.Read)] public async Task 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 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 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 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 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 GetProof(int id) { try @@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase } [HttpDelete("{id:int}/proof")] + [HasPermission(Modules.OfferingSessions, PermissionActions.Delete)] public async Task DeleteProof(int id) { try { await _svc.DeleteProofAsync(id); return NoContent(); } diff --git a/API/ROLAC.API/Controllers/PermissionsController.cs b/API/ROLAC.API/Controllers/PermissionsController.cs new file mode 100644 index 0000000..ec5d1b6 --- /dev/null +++ b/API/ROLAC.API/Controllers/PermissionsController.cs @@ -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; + +/// +/// Admin surface for the configurable RBAC matrix. Restricted to super_admin — +/// the role that governs who governs everyone else. +/// +[ApiController] +[Route("api/permissions")] +[Authorize(Roles = "super_admin")] +public class PermissionsController : ControllerBase +{ + private readonly IPermissionService _permissions; + public PermissionsController(IPermissionService permissions) => _permissions = permissions; + + /// GET /api/permissions — the full role × module matrix. + [HttpGet] + public async Task GetMatrix() => Ok(await _permissions.GetMatrixAsync()); + + /// GET /api/permissions/catalog — module + action names for the grid. + [HttpGet("catalog")] + public IActionResult GetCatalog() => Ok(new PermissionCatalogDto + { + Modules = Modules.All, + Actions = PermissionActions.All, + }); + + /// PUT /api/permissions/{roleName} — replaces a role's grants. + [HttpPut("{roleName}")] + public async Task 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 }); } + } +} diff --git a/API/ROLAC.API/Controllers/UsersController.cs b/API/ROLAC.API/Controllers/UsersController.cs index 9bf0e65..27ae49b 100644 --- a/API/ROLAC.API/Controllers/UsersController.cs +++ b/API/ROLAC.API/Controllers/UsersController.cs @@ -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 /// GET /api/users?page=1&pageSize=20&search=Chris [HttpGet] + [HasPermission(Modules.Users, PermissionActions.Read)] public async Task GetPaged( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, @@ -23,6 +25,7 @@ public class UsersController : ControllerBase /// GET /api/users/{id} [HttpGet("{id}")] + [HasPermission(Modules.Users, PermissionActions.Read)] public async Task 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. /// [HttpPost] + [HasPermission(Modules.Users, PermissionActions.Write)] public async Task Create([FromBody] CreateUserRequest request) { try @@ -49,6 +53,7 @@ public class UsersController : ControllerBase /// PUT /api/users/{id} — update email, roles, IsActive [HttpPut("{id}")] + [HasPermission(Modules.Users, PermissionActions.Write)] public async Task Update(string id, [FromBody] UpdateUserRequest request) { try { await _users.UpdateAsync(id, request); return NoContent(); } @@ -58,6 +63,7 @@ public class UsersController : ControllerBase /// DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete [HttpDelete("{id}")] + [HasPermission(Modules.Users, PermissionActions.Delete)] public async Task Deactivate(string id) { try { await _users.DeactivateAsync(id); return NoContent(); } @@ -66,6 +72,7 @@ public class UsersController : ControllerBase /// POST /api/users/{id}/reset-password — returns new temp password [HttpPost("{id}/reset-password")] + [HasPermission(Modules.Users, PermissionActions.Write)] public async Task ResetPassword(string id) { try diff --git a/API/ROLAC.API/DTOs/Auth/LoginResponse.cs b/API/ROLAC.API/DTOs/Auth/LoginResponse.cs index d7a83bc..8427e45 100644 --- a/API/ROLAC.API/DTOs/Auth/LoginResponse.cs +++ b/API/ROLAC.API/DTOs/Auth/LoginResponse.cs @@ -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 Roles { get; set; } = []; public string LanguagePreference { get; set; } = "en"; + + /// + /// Effective permissions (union across the user's roles), keyed by module name. + /// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side. + /// + public Dictionary Permissions { get; set; } = []; } diff --git a/API/ROLAC.API/DTOs/Permissions/PermissionDtos.cs b/API/ROLAC.API/DTOs/Permissions/PermissionDtos.cs new file mode 100644 index 0000000..08a6156 --- /dev/null +++ b/API/ROLAC.API/DTOs/Permissions/PermissionDtos.cs @@ -0,0 +1,53 @@ +namespace ROLAC.API.DTOs.Permissions; + +/// Effective action flags for one module (union across a user's roles). +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; +} + +/// One module's grant for a single role — used in the admin matrix and updates. +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; } +} + +/// One role's full row in the admin matrix (every module, dense). +public class RolePermissionRow +{ + public string RoleName { get; set; } = null!; + public string? Description { get; set; } + /// super_admin is shown read-only/full — it bypasses the matrix. + public bool IsSuperAdmin { get; set; } + public List Modules { get; set; } = []; +} + +/// GET /api/permissions — the whole matrix plus the catalog for grid headers. +public class PermissionMatrixDto +{ + public IReadOnlyList AllModules { get; set; } = []; + public IReadOnlyList AllActions { get; set; } = []; + public List Roles { get; set; } = []; +} + +/// GET /api/permissions/catalog — module + action names for building the UI. +public class PermissionCatalogDto +{ + public IReadOnlyList Modules { get; set; } = []; + public IReadOnlyList Actions { get; set; } = []; +} + +/// PUT /api/permissions/{roleName} — replaces a role's grants. +public class UpdateRolePermissionsRequest +{ + public List Modules { get; set; } = []; +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 240dc75..a30f14a 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext : IdentityDbContext public DbSet Checks => Set(); public DbSet CheckLines => Set(); public DbSet MealAttendances => Set(); + public DbSet RolePermissions => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -60,6 +61,18 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.Description).HasMaxLength(500); }); + // ── RolePermission (configurable RBAC matrix) ─────────────────────── + builder.Entity(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(entity => { diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 43e788c..d47e3c0 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -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 roleManager) { foreach (var (name, description) in Roles) @@ -159,6 +214,7 @@ public static class DbSeeder await SeedRolesAsync(roleManager); var db = services.GetRequiredService(); + await SeedRolePermissionsAsync(db); await SeedGivingCategoriesAsync(db); await SeedMinistriesAsync(db); await SeedExpenseCategoriesAsync(db); diff --git a/API/ROLAC.API/Entities/RolePermission.cs b/API/ROLAC.API/Entities/RolePermission.cs new file mode 100644 index 0000000..f5253c6 --- /dev/null +++ b/API/ROLAC.API/Entities/RolePermission.cs @@ -0,0 +1,25 @@ +namespace ROLAC.API.Entities; + +/// +/// 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. super_admin is never stored here — it bypasses +/// permission checks entirely (see PermissionAuthorizationHandler). +/// +public class RolePermission +{ + public int Id { get; set; } + + /// FK to AspNetRoles.Id. + public string RoleId { get; set; } = null!; + + /// Module constant name (see ). + 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!; +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 780c766..c64254f 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -1310,6 +1310,44 @@ namespace ROLAC.API.Migrations b.ToTable("RefreshTokens"); }); + modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanApprove") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanRead") + .HasColumnType("boolean"); + + b.Property("CanWrite") + .HasColumnType("boolean"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("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", 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"); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 466abf1..a8be930 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -135,6 +135,19 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +// --------------------------------------------------------------------------- +// Configurable role-based permissions (RBAC matrix) +// --------------------------------------------------------------------------- +builder.Services.AddMemoryCache(); +builder.Services.AddSingleton(); +builder.Services.AddAuthorization(); +// Dynamic policy provider materializes "PERM::" policies on demand; +// must be registered AFTER AddAuthorization so it overrides the default provider. +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + // Real-time hub for the live Sunday attendance counter. builder.Services.AddSignalR(); diff --git a/API/ROLAC.API/Services/AuthService.cs b/API/ROLAC.API/Services/AuthService.cs index 9edbfad..10d1817 100644 --- a/API/ROLAC.API/Services/AuthService.cs +++ b/API/ROLAC.API/Services/AuthService.cs @@ -12,17 +12,20 @@ public class AuthService : IAuthService private readonly UserManager _userManager; private readonly ITokenService _tokenService; private readonly AppDbContext _db; + private readonly IPermissionService _permissions; private readonly int _refreshTokenExpiryDays; public AuthService( UserManager 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 BuildResponseAsync( string accessToken, AppUser user, IList roles) => new() { AccessToken = accessToken, ExpiresIn = 15 * 60, - User = new UserInfo - { - Id = user.Id, - Email = user.Email!, - Roles = roles, - LanguagePreference = user.LanguagePreference, - }, + User = await BuildUserInfoAsync(user, roles), + }; + + /// Builds UserInfo including the effective permission map. Reused by /me. + public async Task BuildUserInfoAsync(AppUser user, IList roles) + => new() + { + Id = user.Id, + Email = user.Email!, + Roles = roles, + LanguagePreference = user.LanguagePreference, + Permissions = await _permissions.GetEffectivePermissionsAsync(roles), }; } diff --git a/API/ROLAC.API/Services/IAuthService.cs b/API/ROLAC.API/Services/IAuthService.cs index d9e3d7a..29b8e28 100644 --- a/API/ROLAC.API/Services/IAuthService.cs +++ b/API/ROLAC.API/Services/IAuthService.cs @@ -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. /// Task LogoutAsync(string rawRefreshToken); + + /// + /// 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. + /// + Task BuildUserInfoAsync(AppUser user, IList roles); } diff --git a/API/ROLAC.API/Services/PermissionService.cs b/API/ROLAC.API/Services/PermissionService.cs new file mode 100644 index 0000000..8182ca5 --- /dev/null +++ b/API/ROLAC.API/Services/PermissionService.cs @@ -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 +{ + /// True if any of the given roles grants the module/action. + Task HasPermissionAsync(IEnumerable roles, string module, string action); + + /// Effective permissions for a user (union across roles). super_admin ⇒ all. + Task> GetEffectivePermissionsAsync(IEnumerable roles); + + /// Dense matrix (every role × every module) for the admin UI. + Task GetMatrixAsync(); + + /// Replaces a role's grants. Rejects super_admin. Invalidates the cache. + Task UpsertRoleAsync(string roleName, IEnumerable rows); + + /// Drops the cached matrix so the next check rebuilds from the database. + void Invalidate(); +} + +/// +/// 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 obtained from . +/// +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 HasPermissionAsync(IEnumerable 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> GetEffectivePermissionsAsync(IEnumerable roles) + { + var roleList = roles.ToList(); + + if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole)) + return AllModulesFull(); + + var snapshot = await GetSnapshotAsync(); + var effective = new Dictionary(); + + 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 GetMatrixAsync() + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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(); + foreach (var role in roles) + { + var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole; + var existing = byRoleId[role.Id].ToDictionary(p => p.Module); + + var moduleRows = new List(); + 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 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(); + + 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 ──────────────────────────────────────────────────────────── + + /// roleName → (module → ModuleActions). Cached until invalidated. + private async Task>> GetSnapshotAsync() + { + if (_cache.TryGetValue(CacheKey, out Dictionary>? cached) + && cached is not null) + { + return cached; + } + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + 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>(); + foreach (var row in rows) + { + if (!snapshot.TryGetValue(row.RoleName, out var modules)) + snapshot[row.RoleName] = modules = new Dictionary(); + + 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 AllModulesFull() + { + var all = new Dictionary(); + foreach (var module in Modules.All) + all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true }; + return all; + } +} diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 7b95f8a..e9741b1 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -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' } }, }, ] }, diff --git a/APP/src/app/core/directives/has-permission.directive.ts b/APP/src/app/core/directives/has-permission.directive.ts new file mode 100644 index 0000000..2779cd6 --- /dev/null +++ b/APP/src/app/core/directives/has-permission.directive.ts @@ -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: + * + * + * + */ +@Directive({ + selector: '[appHasPermission]', + standalone: true, +}) +export class HasPermissionDirective implements OnInit, OnDestroy { + private requirement: PermissionRequirement | null = null; + private hasView = false; + private destroy$ = new Subject(); + + constructor( + private templateRef: TemplateRef, + 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; + } + } +} diff --git a/APP/src/app/core/guards/permission.guard.ts b/APP/src/app/core/guards/permission.guard.ts new file mode 100644 index 0000000..1416a73 --- /dev/null +++ b/APP/src/app/core/guards/permission.guard.ts @@ -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; + } +} diff --git a/APP/src/app/core/models/permission.model.ts b/APP/src/app/core/models/permission.model.ts new file mode 100644 index 0000000..60e9ec9 --- /dev/null +++ b/APP/src/app/core/models/permission.model.ts @@ -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[]; +} diff --git a/APP/src/app/core/services/permission.service.ts b/APP/src/app/core/services/permission.service.ts new file mode 100644 index 0000000..106ac52 --- /dev/null +++ b/APP/src/app/core/services/permission.service.ts @@ -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 on . */ + 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 { + return this.http.get(`${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); + } +} diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html index 88b73ae..8b0d468 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html @@ -85,10 +85,16 @@ diff --git a/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.html b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.html new file mode 100644 index 0000000..eee723b --- /dev/null +++ b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.html @@ -0,0 +1,76 @@ +
+
+

Role Permissions

+ +
+ +

+ Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply + immediately after saving — no re-login required. super_admin always has + full access and cannot be edited. +

+ +
+ {{ savedMessage }} +
+ +
+ Loading... +
+ + +
+ +
+ Description + {{ selectedDescription }} +
+
+ +
+ super_admin bypasses all permission checks — every module is shown as fully granted and is read-only. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.scss b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.scss new file mode 100644 index 0000000..1f452b1 --- /dev/null +++ b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; +} + +input[type='checkbox'] { + width: 18px; + height: 18px; + cursor: pointer; +} + +input[type='checkbox']:disabled { + cursor: not-allowed; +} diff --git a/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.ts b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.ts new file mode 100644 index 0000000..b026f03 --- /dev/null +++ b/APP/src/app/features/permissions/pages/permissions-page/permissions-page.component.ts @@ -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); + } +} diff --git a/APP/src/app/features/permissions/services/permission-api.service.ts b/APP/src/app/features/permissions/services/permission-api.service.ts new file mode 100644 index 0000000..9aabbae --- /dev/null +++ b/APP/src/app/features/permissions/services/permission-api.service.ts @@ -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 { + return this.http.get(this.endpoint); + } + + /** PUT /api/permissions/{roleName} — replaces a role's grants. */ + updateRole(roleName: string, request: UpdateRolePermissionsRequest): Observable { + return this.http.put(`${this.endpoint}/${encodeURIComponent(roleName)}`, request); + } +} diff --git a/APP/src/app/portals/user-portal/user-portal.component.html b/APP/src/app/portals/user-portal/user-portal.component.html index c582598..4fc9261 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.html +++ b/APP/src/app/portals/user-portal/user-portal.component.html @@ -32,50 +32,56 @@ diff --git a/APP/src/app/portals/user-portal/user-portal.component.scss b/APP/src/app/portals/user-portal/user-portal.component.scss index 67a59f3..a6a46cf 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.scss +++ b/APP/src/app/portals/user-portal/user-portal.component.scss @@ -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; diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index 9182ef5..68f7922 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -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', diff --git a/APP/src/app/shared/services/auth.service.ts b/APP/src/app/shared/services/auth.service.ts index bc3386a..bf92cc1 100644 --- a/APP/src/app/shared/services/auth.service.ts +++ b/APP/src/app/shared/services/auth.service.ts @@ -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; } /** Matches the C# LoginResponse DTO exactly. */