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 Moq;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -72,11 +73,20 @@ public class AuthServiceTests
|
|||||||
return svc;
|
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(
|
private static AuthService BuildSut(
|
||||||
Mock<UserManager<AppUser>> umMock,
|
Mock<UserManager<AppUser>> umMock,
|
||||||
Mock<ITokenService> tsMock,
|
Mock<ITokenService> tsMock,
|
||||||
AppDbContext db)
|
AppDbContext db)
|
||||||
=> new(umMock.Object, tsMock.Object, db, BuildConfig());
|
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig());
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Login tests
|
// 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.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using ROLAC.API.DTOs.Auth;
|
using ROLAC.API.DTOs.Auth;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
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 const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||||
|
|
||||||
private readonly IAuthService _authService;
|
private readonly IAuthService _authService;
|
||||||
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly IWebHostEnvironment _env;
|
private readonly IWebHostEnvironment _env;
|
||||||
|
|
||||||
public AuthController(IAuthService authService, IWebHostEnvironment env)
|
public AuthController(
|
||||||
|
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||||
{
|
{
|
||||||
_authService = authService;
|
_authService = authService;
|
||||||
|
_userManager = userManager;
|
||||||
_env = env;
|
_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>
|
/// <summary>
|
||||||
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
/// Returns the current user's identity, roles, and effective permissions.
|
||||||
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
/// The SPA calls this on startup and after an admin edits the permission matrix
|
||||||
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
/// to refresh what the UI shows — without forcing a re-login.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("me")]
|
[HttpGet("me")]
|
||||||
[Authorize] // no role restriction — just needs a valid JWT
|
[Authorize]
|
||||||
public IActionResult GetMe()
|
[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
|
var claims = User.Claims
|
||||||
.Select(c => new { c.Type, c.Value })
|
.Select(c => new { c.Type, c.Value })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/church-profile")]
|
[Route("api/church-profile")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class ChurchProfileController : ControllerBase
|
public class ChurchProfileController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IChurchProfileService _svc;
|
private readonly IChurchProfileService _svc;
|
||||||
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||||
|
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
|
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
await _svc.UpdateAsync(r);
|
await _svc.UpdateAsync(r);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Disbursement;
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/disbursements")]
|
[Route("api/disbursements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class DisbursementsController : ControllerBase
|
public class DisbursementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDisbursementService _svc;
|
private readonly IDisbursementService _svc;
|
||||||
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet("approved-unpaid")]
|
[HttpGet("approved-unpaid")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetApprovedUnpaid()
|
public async Task<IActionResult> GetApprovedUnpaid()
|
||||||
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||||
|
|
||||||
[HttpPost("issue")]
|
[HttpPost("issue")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||||
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks")]
|
[HttpGet("checks")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetRegister(
|
public async Task<IActionResult> GetRegister(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||||
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}")]
|
[HttpGet("checks/{id:int}")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/void")]
|
[HttpPost("checks/{id:int}/void")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||||
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/pdf")]
|
[HttpGet("checks/{id:int}/pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPdf(int id)
|
public async Task<IActionResult> GetPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderPdfAsync(id);
|
var result = await _svc.RenderPdfAsync(id);
|
||||||
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("checks/{id:int}/receipt-pdf")]
|
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetReceiptPdf(int id)
|
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||||
{
|
{
|
||||||
var result = await _svc.RenderReceiptPdfAsync(id);
|
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||||
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("checks/{id:int}/acknowledge")]
|
[HttpPost("checks/{id:int}/acknowledge")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
|
||||||
[RequestSizeLimit(5_242_880)]
|
[RequestSizeLimit(5_242_880)]
|
||||||
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
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")]
|
[HttpGet("checks/{id:int}/signature")]
|
||||||
|
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetSignature(int id)
|
public async Task<IActionResult> GetSignature(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
|
|||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
[HttpPost("groups")]
|
[HttpPost("groups")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||||
|
|
||||||
[HttpPut("groups/{id:int}")]
|
[HttpPut("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||||
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("groups/{id:int}")]
|
[HttpDelete("groups/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateGroup(int id)
|
public async Task<IActionResult> DeactivateGroup(int id)
|
||||||
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPost("subcategories")]
|
[HttpPost("subcategories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||||
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpPut("subcategories/{id:int}")]
|
[HttpPut("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||||
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
[HttpDelete("subcategories/{id:int}")]
|
[HttpDelete("subcategories/{id:int}")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeactivateSub(int id)
|
public async Task<IActionResult> DeactivateSub(int id)
|
||||||
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,38 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
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]
|
[ApiController]
|
||||||
[Route("api/expenses")]
|
[Route("api/expenses")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ExpensesController : ControllerBase
|
public class ExpensesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseService _svc;
|
private readonly IExpenseService _svc;
|
||||||
public ExpensesController(IExpenseService svc) => _svc = svc;
|
private readonly IPermissionService _perms;
|
||||||
|
public ExpensesController(IExpenseService svc, IPermissionService perms)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_perms = perms;
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
|
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||||
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
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.
|
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||||
private string CurrentUserId() =>
|
private string CurrentUserId() =>
|
||||||
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||||
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = 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));
|
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);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
if (dto is null) return NotFound();
|
if (dto is null) return NotFound();
|
||||||
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||||
return Ok(dto);
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
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 }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
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 (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
|
|||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
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 (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
}
|
}
|
||||||
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/approve")]
|
[HttpPost("{id:int}/approve")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Approve(int id)
|
public async Task<IActionResult> Approve(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ApproveAsync(id); return NoContent(); }
|
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||||
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reject")]
|
[HttpPost("{id:int}/reject")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||||
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/pay")]
|
[HttpPost("{id:int}/pay")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.Expenses, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||||
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var stream = file.OpenReadStream();
|
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();
|
return NoContent();
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
|
||||||
if (result is null) return NotFound();
|
if (result is null) return NotFound();
|
||||||
return File(result.Value.stream, result.Value.contentType);
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/finance-dashboard")]
|
[Route("api/finance-dashboard")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
|
||||||
public class FinanceDashboardController : ControllerBase
|
public class FinanceDashboardController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFinanceDashboardService _svc;
|
private readonly IFinanceDashboardService _svc;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/giving-categories")]
|
[Route("api/giving-categories")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingCategoriesController : ControllerBase
|
public class GivingCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingCategoryService _svc;
|
private readonly IGivingCategoryService _svc;
|
||||||
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(int id)
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/givings")]
|
[Route("api/givings")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class GivingsController : ControllerBase
|
public class GivingsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGivingService _svc;
|
private readonly IGivingService _svc;
|
||||||
public GivingsController(IGivingService svc) => _svc = svc;
|
public GivingsController(IGivingService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
[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));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||||
{
|
{
|
||||||
var id = await _svc.CreateAsync(request);
|
var id = await _svc.CreateAsync(request);
|
||||||
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Givings, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteAsync(id); return NoContent(); }
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Members;
|
using ROLAC.API.DTOs.Members;
|
||||||
using ROLAC.API.Services;
|
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>
|
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/members/{id}</summary>
|
/// <summary>GET /api/members/{id}</summary>
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary,pastor")]
|
[HasPermission(Modules.Members, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _members.GetByIdAsync(id);
|
var dto = await _members.GetByIdAsync(id);
|
||||||
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>POST /api/members</summary>
|
/// <summary>POST /api/members</summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||||
{
|
{
|
||||||
var id = await _members.CreateAsync(request);
|
var id = await _members.CreateAsync(request);
|
||||||
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/members/{id}</summary>
|
/// <summary>PUT /api/members/{id}</summary>
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||||
{
|
{
|
||||||
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||||
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
[Authorize(Roles = "super_admin,secretary")]
|
[HasPermission(Modules.Members, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id)
|
||||||
{
|
{
|
||||||
try { await _members.DeleteAsync(id); return NoContent(); }
|
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/monthly-statements")]
|
[Route("api/monthly-statements")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class MonthlyStatementsController : ControllerBase
|
public class MonthlyStatementsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMonthlyStatementService _svc;
|
private readonly IMonthlyStatementService _svc;
|
||||||
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||||
=> Ok(await _svc.GetAllAsync(year));
|
=> Ok(await _svc.GetAllAsync(year));
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
|
||||||
{
|
{
|
||||||
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/finalize")]
|
[HttpPost("{id:int}/finalize")]
|
||||||
|
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Finalize(int id)
|
public async Task<IActionResult> Finalize(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Giving;
|
using ROLAC.API.DTOs.Giving;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/offering-sessions")]
|
[Route("api/offering-sessions")]
|
||||||
[Authorize(Roles = "finance,super_admin")]
|
[Authorize]
|
||||||
public class OfferingSessionsController : ControllerBase
|
public class OfferingSessionsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IOfferingSessionService _svc;
|
private readonly IOfferingSessionService _svc;
|
||||||
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||||
|
|
||||||
[HttpGet("check-date")]
|
[HttpGet("check-date")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||||
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id)
|
||||||
{
|
{
|
||||||
var dto = await _svc.GetByIdAsync(id);
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/reopen")]
|
[HttpPost("{id:int}/reopen")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
|
||||||
public async Task<IActionResult> Reopen(int id)
|
public async Task<IActionResult> Reopen(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.ReopenAsync(id); return NoContent(); }
|
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||||
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:int}")]
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||||
{
|
{
|
||||||
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
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) ───────────
|
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||||
|
|
||||||
[HttpPost("{id:int}/proof")]
|
[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
|
[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)
|
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||||
{
|
{
|
||||||
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/proof")]
|
[HttpGet("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetProof(int id)
|
public async Task<IActionResult> GetProof(int id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}/proof")]
|
[HttpDelete("{id:int}/proof")]
|
||||||
|
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> DeleteProof(int id)
|
public async Task<IActionResult> DeleteProof(int id)
|
||||||
{
|
{
|
||||||
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
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.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Users;
|
using ROLAC.API.DTOs.Users;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/users")]
|
[Route("api/users")]
|
||||||
[Authorize(Roles = "super_admin")]
|
[Authorize]
|
||||||
public class UsersController : ControllerBase
|
public class UsersController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IUserManagementService _users;
|
private readonly IUserManagementService _users;
|
||||||
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetPaged(
|
public async Task<IActionResult> GetPaged(
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 20,
|
[FromQuery] int pageSize = 20,
|
||||||
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>GET /api/users/{id}</summary>
|
/// <summary>GET /api/users/{id}</summary>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Read)]
|
||||||
public async Task<IActionResult> GetById(string id)
|
public async Task<IActionResult> GetById(string id)
|
||||||
{
|
{
|
||||||
var dto = await _users.GetByIdAsync(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.
|
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||||
{
|
{
|
||||||
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
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>
|
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Delete)]
|
||||||
public async Task<IActionResult> Deactivate(string id)
|
public async Task<IActionResult> Deactivate(string id)
|
||||||
{
|
{
|
||||||
try { await _users.DeactivateAsync(id); return NoContent(); }
|
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>
|
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||||
[HttpPost("{id}/reset-password")]
|
[HttpPost("{id}/reset-password")]
|
||||||
|
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> ResetPassword(string id)
|
public async Task<IActionResult> ResetPassword(string id)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using ROLAC.API.DTOs.Permissions;
|
||||||
|
|
||||||
namespace ROLAC.API.DTOs.Auth;
|
namespace ROLAC.API.DTOs.Auth;
|
||||||
|
|
||||||
public class LoginResponse
|
public class LoginResponse
|
||||||
@@ -17,4 +19,10 @@ public class UserInfo
|
|||||||
public string Email { get; set; } = null!;
|
public string Email { get; set; } = null!;
|
||||||
public IList<string> Roles { get; set; } = [];
|
public IList<string> Roles { get; set; } = [];
|
||||||
public string LanguagePreference { get; set; } = "en";
|
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<Check> Checks => Set<Check>();
|
||||||
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||||
|
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
@@ -60,6 +61,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.Description).HasMaxLength(500);
|
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 ──────────────────────────────────────────────────────
|
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||||
builder.Entity<FamilyUnit>(entity =>
|
builder.Entity<FamilyUnit>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
@@ -62,6 +63,60 @@ public static class DbSeeder
|
|||||||
("visitor", "Visitor — public pages only"),
|
("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)
|
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
|
||||||
{
|
{
|
||||||
foreach (var (name, description) in Roles)
|
foreach (var (name, description) in Roles)
|
||||||
@@ -159,6 +214,7 @@ public static class DbSeeder
|
|||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
var db = services.GetRequiredService<AppDbContext>();
|
var db = services.GetRequiredService<AppDbContext>();
|
||||||
|
await SeedRolePermissionsAsync(db);
|
||||||
await SeedGivingCategoriesAsync(db);
|
await SeedGivingCategoriesAsync(db);
|
||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(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");
|
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 =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
@@ -1481,6 +1519,17 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("RefreshTokens");
|
b.Navigation("RefreshTokens");
|
||||||
|
|||||||
@@ -135,6 +135,19 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
|||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
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.
|
// Real-time hub for the live Sunday attendance counter.
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,20 @@ public class AuthService : IAuthService
|
|||||||
private readonly UserManager<AppUser> _userManager;
|
private readonly UserManager<AppUser> _userManager;
|
||||||
private readonly ITokenService _tokenService;
|
private readonly ITokenService _tokenService;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IPermissionService _permissions;
|
||||||
private readonly int _refreshTokenExpiryDays;
|
private readonly int _refreshTokenExpiryDays;
|
||||||
|
|
||||||
public AuthService(
|
public AuthService(
|
||||||
UserManager<AppUser> userManager,
|
UserManager<AppUser> userManager,
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
|
IPermissionService permissions,
|
||||||
IConfiguration config)
|
IConfiguration config)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_tokenService = tokenService;
|
_tokenService = tokenService;
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_permissions = permissions;
|
||||||
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,7 @@ public class AuthService : IAuthService
|
|||||||
await _userManager.UpdateAsync(user);
|
await _userManager.UpdateAsync(user);
|
||||||
await _db.SaveChangesAsync();
|
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();
|
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 helpers
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private static LoginResponse BuildResponse(
|
private async Task<LoginResponse> BuildResponseAsync(
|
||||||
string accessToken, AppUser user, IList<string> roles)
|
string accessToken, AppUser user, IList<string> roles)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
AccessToken = accessToken,
|
AccessToken = accessToken,
|
||||||
ExpiresIn = 15 * 60,
|
ExpiresIn = 15 * 60,
|
||||||
User = new UserInfo
|
User = await BuildUserInfoAsync(user, roles),
|
||||||
{
|
};
|
||||||
Id = user.Id,
|
|
||||||
Email = user.Email!,
|
/// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
|
||||||
Roles = roles,
|
public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
|
||||||
LanguagePreference = user.LanguagePreference,
|
=> 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.DTOs.Auth;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Services;
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
@@ -28,4 +29,11 @@ public interface IAuthService
|
|||||||
/// Silently succeeds if the token is not found.
|
/// Silently succeeds if the token is not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task LogoutAsync(string rawRefreshToken);
|
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 { LoginPage } from './features/login-page/login-page';
|
||||||
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
import { UserPortalComponent } from './portals/user-portal/user-portal.component';
|
||||||
import { AuthGuard } from './core/guards/auth.guard';
|
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 { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
|
||||||
import { UsersPageComponent } from './features/users/pages/users-page/users-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';
|
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
|
||||||
@@ -38,73 +40,84 @@ export const routes: Routes = [
|
|||||||
children: [
|
children: [
|
||||||
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
|
||||||
{ path: 'dashboard', component: DashboardComponent },
|
{ 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',
|
path: 'admin/users',
|
||||||
component: UsersPageComponent,
|
component: UsersPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['super_admin'] },
|
data: { permission: { module: PermissionModules.Users, action: 'read' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/permissions',
|
||||||
|
component: PermissionsPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: { permission: { module: PermissionModules.Permissions, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/dashboard',
|
path: 'finance/dashboard',
|
||||||
component: FinanceDashboardPageComponent,
|
component: FinanceDashboardPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/giving-categories',
|
path: 'finance/giving-categories',
|
||||||
component: GivingCategoriesPageComponent,
|
component: GivingCategoriesPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/givings',
|
path: 'finance/givings',
|
||||||
component: GivingsPageComponent,
|
component: GivingsPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.Givings, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/offering-session',
|
path: 'finance/offering-session',
|
||||||
component: OfferingSessionPageComponent,
|
component: OfferingSessionPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } },
|
||||||
},
|
},
|
||||||
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
|
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
|
||||||
{
|
{
|
||||||
path: 'finance/expenses',
|
path: 'finance/expenses',
|
||||||
component: ExpensesPageComponent,
|
component: ExpensesPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.Expenses, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/expense-categories',
|
path: 'finance/expense-categories',
|
||||||
component: ExpenseCategoriesPageComponent,
|
component: ExpenseCategoriesPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/monthly-statement',
|
path: 'finance/monthly-statement',
|
||||||
component: MonthlyStatementPageComponent,
|
component: MonthlyStatementPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/disbursements',
|
path: 'finance/disbursements',
|
||||||
component: DisbursementPageComponent,
|
component: DisbursementPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/check-register',
|
path: 'finance/check-register',
|
||||||
component: CheckRegisterPageComponent,
|
component: CheckRegisterPageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/church-profile',
|
path: 'finance/church-profile',
|
||||||
component: ChurchProfilePageComponent,
|
component: ChurchProfilePageComponent,
|
||||||
canActivate: [RoleGuard],
|
canActivate: [PermissionGuard],
|
||||||
data: { roles: ['finance', 'super_admin'] },
|
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 -->
|
<!-- Reimbursement mode: receipt file input -->
|
||||||
<ng-container *ngIf="mode === 'reimbursement'">
|
<ng-container *ngIf="mode === 'reimbursement'">
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
|
<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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,application/pdf"
|
accept="image/*,application/pdf"
|
||||||
(change)="onFileSelected($event)"
|
(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" />
|
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>
|
</label>
|
||||||
</ng-container>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nav class="sidebar-nav">
|
<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">
|
<div class="nav-section">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Main</h4>
|
<h4 *ngIf="!sidebarCollapsed">Main</h4>
|
||||||
<a *ngFor="let item of mainNavItems" class="nav-item" [class.active]="item.active"
|
<ng-container *ngFor="let item of mainNavItems">
|
||||||
[title]="item.text" (click)="navigateTo(item.path)">
|
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||||
<div class="nav-icon">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
<div class="nav-icon">
|
||||||
</div>
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
</div>
|
||||||
</a>
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
|
</a>
|
||||||
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section">
|
<div class="nav-section">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Personal</h4>
|
<h4 *ngIf="!sidebarCollapsed">Personal</h4>
|
||||||
<a *ngFor="let item of personalNavItems" class="nav-item" [class.active]="item.active"
|
<ng-container *ngFor="let item of personalNavItems">
|
||||||
[title]="item.text" (click)="navigateTo(item.path)">
|
<a class="nav-item" [class.active]="item.active" *ngIf="matchesSearch(item.text)"
|
||||||
<div class="nav-icon">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
<div class="nav-icon">
|
||||||
</div>
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
</div>
|
||||||
</a>
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
</div>
|
</a>
|
||||||
|
</ng-container>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
<h4 *ngIf="!sidebarCollapsed">Administration</h4>
|
||||||
<a *ngFor="let item of memberAdminNavItems" class="nav-item" [class.active]="item.active"
|
<ng-container *ngFor="let item of memberAdminNavItems">
|
||||||
[title]="item.text" (click)="navigateTo(item.path)">
|
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||||
<div class="nav-icon">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
<div class="nav-icon">
|
||||||
</div>
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
</div>
|
||||||
</a>
|
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
||||||
<ng-container *ngIf="showUserAdminSection">
|
</a>
|
||||||
<a *ngFor="let item of userAdminNavItems" class="nav-item" [class.active]="item.active"
|
</ng-container>
|
||||||
|
<ng-container *ngFor="let item of userAdminNavItems">
|
||||||
|
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
|
||||||
[title]="item.text" (click)="navigateTo(item.path)">
|
[title]="item.text" (click)="navigateTo(item.path)">
|
||||||
<div class="nav-icon">
|
<div class="nav-icon">
|
||||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
||||||
@@ -87,13 +93,46 @@
|
|||||||
|
|
||||||
<div class="nav-section" *ngIf="showFinanceSection">
|
<div class="nav-section" *ngIf="showFinanceSection">
|
||||||
<h4 *ngIf="!sidebarCollapsed">Finance</h4>
|
<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)">
|
<!-- Collapsed sidebar: flat icon-only list, no group headers -->
|
||||||
<div class="nav-icon">
|
<ng-container *ngIf="sidebarCollapsed">
|
||||||
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
|
<ng-container *ngFor="let group of financeGroups">
|
||||||
</div>
|
<ng-container *ngFor="let item of group.items">
|
||||||
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
|
<a *ngIf="canShow(item)" class="nav-item" [class.active]="item.active"
|
||||||
</a>
|
[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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,70 @@
|
|||||||
max-height: calc(100vh - 200px); // Account for header and footer
|
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 {
|
.nav-section {
|
||||||
margin-bottom: 2rem;
|
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 {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
import { Component, OnInit, OnDestroy, HostListener } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
import { Router, NavigationEnd, RouterModule, RouterOutlet } from '@angular/router';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
import { IconsModule } from '@progress/kendo-angular-icons';
|
import { IconsModule } from '@progress/kendo-angular-icons';
|
||||||
import {
|
import {
|
||||||
SVGIcon,
|
SVGIcon,
|
||||||
homeIcon,
|
homeIcon,
|
||||||
calendarIcon,
|
|
||||||
userIcon,
|
userIcon,
|
||||||
groupIcon,
|
groupIcon,
|
||||||
usersOutlineIcon,
|
|
||||||
bedOutlineIcon,
|
|
||||||
pillsOutlineIcon,
|
|
||||||
graphIcon,
|
graphIcon,
|
||||||
buildingsOutlineIcon,
|
buildingsOutlineIcon,
|
||||||
banknoteOutlineIcon,
|
banknoteOutlineIcon,
|
||||||
questionCircleIcon,
|
|
||||||
dollarIcon,
|
dollarIcon,
|
||||||
categorizeIcon,
|
categorizeIcon,
|
||||||
moneyExchangeIcon,
|
moneyExchangeIcon,
|
||||||
fileReportIcon,
|
fileReportIcon,
|
||||||
walletOutlineIcon,
|
walletOutlineIcon,
|
||||||
handIcon,
|
handIcon,
|
||||||
|
searchIcon,
|
||||||
|
xIcon,
|
||||||
|
chevronDownIcon,
|
||||||
|
lockIcon,
|
||||||
} from '@progress/kendo-svg-icons';
|
} from '@progress/kendo-svg-icons';
|
||||||
import { AuthService, UserInfo } from '../../shared/services/auth.service';
|
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';
|
import { Subject, takeUntil, filter } from 'rxjs';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -30,6 +32,15 @@ interface NavItem {
|
|||||||
icon: SVGIcon;
|
icon: SVGIcon;
|
||||||
path: string;
|
path: string;
|
||||||
active?: boolean;
|
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({
|
@Component({
|
||||||
@@ -37,6 +48,7 @@ interface NavItem {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
IconsModule,
|
IconsModule,
|
||||||
@@ -49,57 +61,76 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
isMobile = false;
|
isMobile = false;
|
||||||
currentUser: UserInfo | null = null;
|
currentUser: UserInfo | null = null;
|
||||||
currentPageTitle = 'Dashboard';
|
currentPageTitle = 'Dashboard';
|
||||||
unreadMessages = 3;
|
|
||||||
unreadNotifications = 2;
|
public searchQuery = '';
|
||||||
|
|
||||||
public homeIcon: SVGIcon = homeIcon;
|
public homeIcon: SVGIcon = homeIcon;
|
||||||
public calendarIcon: SVGIcon = calendarIcon;
|
|
||||||
public peopleIcon: SVGIcon = usersOutlineIcon;
|
|
||||||
public bedIcon: SVGIcon = bedOutlineIcon;
|
|
||||||
public userIcon: SVGIcon = userIcon;
|
public userIcon: SVGIcon = userIcon;
|
||||||
public pillIcon: SVGIcon = pillsOutlineIcon;
|
public searchIcon: SVGIcon = searchIcon;
|
||||||
public chartIcon: SVGIcon = graphIcon;
|
public clearIcon: SVGIcon = xIcon;
|
||||||
public buildingIcon: SVGIcon = buildingsOutlineIcon;
|
public chevronDownIcon: SVGIcon = chevronDownIcon;
|
||||||
public creditCardIcon: SVGIcon = banknoteOutlineIcon;
|
|
||||||
public supportIcon: SVGIcon = questionCircleIcon;
|
|
||||||
|
|
||||||
public mainNavItems: NavItem[] = [
|
public mainNavItems: NavItem[] = [
|
||||||
{ text: 'Dashboard', icon: this.homeIcon, path: '/user-portal/dashboard' },
|
{ 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[] = [
|
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[] = [
|
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[] = [
|
public financeGroups: NavGroup[] = [
|
||||||
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard' },
|
{
|
||||||
{ text: 'Offering Entry', icon: handIcon, path: '/user-portal/finance/offering-session' },
|
text: 'Overview',
|
||||||
{ text: 'Givings', icon: dollarIcon, path: '/user-portal/finance/givings' },
|
expanded: false,
|
||||||
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' },
|
items: [
|
||||||
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' },
|
{ text: 'Finance Dashboard', icon: graphIcon, path: '/user-portal/finance/dashboard',
|
||||||
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' },
|
permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
|
||||||
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' },
|
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement',
|
||||||
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' },
|
permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
|
||||||
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' },
|
],
|
||||||
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' },
|
},
|
||||||
|
{
|
||||||
|
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[] = [
|
public personalNavItems: NavItem[] = [
|
||||||
@@ -114,6 +145,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private authService: AuthService,
|
||||||
|
private permissions: PermissionService,
|
||||||
private router: Router
|
private router: Router
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@@ -148,13 +180,22 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
.pipe(takeUntil(this.destroy$))
|
.pipe(takeUntil(this.destroy$))
|
||||||
.subscribe(user => {
|
.subscribe(user => {
|
||||||
this.currentUser = user;
|
this.currentUser = user;
|
||||||
const roles = user?.roles ?? [];
|
// Section visibility is derived from effective permissions (super_admin → all).
|
||||||
this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary');
|
this.showMemberAdminSection = this.memberAdminNavItems.some(item => this.canShow(item));
|
||||||
this.showUserAdminSection = roles.includes('super_admin');
|
this.showUserAdminSection = this.userAdminNavItems.some(item => this.canShow(item));
|
||||||
this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin');
|
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 {
|
private setupRouteSubscription(): void {
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -176,13 +217,13 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateActiveStates(currentUrl: string): void {
|
private updateActiveStates(currentUrl: string): void {
|
||||||
|
const financeItems: NavItem[] = [];
|
||||||
|
this.financeGroups.forEach(group => financeItems.push(...group.items));
|
||||||
const allItems = [
|
const allItems = [
|
||||||
...this.mainNavItems,
|
...this.mainNavItems,
|
||||||
...this.managementNavItems,
|
|
||||||
...this.supportNavItems,
|
|
||||||
...this.memberAdminNavItems,
|
...this.memberAdminNavItems,
|
||||||
...this.userAdminNavItems,
|
...this.userAdminNavItems,
|
||||||
...this.financeNavItems,
|
...financeItems,
|
||||||
...this.personalNavItems,
|
...this.personalNavItems,
|
||||||
];
|
];
|
||||||
allItems.forEach(item => (item.active = false));
|
allItems.forEach(item => (item.active = false));
|
||||||
@@ -191,6 +232,45 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
if (activeItem) {
|
if (activeItem) {
|
||||||
activeItem.active = true;
|
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 {
|
private updatePageTitle(): void {
|
||||||
@@ -222,6 +302,7 @@ export class UserPortalComponent implements OnInit, OnDestroy {
|
|||||||
'settings': 'Settings',
|
'settings': 'Settings',
|
||||||
'admin/members': 'Member Management',
|
'admin/members': 'Member Management',
|
||||||
'admin/users': 'User Management',
|
'admin/users': 'User Management',
|
||||||
|
'admin/permissions': 'Role Permissions',
|
||||||
'finance/dashboard': 'Finance Dashboard',
|
'finance/dashboard': 'Finance Dashboard',
|
||||||
'finance/offering-session': 'Sunday Offering Entry',
|
'finance/offering-session': 'Sunday Offering Entry',
|
||||||
'finance/givings': 'Givings',
|
'finance/givings': 'Givings',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { HttpClient } from '@angular/common/http';
|
|||||||
import { BehaviorSubject, Observable, of } from 'rxjs';
|
import { BehaviorSubject, Observable, of } from 'rxjs';
|
||||||
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
import { catchError, filter, finalize, map, shareReplay, take, tap } from 'rxjs/operators';
|
||||||
import { ApiConfigService } from '../../core/services/api-config.service';
|
import { ApiConfigService } from '../../core/services/api-config.service';
|
||||||
|
import { ModuleActions } from '../../core/models/permission.model';
|
||||||
|
|
||||||
// ── Public interfaces ─────────────────────────────────────────────────────────
|
// ── Public interfaces ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ export interface UserInfo {
|
|||||||
email: string;
|
email: string;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
languagePreference: 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. */
|
/** Matches the C# LoginResponse DTO exactly. */
|
||||||
|
|||||||
Reference in New Issue
Block a user