Add role control
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Moq;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Authorization;
|
||||
|
||||
public class PermissionAuthorizationHandlerTests
|
||||
{
|
||||
private static ClaimsPrincipal UserWithRoles(params string[] roles)
|
||||
{
|
||||
var claims = roles.Select(role => new Claim("role", role));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
|
||||
}
|
||||
|
||||
private static async Task<bool> EvaluateAsync(
|
||||
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
|
||||
{
|
||||
var handler = new PermissionAuthorizationHandler(permissions);
|
||||
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
|
||||
await handler.HandleAsync(context);
|
||||
return context.HasSucceeded;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
|
||||
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
|
||||
|
||||
Assert.True(succeeded);
|
||||
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoleWithPermission_Succeeds()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>();
|
||||
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
|
||||
.ReturnsAsync(true);
|
||||
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
|
||||
|
||||
Assert.True(succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RoleWithoutPermission_Fails()
|
||||
{
|
||||
var permissions = new Mock<IPermissionService>();
|
||||
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
|
||||
|
||||
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
|
||||
|
||||
Assert.False(succeeded);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
@@ -72,11 +73,20 @@ public class AuthServiceTests
|
||||
return svc;
|
||||
}
|
||||
|
||||
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
|
||||
private static Mock<IPermissionService> BuildPermissionService()
|
||||
{
|
||||
var svc = new Mock<IPermissionService>();
|
||||
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
|
||||
.ReturnsAsync(new Dictionary<string, ModuleActions>());
|
||||
return svc;
|
||||
}
|
||||
|
||||
private static AuthService BuildSut(
|
||||
Mock<UserManager<AppUser>> umMock,
|
||||
Mock<ITokenService> tsMock,
|
||||
AppDbContext db)
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
||||
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Login tests
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class PermissionServiceTests
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// Harness: a real PermissionService backed by an in-memory EF database.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public required ServiceProvider Provider { get; init; }
|
||||
public required PermissionService Service { get; init; }
|
||||
|
||||
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
|
||||
{
|
||||
using var scope = Provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
|
||||
db.Roles.Add(role);
|
||||
foreach (var permission in permissions)
|
||||
{
|
||||
permission.RoleId = role.Id;
|
||||
db.RolePermissions.Add(permission);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
{
|
||||
var dbName = Guid.NewGuid().ToString();
|
||||
var services = new ServiceCollection();
|
||||
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
return new Harness
|
||||
{
|
||||
Provider = provider,
|
||||
Service = new PermissionService(scopeFactory, cache),
|
||||
};
|
||||
}
|
||||
|
||||
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
|
||||
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HasPermissionAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
|
||||
|
||||
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
|
||||
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
|
||||
|
||||
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
|
||||
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
|
||||
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
|
||||
|
||||
// User holds both roles — should get the union.
|
||||
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
|
||||
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
|
||||
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GetEffectivePermissionsAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
|
||||
{
|
||||
var h = BuildHarness(); // no rows seeded at all
|
||||
|
||||
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
|
||||
|
||||
Assert.Equal(Modules.All.Count, effective.Count);
|
||||
foreach (var module in Modules.All)
|
||||
{
|
||||
Assert.True(effective[module].Read);
|
||||
Assert.True(effective[module].Write);
|
||||
Assert.True(effective[module].Delete);
|
||||
Assert.True(effective[module].Approve);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
|
||||
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
|
||||
|
||||
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
|
||||
|
||||
Assert.True(effective[Modules.Expenses].Read);
|
||||
Assert.True(effective[Modules.Expenses].Write);
|
||||
Assert.True(effective[Modules.Expenses].Approve);
|
||||
Assert.False(effective[Modules.Expenses].Delete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("member"); // role exists but no grants
|
||||
|
||||
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
|
||||
|
||||
Assert.Empty(effective);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Caching / invalidation via UpsertRoleAsync
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
|
||||
|
||||
// Prime the cache with the original snapshot.
|
||||
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
|
||||
|
||||
// Grant write; UpsertRoleAsync must invalidate the cache.
|
||||
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
|
||||
{
|
||||
Module = Modules.Givings, CanRead = true, CanWrite = true,
|
||||
}]);
|
||||
|
||||
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertRole_SuperAdmin_Throws()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
await h.SeedRoleAsync("super_admin");
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertRole_UnknownRole_Throws()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(
|
||||
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Gates an action/controller on a configurable permission. Usage:
|
||||
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
|
||||
/// Encodes the policy name <c>PERM:<module>:<action></c>, which
|
||||
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class HasPermissionAttribute : AuthorizeAttribute
|
||||
{
|
||||
public const string PolicyPrefix = "PERM:";
|
||||
|
||||
public HasPermissionAttribute(string module, string action)
|
||||
=> Policy = $"{PolicyPrefix}{module}:{action}";
|
||||
|
||||
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
|
||||
public static (string Module, string Action)? Parse(string policyName)
|
||||
{
|
||||
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
|
||||
return null;
|
||||
|
||||
var body = policyName[PolicyPrefix.Length..];
|
||||
var split = body.IndexOf(':');
|
||||
if (split <= 0 || split == body.Length - 1)
|
||||
return null;
|
||||
|
||||
return (body[..split], body[(split + 1)..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical list of permission-controlled modules. The names are stored verbatim
|
||||
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
|
||||
/// attributes, so changing a string here is a breaking change requiring a data update.
|
||||
/// </summary>
|
||||
public static class Modules
|
||||
{
|
||||
public const string Members = "Members";
|
||||
public const string Users = "Users";
|
||||
public const string Givings = "Givings";
|
||||
public const string GivingCategories = "GivingCategories";
|
||||
public const string Expenses = "Expenses";
|
||||
public const string ExpenseCategories = "ExpenseCategories";
|
||||
public const string OfferingSessions = "OfferingSessions";
|
||||
public const string Ministries = "Ministries";
|
||||
public const string FinanceDashboard = "FinanceDashboard";
|
||||
public const string MonthlyStatements = "MonthlyStatements";
|
||||
public const string ChurchProfile = "ChurchProfile";
|
||||
public const string Disbursements = "Disbursements";
|
||||
public const string MealAttendance = "MealAttendance";
|
||||
public const string Permissions = "Permissions";
|
||||
|
||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Members,
|
||||
Users,
|
||||
Givings,
|
||||
GivingCategories,
|
||||
Expenses,
|
||||
ExpenseCategories,
|
||||
OfferingSessions,
|
||||
Ministries,
|
||||
FinanceDashboard,
|
||||
MonthlyStatements,
|
||||
ChurchProfile,
|
||||
Disbursements,
|
||||
MealAttendance,
|
||||
Permissions,
|
||||
];
|
||||
|
||||
public static bool IsValid(string module) => All.Contains(module);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
|
||||
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
|
||||
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
|
||||
/// </summary>
|
||||
public static class PermissionActions
|
||||
{
|
||||
public const string Read = "Read";
|
||||
public const string Write = "Write";
|
||||
public const string Delete = "Delete";
|
||||
public const string Approve = "Approve";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
|
||||
|
||||
public static bool IsValid(string action) => All.Contains(action);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
|
||||
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
|
||||
/// ANY of the user's roles grants the requested module/action (union across roles).
|
||||
/// </summary>
|
||||
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
{
|
||||
public const string SuperAdminRole = "super_admin";
|
||||
|
||||
private readonly IPermissionService _permissions;
|
||||
|
||||
public PermissionAuthorizationHandler(IPermissionService permissions)
|
||||
=> _permissions = permissions;
|
||||
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context, PermissionRequirement requirement)
|
||||
{
|
||||
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
|
||||
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||
|
||||
if (roles.Contains(SuperAdminRole))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Materializes <c>PERM:<module>:<action></c> policies on demand so we never
|
||||
/// have to register every module/action combination at startup. Any other policy name
|
||||
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
|
||||
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
|
||||
/// </summary>
|
||||
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
|
||||
{
|
||||
private readonly DefaultAuthorizationPolicyProvider _fallback;
|
||||
|
||||
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
var parsed = HasPermissionAttribute.Parse(policyName);
|
||||
if (parsed is null)
|
||||
return _fallback.GetPolicyAsync(policyName);
|
||||
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
|
||||
.Build();
|
||||
|
||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ROLAC.API.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization requirement carrying the module + action a request needs.
|
||||
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
|
||||
/// name of the form <c>PERM:<module>:<action></c>.
|
||||
/// </summary>
|
||||
public class PermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public string Module { get; }
|
||||
public string Action { get; }
|
||||
|
||||
public PermissionRequirement(string module, string action)
|
||||
{
|
||||
Module = module;
|
||||
Action = action;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
|
||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
||||
public AuthController(
|
||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
{
|
||||
_authService = authService;
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
}
|
||||
|
||||
@@ -79,17 +85,43 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
||||
// GET /api/auth/me
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
||||
/// Returns the current user's identity, roles, and effective permissions.
|
||||
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||
/// to refresh what the UI shows — without forcing a re-login.
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize] // no role restriction — just needs a valid JWT
|
||||
public IActionResult GetMe()
|
||||
[Authorize]
|
||||
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMe()
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var user = await _userManager.FindByIdAsync(userId);
|
||||
if (user is null || !user.IsActive)
|
||||
return Unauthorized();
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
return Ok(await _authService.BuildUserInfoAsync(user, roles));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/claims (dev-only diagnostic)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
|
||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
|
||||
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
|
||||
/// </summary>
|
||||
[HttpGet("claims")]
|
||||
[Authorize]
|
||||
public IActionResult GetClaims()
|
||||
{
|
||||
var claims = User.Claims
|
||||
.Select(c => new { c.Type, c.Value })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/church-profile")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class ChurchProfileController : ControllerBase
|
||||
{
|
||||
private readonly IChurchProfileService _svc;
|
||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||
|
||||
[HttpPut]
|
||||
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||
{
|
||||
await _svc.UpdateAsync(r);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Disbursement;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/disbursements")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class DisbursementsController : ControllerBase
|
||||
{
|
||||
private readonly IDisbursementService _svc;
|
||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("approved-unpaid")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetApprovedUnpaid()
|
||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||
|
||||
[HttpPost("issue")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||
{
|
||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetRegister(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||
|
||||
[HttpGet("checks/{id:int}")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/void")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||
{
|
||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderPdfAsync(id);
|
||||
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||
{
|
||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("checks/{id:int}/acknowledge")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||
[RequestSizeLimit(5_242_880)]
|
||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
||||
{
|
||||
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("checks/{id:int}/signature")]
|
||||
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetSignature(int id)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost("groups")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("groups/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateGroup(int id)
|
||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPost("subcategories")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpPut("subcategories/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
|
||||
[HttpDelete("subcategories/{id:int}")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeactivateSub(int id)
|
||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||
}
|
||||
|
||||
@@ -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<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||
|
||||
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
|
||||
private async Task<bool> CanManageAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||
|
||||
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
|
||||
private async Task<bool> CanViewAllAsync() =>
|
||||
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
|
||||
|
||||
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||
private string CurrentUserId() =>
|
||||
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
|
||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||
{
|
||||
if (!CanViewAll()) return Forbid();
|
||||
if (!await CanViewAllAsync()) return Forbid();
|
||||
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||
}
|
||||
|
||||
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
if (dto is null) return NotFound();
|
||||
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); }
|
||||
try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
|
||||
try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||
}
|
||||
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/approve")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Approve(int id)
|
||||
{
|
||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reject")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||
{
|
||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/pay")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||
{
|
||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
|
||||
await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
||||
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.stream, result.Value.contentType);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/finance-dashboard")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||
public class FinanceDashboardController : ControllerBase
|
||||
{
|
||||
private readonly IFinanceDashboardService _svc;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/giving-categories")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class GivingCategoriesController : ControllerBase
|
||||
{
|
||||
private readonly IGivingCategoryService _svc;
|
||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(int id)
|
||||
{
|
||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/givings")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class GivingsController : ControllerBase
|
||||
{
|
||||
private readonly IGivingService _svc;
|
||||
public GivingsController(IGivingService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
||||
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Members;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||
[HttpGet]
|
||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/members/{id}</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _members.GetByIdAsync(id);
|
||||
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>POST /api/members</summary>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||
{
|
||||
var id = await _members.CreateAsync(request);
|
||||
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>PUT /api/members/{id}</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||
{
|
||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
|
||||
|
||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
[Authorize(Roles = "super_admin,secretary")]
|
||||
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/monthly-statements")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class MonthlyStatementsController : ControllerBase
|
||||
{
|
||||
private readonly IMonthlyStatementService _svc;
|
||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||
=> Ok(await _svc.GetAllAsync(year));
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||
{
|
||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/finalize")]
|
||||
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Finalize(int id)
|
||||
{
|
||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Giving;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/offering-sessions")]
|
||||
[Authorize(Roles = "finance,super_admin")]
|
||||
[Authorize]
|
||||
public class OfferingSessionsController : ControllerBase
|
||||
{
|
||||
private readonly IOfferingSessionService _svc;
|
||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||
|
||||
[HttpGet("check-date")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var dto = await _svc.GetByIdAsync(id);
|
||||
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try
|
||||
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:int}/reopen")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||
public async Task<IActionResult> Reopen(int id)
|
||||
{
|
||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||
{
|
||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
||||
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||
|
||||
[HttpPost("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||
{
|
||||
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetProof(int id)
|
||||
{
|
||||
try
|
||||
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}/proof")]
|
||||
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> DeleteProof(int id)
|
||||
{
|
||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
|
||||
/// the role that governs who governs everyone else.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/permissions")]
|
||||
[Authorize(Roles = "super_admin")]
|
||||
public class PermissionsController : ControllerBase
|
||||
{
|
||||
private readonly IPermissionService _permissions;
|
||||
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
|
||||
|
||||
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
|
||||
|
||||
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
|
||||
[HttpGet("catalog")]
|
||||
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
|
||||
{
|
||||
Modules = Modules.All,
|
||||
Actions = PermissionActions.All,
|
||||
});
|
||||
|
||||
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
|
||||
[HttpPut("{roleName}")]
|
||||
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _permissions.UpsertRoleAsync(roleName, request.Modules);
|
||||
return NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Users;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize(Roles = "super_admin")]
|
||||
[Authorize]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly IUserManagementService _users;
|
||||
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetPaged(
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>GET /api/users/{id}</summary>
|
||||
[HttpGet("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetById(string id)
|
||||
{
|
||||
var dto = await _users.GetByIdAsync(id);
|
||||
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
|
||||
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||
{
|
||||
try
|
||||
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||
[HttpPut("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||||
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||
[HttpDelete("{id}")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(string id)
|
||||
{
|
||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||||
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
|
||||
|
||||
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||
[HttpPost("{id}/reset-password")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> ResetPassword(string id)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
|
||||
namespace ROLAC.API.DTOs.Auth;
|
||||
|
||||
public class LoginResponse
|
||||
@@ -17,4 +19,10 @@ public class UserInfo
|
||||
public string Email { get; set; } = null!;
|
||||
public IList<string> Roles { get; set; } = [];
|
||||
public string LanguagePreference { get; set; } = "en";
|
||||
|
||||
/// <summary>
|
||||
/// Effective permissions (union across the user's roles), keyed by module name.
|
||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||
/// </summary>
|
||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ROLAC.API.DTOs.Permissions;
|
||||
|
||||
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
|
||||
public class ModuleActions
|
||||
{
|
||||
public bool Read { get; set; }
|
||||
public bool Write { get; set; }
|
||||
public bool Delete { get; set; }
|
||||
public bool Approve { get; set; }
|
||||
|
||||
public bool Any => Read || Write || Delete || Approve;
|
||||
}
|
||||
|
||||
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
|
||||
public class ModulePermissionDto
|
||||
{
|
||||
public string Module { get; set; } = null!;
|
||||
public bool CanRead { get; set; }
|
||||
public bool CanWrite { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
public bool CanApprove { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
|
||||
public class RolePermissionRow
|
||||
{
|
||||
public string RoleName { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
|
||||
public bool IsSuperAdmin { get; set; }
|
||||
public List<ModulePermissionDto> Modules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
|
||||
public class PermissionMatrixDto
|
||||
{
|
||||
public IReadOnlyList<string> AllModules { get; set; } = [];
|
||||
public IReadOnlyList<string> AllActions { get; set; } = [];
|
||||
public List<RolePermissionRow> Roles { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
|
||||
public class PermissionCatalogDto
|
||||
{
|
||||
public IReadOnlyList<string> Modules { get; set; } = [];
|
||||
public IReadOnlyList<string> Actions { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
|
||||
public class UpdateRolePermissionsRequest
|
||||
{
|
||||
public List<ModulePermissionDto> Modules { get; set; } = [];
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
@@ -60,6 +61,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.Description).HasMaxLength(500);
|
||||
});
|
||||
|
||||
// ── RolePermission (configurable RBAC matrix) ───────────────────────
|
||||
builder.Entity<RolePermission>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
|
||||
// One row per (role, module).
|
||||
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
|
||||
entity.HasOne(e => e.Role).WithMany()
|
||||
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||
builder.Entity<FamilyUnit>(entity =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
@@ -62,6 +63,60 @@ public static class DbSeeder
|
||||
("visitor", "Visitor — public pages only"),
|
||||
];
|
||||
|
||||
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
|
||||
// existed before the configurable RBAC system, so day-one behavior is unchanged.
|
||||
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
|
||||
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
|
||||
// later edits via the Permissions UI are never clobbered on restart.
|
||||
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
|
||||
[
|
||||
// Secretary — manages member data.
|
||||
("secretary", Modules.Members, true, true, true, false),
|
||||
|
||||
// Pastor — read-only overview of members and all expenses.
|
||||
("pastor", Modules.Members, true, false, false, false),
|
||||
("pastor", Modules.Expenses, true, false, false, false),
|
||||
|
||||
// Finance — full control over the finance modules.
|
||||
("finance", Modules.Givings, true, true, true, false),
|
||||
("finance", Modules.GivingCategories, true, true, true, false),
|
||||
("finance", Modules.Expenses, true, true, true, true),
|
||||
("finance", Modules.ExpenseCategories, true, true, true, false),
|
||||
("finance", Modules.OfferingSessions, true, true, true, true),
|
||||
("finance", Modules.FinanceDashboard, true, false, false, false),
|
||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
];
|
||||
|
||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||
{
|
||||
var rolesByName = await db.Roles
|
||||
.Where(r => r.Name != null)
|
||||
.ToDictionaryAsync(r => r.Name!, r => r.Id);
|
||||
|
||||
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
|
||||
{
|
||||
if (!rolesByName.TryGetValue(role, out var roleId))
|
||||
continue;
|
||||
|
||||
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
|
||||
if (exists)
|
||||
continue; // never clobber an admin's edit
|
||||
|
||||
db.RolePermissions.Add(new RolePermission
|
||||
{
|
||||
RoleId = roleId,
|
||||
Module = module,
|
||||
CanRead = read,
|
||||
CanWrite = write,
|
||||
CanDelete = delete,
|
||||
CanApprove = approve,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||
{
|
||||
foreach (var (name, description) in Roles)
|
||||
@@ -159,6 +214,7 @@ public static class DbSeeder
|
||||
await SeedRolesAsync(roleManager);
|
||||
|
||||
var db = services.GetRequiredService<AppDbContext>();
|
||||
await SeedRolePermissionsAsync(db);
|
||||
await SeedGivingCategoriesAsync(db);
|
||||
await SeedMinistriesAsync(db);
|
||||
await SeedExpenseCategoriesAsync(db);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (Role × Module). Stores what the role may do on that module.
|
||||
/// The effective permission for a user is the boolean OR of these flags across
|
||||
/// all of the user's roles. <c>super_admin</c> is never stored here — it bypasses
|
||||
/// permission checks entirely (see PermissionAuthorizationHandler).
|
||||
/// </summary>
|
||||
public class RolePermission
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>FK to AspNetRoles.Id.</summary>
|
||||
public string RoleId { get; set; } = null!;
|
||||
|
||||
/// <summary>Module constant name (see <see cref="Authorization.Modules"/>).</summary>
|
||||
public string Module { get; set; } = null!;
|
||||
|
||||
public bool CanRead { get; set; }
|
||||
public bool CanWrite { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
public bool CanApprove { get; set; }
|
||||
|
||||
public AppRole Role { get; set; } = null!;
|
||||
}
|
||||
@@ -1310,6 +1310,44 @@ namespace ROLAC.API.Migrations
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("CanApprove")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanDelete")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanRead")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("CanWrite")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Module")
|
||||
.IsRequired()
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId", "Module")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("RolePermissions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||
@@ -1481,6 +1519,17 @@ namespace ROLAC.API.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
|
||||
{
|
||||
b.HasOne("ROLAC.API.Entities.AppRole", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||
{
|
||||
b.Navigation("RefreshTokens");
|
||||
|
||||
@@ -135,6 +135,19 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configurable role-based permissions (RBAC matrix)
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IPermissionService, PermissionService>();
|
||||
builder.Services.AddAuthorization();
|
||||
// Dynamic policy provider materializes "PERM:<module>:<action>" policies on demand;
|
||||
// must be registered AFTER AddAuthorization so it overrides the default provider.
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
|
||||
ROLAC.API.Authorization.PermissionPolicyProvider>();
|
||||
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
|
||||
ROLAC.API.Authorization.PermissionAuthorizationHandler>();
|
||||
|
||||
// Real-time hub for the live Sunday attendance counter.
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
|
||||
@@ -12,17 +12,20 @@ public class AuthService : IAuthService
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ITokenService _tokenService;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPermissionService _permissions;
|
||||
private readonly int _refreshTokenExpiryDays;
|
||||
|
||||
public AuthService(
|
||||
UserManager<AppUser> userManager,
|
||||
ITokenService tokenService,
|
||||
AppDbContext db,
|
||||
IPermissionService permissions,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_tokenService = tokenService;
|
||||
_db = db;
|
||||
_permissions = permissions;
|
||||
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ public class AuthService : IAuthService
|
||||
await _userManager.UpdateAsync(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return (BuildResponse(accessToken, user, roles), rawRefresh);
|
||||
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -104,7 +107,7 @@ public class AuthService : IAuthService
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return (BuildResponse(newAccess, user, roles), newRaw);
|
||||
return (await BuildResponseAsync(newAccess, user, roles), newRaw);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -128,18 +131,23 @@ public class AuthService : IAuthService
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static LoginResponse BuildResponse(
|
||||
private async Task<LoginResponse> BuildResponseAsync(
|
||||
string accessToken, AppUser user, IList<string> roles)
|
||||
=> new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
ExpiresIn = 15 * 60,
|
||||
User = new UserInfo
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email!,
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
},
|
||||
User = await BuildUserInfoAsync(user, roles),
|
||||
};
|
||||
|
||||
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
|
||||
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
|
||||
=> new()
|
||||
{
|
||||
Id = user.Id,
|
||||
Email = user.Email!,
|
||||
Roles = roles,
|
||||
LanguagePreference = user.LanguagePreference,
|
||||
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
@@ -28,4 +29,11 @@ public interface IAuthService
|
||||
/// Silently succeeds if the token is not found.
|
||||
/// </summary>
|
||||
Task LogoutAsync(string rawRefreshToken);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
|
||||
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
|
||||
/// after an admin edits the matrix, without forcing a re-login.
|
||||
/// </summary>
|
||||
Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Permissions;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IPermissionService
|
||||
{
|
||||
/// <summary>True if any of the given roles grants the module/action.</summary>
|
||||
Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action);
|
||||
|
||||
/// <summary>Effective permissions for a user (union across roles). super_admin ⇒ all.</summary>
|
||||
Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles);
|
||||
|
||||
/// <summary>Dense matrix (every role × every module) for the admin UI.</summary>
|
||||
Task<PermissionMatrixDto> GetMatrixAsync();
|
||||
|
||||
/// <summary>Replaces a role's grants. Rejects super_admin. Invalidates the cache.</summary>
|
||||
Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows);
|
||||
|
||||
/// <summary>Drops the cached matrix so the next check rebuilds from the database.</summary>
|
||||
void Invalidate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the configurable RBAC matrix. Registered as a singleton; the in-memory
|
||||
/// snapshot is shared across requests and rebuilt on demand. Database access goes
|
||||
/// through a scoped <see cref="AppDbContext"/> obtained from <see cref="IServiceScopeFactory"/>.
|
||||
/// </summary>
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private const string CacheKey = "rbac:matrix";
|
||||
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public PermissionService(IServiceScopeFactory scopeFactory, IMemoryCache cache)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
|
||||
{
|
||||
var snapshot = await GetSnapshotAsync();
|
||||
|
||||
foreach (var role in roles)
|
||||
{
|
||||
if (snapshot.TryGetValue(role, out var modules)
|
||||
&& modules.TryGetValue(module, out var actions)
|
||||
&& Grants(actions, action))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles)
|
||||
{
|
||||
var roleList = roles.ToList();
|
||||
|
||||
if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole))
|
||||
return AllModulesFull();
|
||||
|
||||
var snapshot = await GetSnapshotAsync();
|
||||
var effective = new Dictionary<string, ModuleActions>();
|
||||
|
||||
foreach (var role in roleList)
|
||||
{
|
||||
if (!snapshot.TryGetValue(role, out var modules))
|
||||
continue;
|
||||
|
||||
foreach (var (module, actions) in modules)
|
||||
{
|
||||
if (!effective.TryGetValue(module, out var merged))
|
||||
effective[module] = merged = new ModuleActions();
|
||||
|
||||
merged.Read |= actions.Read;
|
||||
merged.Write |= actions.Write;
|
||||
merged.Delete |= actions.Delete;
|
||||
merged.Approve |= actions.Approve;
|
||||
}
|
||||
}
|
||||
|
||||
// Only surface modules the user can actually touch.
|
||||
return effective.Where(kvp => kvp.Value.Any)
|
||||
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
||||
}
|
||||
|
||||
public async Task<PermissionMatrixDto> GetMatrixAsync()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var roles = await db.Roles.OrderBy(r => r.Name).ToListAsync();
|
||||
var perms = await db.RolePermissions.ToListAsync();
|
||||
var byRoleId = perms.ToLookup(p => p.RoleId);
|
||||
|
||||
var rows = new List<RolePermissionRow>();
|
||||
foreach (var role in roles)
|
||||
{
|
||||
var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole;
|
||||
var existing = byRoleId[role.Id].ToDictionary(p => p.Module);
|
||||
|
||||
var moduleRows = new List<ModulePermissionDto>();
|
||||
foreach (var module in Modules.All)
|
||||
{
|
||||
existing.TryGetValue(module, out var rp);
|
||||
moduleRows.Add(new ModulePermissionDto
|
||||
{
|
||||
Module = module,
|
||||
// super_admin is always-full (display only — never persisted).
|
||||
CanRead = isSuperAdmin || (rp?.CanRead ?? false),
|
||||
CanWrite = isSuperAdmin || (rp?.CanWrite ?? false),
|
||||
CanDelete = isSuperAdmin || (rp?.CanDelete ?? false),
|
||||
CanApprove = isSuperAdmin || (rp?.CanApprove ?? false),
|
||||
});
|
||||
}
|
||||
|
||||
rows.Add(new RolePermissionRow
|
||||
{
|
||||
RoleName = role.Name!,
|
||||
Description = role.Description,
|
||||
IsSuperAdmin = isSuperAdmin,
|
||||
Modules = moduleRows,
|
||||
});
|
||||
}
|
||||
|
||||
return new PermissionMatrixDto
|
||||
{
|
||||
AllModules = Modules.All,
|
||||
AllActions = PermissionActions.All,
|
||||
Roles = rows,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows)
|
||||
{
|
||||
if (roleName == PermissionAuthorizationHandler.SuperAdminRole)
|
||||
throw new InvalidOperationException("super_admin permissions are fixed and cannot be edited.");
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == roleName)
|
||||
?? throw new KeyNotFoundException($"Role '{roleName}' not found.");
|
||||
|
||||
var existing = await db.RolePermissions
|
||||
.Where(p => p.RoleId == role.Id)
|
||||
.ToListAsync();
|
||||
db.RolePermissions.RemoveRange(existing);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!Modules.IsValid(row.Module))
|
||||
continue;
|
||||
if (!(row.CanRead || row.CanWrite || row.CanDelete || row.CanApprove))
|
||||
continue; // don't store all-false rows
|
||||
|
||||
db.RolePermissions.Add(new RolePermission
|
||||
{
|
||||
RoleId = role.Id,
|
||||
Module = row.Module,
|
||||
CanRead = row.CanRead,
|
||||
CanWrite = row.CanWrite,
|
||||
CanDelete = row.CanDelete,
|
||||
CanApprove = row.CanApprove,
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
public void Invalidate() => _cache.Remove(CacheKey);
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>roleName → (module → ModuleActions). Cached until invalidated.</summary>
|
||||
private async Task<Dictionary<string, Dictionary<string, ModuleActions>>> GetSnapshotAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(CacheKey, out Dictionary<string, Dictionary<string, ModuleActions>>? cached)
|
||||
&& cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var rows = await (
|
||||
from rp in db.RolePermissions
|
||||
join r in db.Roles on rp.RoleId equals r.Id
|
||||
select new { RoleName = r.Name!, rp.Module, rp.CanRead, rp.CanWrite, rp.CanDelete, rp.CanApprove }
|
||||
).ToListAsync();
|
||||
|
||||
var snapshot = new Dictionary<string, Dictionary<string, ModuleActions>>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!snapshot.TryGetValue(row.RoleName, out var modules))
|
||||
snapshot[row.RoleName] = modules = new Dictionary<string, ModuleActions>();
|
||||
|
||||
modules[row.Module] = new ModuleActions
|
||||
{
|
||||
Read = row.CanRead,
|
||||
Write = row.CanWrite,
|
||||
Delete = row.CanDelete,
|
||||
Approve = row.CanApprove,
|
||||
};
|
||||
}
|
||||
|
||||
_cache.Set(CacheKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static bool Grants(ModuleActions actions, string action) => action switch
|
||||
{
|
||||
PermissionActions.Read => actions.Read,
|
||||
PermissionActions.Write => actions.Write,
|
||||
PermissionActions.Delete => actions.Delete,
|
||||
PermissionActions.Approve => actions.Approve,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static Dictionary<string, ModuleActions> AllModulesFull()
|
||||
{
|
||||
var all = new Dictionary<string, ModuleActions>();
|
||||
foreach (var module in Modules.All)
|
||||
all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true };
|
||||
return all;
|
||||
}
|
||||
}
|
||||
+37
-24
@@ -3,7 +3,9 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo
|
||||
import { LoginPage } from './features/login-page/login-page';
|
||||
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
||||
import { AuthGuard } from './core/guards/auth.guard';
|
||||
import { RoleGuard } from './core/guards/role.guard';
|
||||
import { PermissionGuard } from './core/guards/permission.guard';
|
||||
import { PermissionModules } from './core/models/permission.model';
|
||||
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
|
||||
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||||
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
|
||||
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
|
||||
@@ -38,73 +40,84 @@ export const routes: Routes = [
|
||||
children: [
|
||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||
{ path: 'dashboard', component: DashboardComponent },
|
||||
{ path: 'admin/members', component: MembersPageComponent },
|
||||
{
|
||||
path: 'admin/members',
|
||||
component: MembersPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Members, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/users',
|
||||
component: UsersPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Users, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'admin/permissions',
|
||||
component: PermissionsPageComponent,
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/dashboard',
|
||||
component: FinanceDashboardPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/giving-categories',
|
||||
component: GivingCategoriesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/givings',
|
||||
component: GivingsPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/offering-session',
|
||||
component: OfferingSessionPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||
},
|
||||
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
|
||||
{
|
||||
path: 'finance/expenses',
|
||||
component: ExpensesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/expense-categories',
|
||||
component: ExpenseCategoriesPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/monthly-statement',
|
||||
component: MonthlyStatementPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/disbursements',
|
||||
component: DisbursementPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/check-register',
|
||||
component: CheckRegisterPageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
},
|
||||
{
|
||||
path: 'finance/church-profile',
|
||||
component: ChurchProfilePageComponent,
|
||||
canActivate: [RoleGuard],
|
||||
data: { roles: ['finance', 'super_admin'] },
|
||||
canActivate: [PermissionGuard],
|
||||
data: { permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Directive, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { AuthService } from '../../shared/services/auth.service';
|
||||
import { PermissionService } from '../services/permission.service';
|
||||
import { PermissionAction, PermissionRequirement } from '../models/permission.model';
|
||||
|
||||
/**
|
||||
* Structural directive that renders its content only if the current user has the
|
||||
* required permission. Re-evaluates when the current user changes (e.g. after a
|
||||
* matrix edit + refresh). Usage:
|
||||
*
|
||||
* <button *appHasPermission="{ module: 'Expenses', action: 'write' }">Edit</button>
|
||||
* <button *appHasPermission="['Expenses', 'approve']">Approve</button>
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appHasPermission]',
|
||||
standalone: true,
|
||||
})
|
||||
export class HasPermissionDirective implements OnInit, OnDestroy {
|
||||
private requirement: PermissionRequirement | null = null;
|
||||
private hasView = false;
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private templateRef: TemplateRef<unknown>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private permissions: PermissionService,
|
||||
private auth: AuthService
|
||||
) { }
|
||||
|
||||
@Input()
|
||||
set appHasPermission(value: PermissionRequirement | [string, PermissionAction]) {
|
||||
if (Array.isArray(value)) {
|
||||
this.requirement = { module: value[0], action: value[1] };
|
||||
} else {
|
||||
this.requirement = value;
|
||||
}
|
||||
this.updateView();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// React to login/logout/refresh so visibility stays in sync with permissions.
|
||||
this.auth.currentUser$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(() => this.updateView());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
private updateView(): void {
|
||||
const allowed = this.requirement
|
||||
? this.permissions.can(this.requirement.module, this.requirement.action)
|
||||
: false;
|
||||
|
||||
if (allowed && !this.hasView) {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
this.hasView = true;
|
||||
} else if (!allowed && this.hasView) {
|
||||
this.viewContainer.clear();
|
||||
this.hasView = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
|
||||
import { AuthService } from '../../shared/services/auth.service';
|
||||
import { PermissionService } from '../services/permission.service';
|
||||
import { PermissionRequirement } from '../models/permission.model';
|
||||
|
||||
/**
|
||||
* Route guard for the configurable permission system. Reads
|
||||
* route.data['permission'] = { module, action } and blocks navigation if the
|
||||
* current user lacks it (redirecting to the dashboard). The parent route's
|
||||
* AuthGuard guarantees the session is restored before children activate.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(
|
||||
private permissions: PermissionService,
|
||||
private auth: AuthService,
|
||||
private router: Router
|
||||
) { }
|
||||
|
||||
canActivate(route: ActivatedRouteSnapshot): boolean {
|
||||
const required = route.data['permission'] as PermissionRequirement | undefined;
|
||||
if (!required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const allowed = this.permissions.can(required.module, required.action);
|
||||
if (!allowed) {
|
||||
this.router.navigate(['/user-portal/dashboard']);
|
||||
}
|
||||
return allowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Effective action flags for one module — mirrors the C# ModuleActions DTO. */
|
||||
export interface ModuleActions {
|
||||
read: boolean;
|
||||
write: boolean;
|
||||
delete: boolean;
|
||||
approve: boolean;
|
||||
/** Computed server-side (true if any flag is set). */
|
||||
any?: boolean;
|
||||
}
|
||||
|
||||
export type PermissionAction = 'read' | 'write' | 'delete' | 'approve';
|
||||
|
||||
/**
|
||||
* Canonical module names — must match the C# ROLAC.API.Authorization.Modules constants
|
||||
* (PascalCase). Used by the permission directive, guard, nav, and admin page.
|
||||
*/
|
||||
export const PermissionModules = {
|
||||
Members: 'Members',
|
||||
Users: 'Users',
|
||||
Givings: 'Givings',
|
||||
GivingCategories: 'GivingCategories',
|
||||
Expenses: 'Expenses',
|
||||
ExpenseCategories: 'ExpenseCategories',
|
||||
OfferingSessions: 'OfferingSessions',
|
||||
Ministries: 'Ministries',
|
||||
FinanceDashboard: 'FinanceDashboard',
|
||||
MonthlyStatements: 'MonthlyStatements',
|
||||
ChurchProfile: 'ChurchProfile',
|
||||
Disbursements: 'Disbursements',
|
||||
MealAttendance: 'MealAttendance',
|
||||
Permissions: 'Permissions',
|
||||
} as const;
|
||||
|
||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||
export interface PermissionRequirement {
|
||||
module: string;
|
||||
action: PermissionAction;
|
||||
}
|
||||
|
||||
// ── Admin matrix DTOs (mirror C# DTOs.Permissions) ────────────────────────────
|
||||
|
||||
export interface ModulePermissionDto {
|
||||
module: string;
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
canApprove: boolean;
|
||||
}
|
||||
|
||||
export interface RolePermissionRow {
|
||||
roleName: string;
|
||||
description?: string;
|
||||
isSuperAdmin: boolean;
|
||||
modules: ModulePermissionDto[];
|
||||
}
|
||||
|
||||
export interface PermissionMatrixDto {
|
||||
allModules: string[];
|
||||
allActions: string[];
|
||||
roles: RolePermissionRow[];
|
||||
}
|
||||
|
||||
export interface UpdateRolePermissionsRequest {
|
||||
modules: ModulePermissionDto[];
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||
import { ApiConfigService } from './api-config.service';
|
||||
import { PermissionAction } from '../models/permission.model';
|
||||
|
||||
const SUPER_ADMIN = 'super_admin';
|
||||
|
||||
/**
|
||||
* Reads the current user's effective permissions (delivered on the UserInfo payload)
|
||||
* and answers can(module, action). super_admin always passes. This is a UX mirror —
|
||||
* the backend remains the authoritative permission boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionService {
|
||||
constructor(
|
||||
private auth: AuthService,
|
||||
private http: HttpClient,
|
||||
private apiConfig: ApiConfigService
|
||||
) { }
|
||||
|
||||
/** True if the current user may perform <action> on <module>. */
|
||||
can(module: string, action: PermissionAction): boolean {
|
||||
const user = this.auth.getCurrentUser();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
if (user.roles?.includes(SUPER_ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
const moduleActions = user.permissions?.[this.normalizeKey(module)];
|
||||
return !!moduleActions && !!moduleActions[action];
|
||||
}
|
||||
|
||||
canRead(module: string): boolean { return this.can(module, 'read'); }
|
||||
canWrite(module: string): boolean { return this.can(module, 'write'); }
|
||||
canDelete(module: string): boolean { return this.can(module, 'delete'); }
|
||||
canApprove(module: string): boolean { return this.can(module, 'approve'); }
|
||||
|
||||
/**
|
||||
* Re-fetches the current user (with fresh permissions) from GET /api/auth/me.
|
||||
* Call after an admin edits the matrix so the UI reflects the change without
|
||||
* a re-login. Returns the updated user, or null on failure.
|
||||
*/
|
||||
refresh(): Observable<UserInfo | null> {
|
||||
return this.http.get<UserInfo>(`${this.apiConfig.authUrl}/me`).pipe(
|
||||
tap(user => this.auth.setCurrentUser(user)),
|
||||
map(user => user),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module names are stored PascalCase in code but arrive as camelCase dictionary
|
||||
* keys (server's DictionaryKeyPolicy). Lowercase the first character to match.
|
||||
*/
|
||||
private normalizeKey(module: string): string {
|
||||
if (!module) {
|
||||
return module;
|
||||
}
|
||||
return module.charAt(0).toLowerCase() + module.slice(1);
|
||||
}
|
||||
}
|
||||
+6
@@ -85,10 +85,16 @@
|
||||
<!-- Reimbursement mode: receipt file input -->
|
||||
<ng-container *ngIf="mode === 'reimbursement'">
|
||||
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
|
||||
<!--
|
||||
Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed)
|
||||
from bubbling up to the host, where it would collide with this component's
|
||||
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
||||
-->
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
(change)="onFileSelected($event)"
|
||||
(cancel)="$event.stopPropagation()"
|
||||
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
||||
</label>
|
||||
</ng-container>
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<div class="k-p-4">
|
||||
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
|
||||
<h2 class="k-m-0">Role Permissions</h2>
|
||||
<button kendoButton themeColor="primary"
|
||||
[disabled]="!selectedRole || isSuperAdminSelected || isSaving"
|
||||
(click)="save()">
|
||||
{{ isSaving ? 'Saving...' : 'Save Changes' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="k-mb-4" style="color:#666">
|
||||
Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply
|
||||
immediately after saving — no re-login required. <strong>super_admin</strong> always has
|
||||
full access and cannot be edited.
|
||||
</p>
|
||||
|
||||
<div *ngIf="savedMessage" class="k-mb-3 k-p-2"
|
||||
style="background:#e8f5e9;border-radius:4px;color:#2e7d32">
|
||||
{{ savedMessage }}
|
||||
</div>
|
||||
|
||||
<div *ngIf="isLoading" class="k-mb-3">
|
||||
<kendo-loader type="infinite-spinner"></kendo-loader> Loading...
|
||||
</div>
|
||||
|
||||
<!-- Role selector -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-1 k-mb-4">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span style="font-weight:600">Role</span>
|
||||
<kendo-dropdownlist
|
||||
[data]="roleNames"
|
||||
[value]="selectedRole"
|
||||
(valueChange)="selectRole($event)"
|
||||
[valuePrimitive]="true">
|
||||
</kendo-dropdownlist>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1" *ngIf="selectedDescription">
|
||||
<span style="font-weight:600">Description</span>
|
||||
<span style="padding-top:6px;color:#555">{{ selectedDescription }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="isSuperAdminSelected" class="k-mb-3 k-p-2"
|
||||
style="background:#fff3e0;border-radius:4px;color:#e65100">
|
||||
super_admin bypasses all permission checks — every module is shown as fully granted and is read-only.
|
||||
</div>
|
||||
|
||||
<!-- Module × action matrix for the selected role -->
|
||||
<kendo-grid [data]="rows" [height]="520" *ngIf="selectedRole">
|
||||
<kendo-grid-column field="module" title="Module" [width]="220"></kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Read" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<input type="checkbox" [(ngModel)]="dataItem.canRead" [disabled]="isSuperAdminSelected" />
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Write" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<input type="checkbox" [(ngModel)]="dataItem.canWrite" [disabled]="isSuperAdminSelected" />
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Delete" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<input type="checkbox" [(ngModel)]="dataItem.canDelete" [disabled]="isSuperAdminSelected" />
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Approve" [width]="100">
|
||||
<ng-template kendoGridCellTemplate let-dataItem>
|
||||
<input type="checkbox" [(ngModel)]="dataItem.canApprove" [disabled]="isSuperAdminSelected" />
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
</kendo-grid>
|
||||
</div>
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type='checkbox']:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { GridModule } from '@progress/kendo-angular-grid';
|
||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
|
||||
import { PermissionApiService } from '../../services/permission-api.service';
|
||||
import { PermissionService } from '../../../../core/services/permission.service';
|
||||
import {
|
||||
ModulePermissionDto,
|
||||
PermissionMatrixDto,
|
||||
RolePermissionRow,
|
||||
} from '../../../../core/models/permission.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-permissions-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, IndicatorsModule,
|
||||
],
|
||||
templateUrl: './permissions-page.component.html',
|
||||
styleUrls: ['./permissions-page.component.scss'],
|
||||
})
|
||||
export class PermissionsPageComponent implements OnInit {
|
||||
matrix: PermissionMatrixDto | null = null;
|
||||
roleNames: string[] = [];
|
||||
|
||||
selectedRole: string | null = null;
|
||||
selectedDescription: string | null = null;
|
||||
isSuperAdminSelected = false;
|
||||
|
||||
/** Editable copy of the selected role's per-module grants. */
|
||||
rows: ModulePermissionDto[] = [];
|
||||
|
||||
isLoading = false;
|
||||
isSaving = false;
|
||||
savedMessage: string | null = null;
|
||||
|
||||
constructor(
|
||||
private api: PermissionApiService,
|
||||
private permissions: PermissionService
|
||||
) { }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMatrix();
|
||||
}
|
||||
|
||||
loadMatrix(): void {
|
||||
this.isLoading = true;
|
||||
this.api.getMatrix().subscribe({
|
||||
next: matrix => {
|
||||
this.matrix = matrix;
|
||||
this.roleNames = matrix.roles.map(role => role.roleName);
|
||||
// Preserve the current selection across reloads, else pick the first editable role.
|
||||
const keep = this.selectedRole && this.roleNames.includes(this.selectedRole)
|
||||
? this.selectedRole
|
||||
: matrix.roles.find(role => !role.isSuperAdmin)?.roleName ?? this.roleNames[0] ?? null;
|
||||
this.selectRole(keep);
|
||||
this.isLoading = false;
|
||||
},
|
||||
error: () => { this.isLoading = false; },
|
||||
});
|
||||
}
|
||||
|
||||
selectRole(roleName: string | null): void {
|
||||
this.selectedRole = roleName;
|
||||
this.savedMessage = null;
|
||||
const row = this.findRow(roleName);
|
||||
this.selectedDescription = row?.description ?? null;
|
||||
this.isSuperAdminSelected = row?.isSuperAdmin ?? false;
|
||||
// Clone so edits aren't committed until Save.
|
||||
this.rows = (row?.modules ?? []).map(module => ({ ...module }));
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.selectedRole || this.isSuperAdminSelected) {
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
this.api.updateRole(this.selectedRole, { modules: this.rows }).subscribe({
|
||||
next: () => {
|
||||
this.isSaving = false;
|
||||
this.savedMessage = `Saved permissions for "${this.selectedRole}".`;
|
||||
// Refresh the matrix and the current user's own permissions (in case they edited their effect).
|
||||
this.permissions.refresh().subscribe();
|
||||
this.loadMatrix();
|
||||
},
|
||||
error: () => { this.isSaving = false; },
|
||||
});
|
||||
}
|
||||
|
||||
private findRow(roleName: string | null): RolePermissionRow | undefined {
|
||||
if (!roleName || !this.matrix) {
|
||||
return undefined;
|
||||
}
|
||||
return this.matrix.roles.find(role => role.roleName === roleName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||
import {
|
||||
PermissionMatrixDto,
|
||||
UpdateRolePermissionsRequest,
|
||||
} from '../../../core/models/permission.model';
|
||||
|
||||
/** Admin API for the configurable RBAC matrix (super_admin only on the server). */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PermissionApiService {
|
||||
private readonly endpoint: string;
|
||||
|
||||
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||
this.endpoint = apiConfig.getApiUrl('permissions');
|
||||
}
|
||||
|
||||
/** GET /api/permissions — the full role × module matrix. */
|
||||
getMatrix(): Observable<PermissionMatrixDto> {
|
||||
return this.http.get<PermissionMatrixDto>(this.endpoint);
|
||||
}
|
||||
|
||||
/** PUT /api/permissions/{roleName} — replaces a role's grants. */
|
||||
updateRole(roleName: string, request: UpdateRolePermissionsRequest): Observable<void> {
|
||||
return this.http.put<void>(`${this.endpoint}/${encodeURIComponent(roleName)}`, request);
|
||||
}
|
||||
}
|
||||
@@ -32,50 +32,56 @@
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Quick search / filter -->
|
||||
<div class="nav-search" *ngIf="!sidebarCollapsed">
|
||||
<kendo-svgicon [icon]="searchIcon" class="nav-search-icon"></kendo-svgicon>
|
||||
<input type="text" class="nav-search-input" placeholder="Search pages..."
|
||||
[(ngModel)]="searchQuery" aria-label="Search pages">
|
||||
<button type="button" class="nav-search-clear" *ngIf="searchQuery" (click)="clearSearch()"
|
||||
title="Clear search" aria-label="Clear search">
|
||||
<kendo-svgicon [icon]="clearIcon"></kendo-svgicon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Main</h4>
|
||||
<a *ngFor="let item of mainNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let item of mainNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Personal</h4>
|
||||
<a *ngFor="let item of personalNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 *ngIf="!sidebarCollapsed">Management</h4>
|
||||
<a *ngFor="let item of managementNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let item of personalNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||
<a *ngFor="let item of memberAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
<ng-container *ngIf="showUserAdminSection">
|
||||
<a *ngFor="let item of userAdminNavItems" class="nav-item" [class.active]="item.active"
|
||||
<ng-container *ngFor="let item of memberAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let item of userAdminNavItems">
|
||||
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
@@ -87,13 +93,46 @@
|
||||
|
||||
<div class="nav-section" *ngIf="showFinanceSection">
|
||||
<h4 *ngIf="!sidebarCollapsed">Finance</h4>
|
||||
<a *ngFor="let item of financeNavItems" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||
</a>
|
||||
|
||||
<!-- Collapsed sidebar: flat icon-only list, no group headers -->
|
||||
<ng-container *ngIf="sidebarCollapsed">
|
||||
<ng-container *ngFor="let group of financeGroups">
|
||||
<ng-container *ngFor="let item of group.items">
|
||||
<a *ngIf="canShow(item)" class="nav-item" [class.active]="item.active"
|
||||
[title]="item.text" (click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<!-- Expanded sidebar: collapsible groups -->
|
||||
<ng-container *ngIf="!sidebarCollapsed">
|
||||
<ng-container *ngFor="let group of financeGroups">
|
||||
<div class="nav-group" *ngIf="groupVisible(group)">
|
||||
<button type="button" class="nav-group-header"
|
||||
[class.expanded]="group.expanded || searchQuery"
|
||||
(click)="toggleGroup(group)">
|
||||
<span class="nav-group-title">{{ group.text }}</span>
|
||||
<kendo-svgicon class="nav-group-chevron" [icon]="chevronDownIcon"></kendo-svgicon>
|
||||
</button>
|
||||
<div class="nav-group-items" *ngIf="group.expanded || searchQuery">
|
||||
<ng-container *ngFor="let item of group.items">
|
||||
<a class="nav-item nav-item-nested" [class.active]="item.active"
|
||||
*ngIf="isVisible(item)" [title]="item.text"
|
||||
(click)="navigateTo(item.path)">
|
||||
<div class="nav-icon">
|
||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||
</div>
|
||||
<span>{{ item.text }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -200,6 +200,70 @@
|
||||
max-height: calc(100vh - 200px); // Account for header and footer
|
||||
}
|
||||
|
||||
// Quick search / filter box
|
||||
.nav-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0 1.25rem 1.5rem;
|
||||
|
||||
.nav-search-icon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
font-size: 0.85rem;
|
||||
color: #1f2937;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #1e40af;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-search-clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
kendo-svgicon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #1e40af;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -213,6 +277,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Collapsible finance group
|
||||
.nav-group {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.nav-group-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #4b5563;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.nav-group-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-group-chevron {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #9ca3af;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(30, 64, 175, 0.06);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
&.expanded .nav-group-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group-items {
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Nested item inside a group — indent the icon to show hierarchy
|
||||
.nav-item.nav-item-nested {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IconsModule } from '@progress/kendo-angular-icons';
|
||||
import {
|
||||
SVGIcon,
|
||||
homeIcon,
|
||||
calendarIcon,
|
||||
userIcon,
|
||||
groupIcon,
|
||||
usersOutlineIcon,
|
||||
bedOutlineIcon,
|
||||
pillsOutlineIcon,
|
||||
graphIcon,
|
||||
buildingsOutlineIcon,
|
||||
banknoteOutlineIcon,
|
||||
questionCircleIcon,
|
||||
dollarIcon,
|
||||
categorizeIcon,
|
||||
moneyExchangeIcon,
|
||||
fileReportIcon,
|
||||
walletOutlineIcon,
|
||||
handIcon,
|
||||
searchIcon,
|
||||
xIcon,
|
||||
chevronDownIcon,
|
||||
lockIcon,
|
||||
} from '@progress/kendo-svg-icons';
|
||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
||||
import { PermissionService } from '../../core/services/permission.service';
|
||||
import { PermissionAction, PermissionModules } from '../../core/models/permission.model';
|
||||
import { Subject, takeUntil, filter } from 'rxjs';
|
||||
|
||||
interface NavItem {
|
||||
@@ -30,6 +32,15 @@ interface NavItem {
|
||||
icon: SVGIcon;
|
||||
path: string;
|
||||
active?: boolean;
|
||||
/** When set, the item is shown only if the user has this permission. */
|
||||
permission?: { module: string; action: PermissionAction };
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
text: string;
|
||||
icon?: SVGIcon;
|
||||
items: NavItem[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -37,6 +48,7 @@ interface NavItem {
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterModule,
|
||||
RouterOutlet,
|
||||
IconsModule,
|
||||
@@ -49,57 +61,76 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
isMobile = false;
|
||||
currentUser: UserInfo | null = null;
|
||||
currentPageTitle = 'Dashboard';
|
||||
unreadMessages = 3;
|
||||
unreadNotifications = 2;
|
||||
|
||||
public searchQuery = '';
|
||||
|
||||
public homeIcon: SVGIcon = homeIcon;
|
||||
public calendarIcon: SVGIcon = calendarIcon;
|
||||
public peopleIcon: SVGIcon = usersOutlineIcon;
|
||||
public bedIcon: SVGIcon = bedOutlineIcon;
|
||||
public userIcon: SVGIcon = userIcon;
|
||||
public pillIcon: SVGIcon = pillsOutlineIcon;
|
||||
public chartIcon: SVGIcon = graphIcon;
|
||||
public buildingIcon: SVGIcon = buildingsOutlineIcon;
|
||||
public creditCardIcon: SVGIcon = banknoteOutlineIcon;
|
||||
public supportIcon: SVGIcon = questionCircleIcon;
|
||||
public searchIcon: SVGIcon = searchIcon;
|
||||
public clearIcon: SVGIcon = xIcon;
|
||||
public chevronDownIcon: SVGIcon = chevronDownIcon;
|
||||
|
||||
public mainNavItems: NavItem[] = [
|
||||
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
|
||||
// { text: 'Schedule', icon: this.calendarIcon, path: '/user-portal/schedule' },
|
||||
// { text: 'Patients', icon: this.peopleIcon, path: '/user-portal/patients' },
|
||||
];
|
||||
|
||||
public managementNavItems: NavItem[] = [
|
||||
// { text: 'Staff', icon: this.userIcon, path: '/user-portal/staff' },
|
||||
// { text: 'Pharmacy', icon: this.pillIcon, path: '/user-portal/pharmacy' },
|
||||
// { text: 'Reports', icon: this.chartIcon, path: '/user-portal/reports' },1124
|
||||
// { text: 'Departments', icon: this.buildingIcon, path: '/user-portal/departments' },
|
||||
// { text: 'Payments', icon: this.creditCardIcon, path: '/user-portal/payments' },
|
||||
];
|
||||
|
||||
public supportNavItems: NavItem[] = [
|
||||
{ text: 'Support', icon: this.supportIcon, path: '/user-portal/support' },
|
||||
];
|
||||
|
||||
public memberAdminNavItems: NavItem[] = [
|
||||
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members' },
|
||||
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members',
|
||||
permission: { module: PermissionModules.Members, action: 'read' } },
|
||||
];
|
||||
|
||||
public userAdminNavItems: NavItem[] = [
|
||||
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' },
|
||||
{ text: 'User Management', icon: userIcon, path: '/user-portal/admin/users',
|
||||
permission: { module: PermissionModules.Users, action: 'read' } },
|
||||
{ text: 'Role Permissions', icon: lockIcon, path: '/user-portal/admin/permissions',
|
||||
permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||
];
|
||||
|
||||
public financeNavItems: NavItem[] = [
|
||||
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard' },
|
||||
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session' },
|
||||
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings' },
|
||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' },
|
||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' },
|
||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' },
|
||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' },
|
||||
public financeGroups: NavGroup[] = [
|
||||
{
|
||||
text: 'Overview',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard',
|
||||
permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement',
|
||||
permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Income',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session',
|
||||
permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings',
|
||||
permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories',
|
||||
permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Expenses',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses',
|
||||
permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories',
|
||||
permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',
|
||||
permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Settings',
|
||||
expanded: false,
|
||||
items: [
|
||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile',
|
||||
permission: { module: PermissionModules.ChurchProfile, action: 'read' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
public personalNavItems: NavItem[] = [
|
||||
@@ -114,6 +145,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private permissions: PermissionService,
|
||||
private router: Router
|
||||
) { }
|
||||
|
||||
@@ -148,13 +180,22 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(user => {
|
||||
this.currentUser = user;
|
||||
const roles = user?.roles ?? [];
|
||||
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
|
||||
this.showUserAdminSection = roles.includes('super_admin');
|
||||
this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin');
|
||||
// Section visibility is derived from effective permissions (super_admin → all).
|
||||
this.showMemberAdminSection = this.memberAdminNavItems.some(item => this.canShow(item));
|
||||
this.showUserAdminSection = this.userAdminNavItems.some(item => this.canShow(item));
|
||||
this.showFinanceSection = this.financeGroups
|
||||
.some(group => group.items.some(item => this.canShow(item)));
|
||||
});
|
||||
}
|
||||
|
||||
/** True if a nav item should be shown — items without a permission are always visible. */
|
||||
public canShow(item: NavItem): boolean {
|
||||
if (!item.permission) {
|
||||
return true;
|
||||
}
|
||||
return this.permissions.can(item.permission.module, item.permission.action);
|
||||
}
|
||||
|
||||
private setupRouteSubscription(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
@@ -176,13 +217,13 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private updateActiveStates(currentUrl: string): void {
|
||||
const financeItems: NavItem[] = [];
|
||||
this.financeGroups.forEach(group => financeItems.push(...group.items));
|
||||
const allItems = [
|
||||
...this.mainNavItems,
|
||||
...this.managementNavItems,
|
||||
...this.supportNavItems,
|
||||
...this.memberAdminNavItems,
|
||||
...this.userAdminNavItems,
|
||||
...this.financeNavItems,
|
||||
...financeItems,
|
||||
...this.personalNavItems,
|
||||
];
|
||||
allItems.forEach(item => (item.active = false));
|
||||
@@ -191,6 +232,45 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
if (activeItem) {
|
||||
activeItem.active = true;
|
||||
}
|
||||
|
||||
// Auto-expand the finance group that contains the active page so the
|
||||
// current location is visible on load/navigation.
|
||||
const activeGroup = this.financeGroups.find(group =>
|
||||
group.items.some(item => item.active)
|
||||
);
|
||||
if (activeGroup) {
|
||||
activeGroup.expanded = true;
|
||||
}
|
||||
}
|
||||
|
||||
public toggleGroup(group: NavGroup): void {
|
||||
group.expanded = !group.expanded;
|
||||
}
|
||||
|
||||
public matchesSearch(text: string): boolean {
|
||||
const query = this.searchQuery.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return text.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
public groupHasMatch(group: NavGroup): boolean {
|
||||
return group.items.some(item => this.matchesSearch(item.text));
|
||||
}
|
||||
|
||||
/** Combined search + permission filter for a single nav item. */
|
||||
public isVisible(item: NavItem): boolean {
|
||||
return this.matchesSearch(item.text) && this.canShow(item);
|
||||
}
|
||||
|
||||
/** True if a finance group has at least one visible (permitted + matching) item. */
|
||||
public groupVisible(group: NavGroup): boolean {
|
||||
return group.items.some(item => this.isVisible(item));
|
||||
}
|
||||
|
||||
public clearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
}
|
||||
|
||||
private updatePageTitle(): void {
|
||||
@@ -222,6 +302,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
||||
'settings': 'Settings',
|
||||
'admin/members': 'Member Management',
|
||||
'admin/users': 'User Management',
|
||||
'admin/permissions': 'Role Permissions',
|
||||
'finance/dashboard': 'Finance Dashboard',
|
||||
'finance/offering-session': 'Sunday Offering Entry',
|
||||
'finance/givings': 'Givings',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
||||
import { ApiConfigService } from '../../core/services/api-config.service';
|
||||
import { ModuleActions } from '../../core/models/permission.model';
|
||||
|
||||
// ── Public interfaces ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +13,11 @@ export interface UserInfo {
|
||||
email: string;
|
||||
roles: string[];
|
||||
languagePreference: string;
|
||||
/**
|
||||
* Effective permissions, keyed by camelCased module name (server uses a
|
||||
* camelCase dictionary-key policy). Absent for legacy/secret-link tokens.
|
||||
*/
|
||||
permissions?: Record<string, ModuleActions>;
|
||||
}
|
||||
|
||||
/** Matches the C# LoginResponse DTO exactly. */
|
||||
|
||||
Reference in New Issue
Block a user