Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s

This commit is contained in:
Chris Chen
2026-06-23 12:13:47 -07:00
parent 870eeec82a
commit 62592c29ae
106 changed files with 2522 additions and 311 deletions
@@ -26,7 +26,7 @@ public class AuditInterceptorTests
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AuditSaveChangesInterceptor(mock.Object); return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
} }
[Fact] [Fact]
@@ -86,7 +86,8 @@ public class AuthServiceTests
Mock<UserManager<AppUser>> umMock, Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock, Mock<ITokenService> tsMock,
AppDbContext db) AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object, BuildConfig()); => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Login tests // Login tests
@@ -49,7 +49,7 @@ public class DisbursementServiceTests
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new DisbursementService(db, http.Object, fs, new FakePrint()); return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin") private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
@@ -203,7 +203,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return (new DisbursementService(db, http.Object, fs, print), db, fs, print); return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
} }
[Fact] [Fact]
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -33,7 +33,7 @@ public class ExpenseServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
@@ -52,7 +52,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
@@ -62,7 +62,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static CreateExpenseRequest Reimb() => new() private static CreateExpenseRequest Reimb() => new()
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -24,7 +24,7 @@ public class GivingServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -29,7 +29,7 @@ public class MemberServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var accessor = BuildAccessor(userId); var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(accessor); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -20,7 +20,7 @@ public class MinistryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static MonthlyStatementService Build(AppDbContext db) private static MonthlyStatementService Build(AppDbContext db)
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new MonthlyStatementService(db, mock.Object); return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
[Fact] [Fact]
@@ -36,7 +36,7 @@ public class OfferingSessionServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -49,7 +49,9 @@ public class PermissionServiceTests
return new Harness return new Harness
{ {
Provider = provider, Provider = provider,
Service = new PermissionService(scopeFactory, cache), Service = new PermissionService(scopeFactory, cache,
new ROLAC.API.Services.Logging.SystemLogQueue(),
new Microsoft.AspNetCore.Http.HttpContextAccessor()),
}; };
} }
@@ -76,7 +76,7 @@ public class UserManagementServiceTests
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var result = await svc.CreateAsync(new CreateUserRequest var result = await svc.CreateAsync(new CreateUserRequest
{ {
MemberId = member.Id, MemberId = member.Id,
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
var mgr = BuildUserManager(); var mgr = BuildUserManager();
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
// The service checks _userManager.Users — we need to return the existing user // The service checks _userManager.Users — we need to return the existing user
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser> { existingUser }.AsQueryable()); .Returns(new List<AppUser> { existingUser }.AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
var user = new AppUser var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await svc.DeactivateAsync("u1"); await svc.DeactivateAsync("u1");
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
{ {
using var db = BuildDb(); using var db = BuildDb();
var mgr = BuildUserManager(findResult: null); var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing")); await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
} }
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
using var db = BuildDb(); using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var pwd = await svc.ResetPasswordAsync("u1"); var pwd = await svc.ResetPasswordAsync("u1");
@@ -0,0 +1,19 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>No-op <see cref="IAuditLogger"/> for unit tests that don't assert on audit output.</summary>
public sealed class NullAuditLogger : IAuditLogger
{
public static readonly NullAuditLogger Instance = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
// intentionally empty
}
}
+4
View File
@@ -21,6 +21,8 @@ public static class Modules
public const string Disbursements = "Disbursements"; public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance"; public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions"; public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary> /// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
@@ -39,6 +41,8 @@ public static class Modules
Disbursements, Disbursements,
MealAttendance, MealAttendance,
Permissions, Permissions,
SystemLogs,
AuditLogs,
]; ];
public static bool IsValid(string module) => All.Contains(module); public static bool IsValid(string module) => All.Contains(module);
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/audit-logs")]
[Authorize]
public class AuditLogsController : ControllerBase
{
private readonly IAuditLogQueryService _svc;
public AuditLogsController(IAuditLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] AuditLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Category / action / level option lists for the filter UI.</summary>
[HttpGet("catalog")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public IActionResult GetCatalog() => Ok(_svc.GetCatalog());
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/system-logs")]
[Authorize]
public class SystemLogsController : ControllerBase
{
private readonly ISystemLogQueryService _svc;
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
[HttpGet("levels")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
}
@@ -0,0 +1,50 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the Audit Logs grid (no heavy Changes JSON).</summary>
public class AuditLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Action { get; set; } = null!;
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? Summary { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
/// <summary>Full detail for the Audit Log dialog, including the before→after JSON.</summary>
public class AuditLogDetailDto : AuditLogListItemDto
{
public string? Changes { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Filters for the paged Audit Logs query.</summary>
public class AuditLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
public string? Category { get; set; }
public string? Action { get; set; }
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? UserId { get; set; }
public LogLevelEnum? MinLevel { get; set; }
public string? Search { get; set; }
}
/// <summary>Option lists for the Audit Logs filter UI.</summary>
public class AuditCatalogDto
{
public IReadOnlyList<string> Categories { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
public IReadOnlyList<string> Levels { get; set; } = [];
}
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the System Logs grid (no heavy exception text).</summary>
public class SystemLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Category { get; set; } = null!;
public string Message { get; set; } = null!;
public bool HasException { get; set; }
public int? StatusCode { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Full detail for the System Log dialog, including the stack trace.</summary>
public class SystemLogDetailDto : SystemLogListItemDto
{
public int? EventId { get; set; }
public string? Exception { get; set; }
public string? IpAddress { get; set; }
}
/// <summary>Filters for the paged System Logs query.</summary>
public class SystemLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
/// <summary>Lower bound on severity (inclusive).</summary>
public LogLevelEnum? MinLevel { get; set; }
/// <summary>Exact severity match (takes precedence over MinLevel when set).</summary>
public LogLevelEnum? Level { get; set; }
public string? Search { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
+8
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities; using ROLAC.API.Entities;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
@@ -324,5 +325,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
}); });
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
// Runtime reads/writes go through the dedicated LogDbContext. Including
// them in the model lets the single startup migration create the tables.
LogModelConfiguration.Configure(builder);
} }
} }
+7
View File
@@ -87,6 +87,13 @@ public static class DbSeeder
("finance", Modules.MonthlyStatements, true, true, false, true), ("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false), ("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true), ("finance", Modules.Disbursements, true, true, true, true),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
]; ];
public static async Task SeedRolePermissionsAsync(AppDbContext db) public static async Task SeedRolePermissionsAsync(AppDbContext db)
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
/// <summary>
/// Writes a before→after <see cref="AuditLog"/> row for every Create/Update/Delete of an
/// <see cref="IAuditable"/> entity. Two-phase: snapshot changed values BEFORE save (while
/// original values are still available), then — AFTER save succeeds — read DB-generated keys and
/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't
/// fail the user's transaction, and never recurses through AppDbContext.
/// </summary>
public sealed class AuditLogInterceptor : SaveChangesInterceptor
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
private readonly List<PendingAudit> _pending = [];
public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Capture(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Capture(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Flush();
return base.SavedChanges(eventData, result);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Flush();
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear();
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
// ── Phase 1: snapshot before save ─────────────────────────────────────────
private void Capture(DbContext? db)
{
if (db is null)
return;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditable)
continue;
switch (entry.State)
{
case EntityState.Added:
_pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true)));
break;
case EntityState.Deleted:
_pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null));
break;
case EntityState.Modified:
var before = new Dictionary<string, object?>();
var after = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (!property.IsModified)
continue;
var name = property.Metadata.Name;
before[name] = Read(name, property.OriginalValue);
after[name] = Read(name, property.CurrentValue);
}
if (after.Count == 0)
break; // no real change (e.g. only audit timestamps touched on a no-op)
// A soft-delete (IsDeleted false→true) reads more naturally as a Delete.
var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update;
_pending.Add(new PendingAudit(entry, action, before, after));
break;
}
}
}
// ── Phase 2: keys exist, enqueue ──────────────────────────────────────────
private void Flush()
{
if (_pending.Count == 0)
return;
var userId = _currentUser.UserId;
var userEmail = _currentUser.Email;
var ip = _currentUser.IpAddress;
var corr = _currentUser.CorrelationId;
foreach (var item in _pending)
{
_queue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Information,
Action = item.Action,
Category = AuditCategories.DataChange,
EntityName = item.Entry.Metadata.ClrType.Name,
EntityId = ReadKey(item.Entry),
Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After),
UserId = userId,
UserEmail = userEmail,
IpAddress = ip,
CorrelationId = corr,
});
}
_pending.Clear();
}
private static Dictionary<string, object?> BuildValues(EntityEntry entry, bool current)
{
var values = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (property.Metadata.IsPrimaryKey())
continue;
var name = property.Metadata.Name;
values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue);
}
return values;
}
private static object? Read(string propertyName, object? value) =>
AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value;
private static bool IsSoftDelete(Dictionary<string, object?> after) =>
after.TryGetValue("IsDeleted", out var value) && value is true;
private static string? ReadKey(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key is null)
return null;
var parts = key.Properties
.Select(p => entry.Property(p.Name).CurrentValue?.ToString())
.Where(v => v is not null);
return string.Join(",", parts);
}
private sealed record PendingAudit(
EntityEntry Entry,
string Action,
Dictionary<string, object?>? Before,
Dictionary<string, object?>? After);
}
@@ -1,15 +1,15 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors; namespace ROLAC.API.Data.Interceptors;
public class AuditSaveChangesInterceptor : SaveChangesInterceptor public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
private readonly IHttpContextAccessor _http; private readonly CurrentUserAccessor _currentUser;
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges( public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result) DbContextEventData eventData, InterceptionResult<int> result)
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
if (db is null) return; if (db is null) return;
var userId = _http.HttpContext?.User var userId = _currentUser.UserIdOrSystem;
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
foreach (var entry in db.ChangeTracker.Entries()) foreach (var entry in db.ChangeTracker.Entries())
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the
/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors
/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log
/// events that the DB sink would pick up. It shares the same physical database/connection as
/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are
/// only mapped here so this context can read/write them.
/// </summary>
public class LogDbContext : DbContext
{
public LogDbContext(DbContextOptions<LogDbContext> options) : base(options) { }
public DbSet<SystemLog> SystemLogs => Set<SystemLog>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
LogModelConfiguration.Configure(builder);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// Single source of truth for the SystemLog / AuditLog table schema. Applied by
/// <see cref="AppDbContext"/> (so the startup migration creates the tables) AND by
/// <see cref="LogDbContext"/> (so runtime reads/writes map to the same shape).
/// </summary>
public static class LogModelConfiguration
{
public static void Configure(ModelBuilder builder)
{
builder.Entity<SystemLog>(entity =>
{
entity.ToTable("SystemLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Category).HasMaxLength(256).IsRequired();
entity.Property(e => e.Message).IsRequired(); // text
entity.Property(e => e.RequestPath).HasMaxLength(2048);
entity.Property(e => e.HttpMethod).HasMaxLength(10);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.Level);
entity.HasIndex(e => new { e.Timestamp, e.Level });
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
builder.Entity<AuditLog>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Action).HasMaxLength(40).IsRequired();
entity.Property(e => e.Category).HasMaxLength(40).IsRequired();
entity.Property(e => e.EntityName).HasMaxLength(128);
entity.Property(e => e.EntityId).HasMaxLength(64);
entity.Property(e => e.Changes).HasColumnType("jsonb");
entity.Property(e => e.Summary).HasMaxLength(512);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.UserEmail).HasMaxLength(256);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => new { e.Category, e.Timestamp });
entity.HasIndex(e => new { e.EntityName, e.EntityId });
entity.HasIndex(e => e.Action);
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ROLAC.API.Entities.Base;
/// <summary>
/// Opt-in marker: entities implementing this are diffed by <c>AuditLogInterceptor</c>, which
/// writes a before→after AuditLog row on every Create/Update/Delete. Applied only to business
/// entities the church cares about — not to internal/high-churn rows (RefreshToken, log tables).
/// </summary>
public interface IAuditable
{
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ROLAC.API.Entities;
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at /// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
/// issue time so the printed check is reproducible even if member data later changes. /// issue time so the printed check is reproducible even if member data later changes.
/// </summary> /// </summary>
public class Check : SoftDeleteEntity public class Check : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string CheckNumber { get; set; } = null!; public string CheckNumber { get; set; } = null!;
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ROLAC.API.Entities;
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the /// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
/// running check-number counter used when disbursing checks. Seeded on startup. /// running check-number counter used when disbursing checks. Seeded on startup.
/// </summary> /// </summary>
public class ChurchProfile : AuditableEntity public class ChurchProfile : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Expense : SoftDeleteEntity public class Expense : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int MinistryId { get; set; } public int MinistryId { get; set; }
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class ExpenseCategoryGroup : AuditableEntity public class ExpenseCategoryGroup : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class ExpenseSubCategory : AuditableEntity public class ExpenseSubCategory : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int GroupId { get; set; } public int GroupId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Giving : AuditableEntity public class Giving : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int? MemberId { get; set; } public int? MemberId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class GivingCategory : AuditableEntity public class GivingCategory : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
@@ -0,0 +1,71 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only audit row recording a meaningful action: a data change (Create/Update/
/// Delete with before→after values), a security event (login, role/permission change), or a
/// key business action (check issued, expense approved, ...). Does NOT inherit AuditableEntity.
/// </summary>
public class AuditLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; } = LogLevelEnum.Information;
/// <summary>One of <see cref="AuditActions"/>.</summary>
public string Action { get; set; } = null!;
/// <summary>One of <see cref="AuditCategories"/> — drives the UI grouping.</summary>
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
/// <summary>String to cover int, Guid and string primary keys uniformly.</summary>
public string? EntityId { get; set; }
/// <summary>JSON <c>{ "before": {...}, "after": {...} }</c> (jsonb column); sensitive fields masked.</summary>
public string? Changes { get; set; }
/// <summary>Human-readable one-liner, e.g. "Check #1042 issued to Acme — $1,200.00".</summary>
public string? Summary { get; set; }
public string? UserId { get; set; }
/// <summary>Denormalized actor email — survives user deletion and avoids a join in the grid.</summary>
public string? UserEmail { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Canonical audit action names (stored verbatim in <see cref="AuditLog.Action"/>).</summary>
public static class AuditActions
{
public const string Create = "Create";
public const string Update = "Update";
public const string Delete = "Delete";
public const string Login = "Login";
public const string Logout = "Logout";
public const string LoginFailed = "LoginFailed";
public const string RoleChanged = "RoleChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved";
public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
ExpenseApproved, StatementFinalized,
];
}
/// <summary>Top-level audit grouping (stored verbatim in <see cref="AuditLog.Category"/>).</summary>
public static class AuditCategories
{
public const string DataChange = "DataChange";
public const string Security = "Security";
public const string Business = "Business";
public static readonly IReadOnlyList<string> All = [DataChange, Security, Business];
}
@@ -0,0 +1,38 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// Persisted severity for system and audit logs. Byte-backed so it stores compactly
/// as <c>smallint</c> and sorts/filters by ordinal. Deliberately omits the
/// <see cref="MsLogLevel.None"/> sentinel (value 6) — "None" means "log nothing" and
/// is meaningless once a row already exists.
/// </summary>
public enum LogLevelEnum : byte
{
Trace = 0,
Debug = 1,
Information = 2,
Warning = 3,
Error = 4,
Critical = 5,
}
public static class LogLevelMap
{
/// <summary>
/// Maps a framework <see cref="MsLogLevel"/> to our persisted enum.
/// <see cref="MsLogLevel.None"/> falls through to <see cref="LogLevelEnum.Critical"/>
/// (it never reaches the sink because the floor filter drops it first).
/// </summary>
public static LogLevelEnum FromMs(MsLogLevel level) => level switch
{
MsLogLevel.Trace => LogLevelEnum.Trace,
MsLogLevel.Debug => LogLevelEnum.Debug,
MsLogLevel.Information => LogLevelEnum.Information,
MsLogLevel.Warning => LogLevelEnum.Warning,
MsLogLevel.Error => LogLevelEnum.Error,
MsLogLevel.Critical => LogLevelEnum.Critical,
_ => LogLevelEnum.Critical,
};
}
@@ -0,0 +1,31 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only operational log row — one per persisted framework/app log event,
/// including every unhandled API exception captured by ExceptionHandlingMiddleware.
/// Intentionally does NOT inherit AuditableEntity: these rows are never updated and
/// must not be re-stamped or re-audited (that would recurse through the log pipeline).
/// </summary>
public class SystemLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; }
/// <summary>The ILogger category (source), e.g. "ROLAC.API.Controllers.GivingsController".</summary>
public string Category { get; set; } = null!;
public int? EventId { get; set; }
public string Message { get; set; } = null!;
/// <summary>Full <c>exception.ToString()</c> (type + message + stack), when present.</summary>
public string? Exception { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public int? StatusCode { get; set; }
/// <summary>The acting user id ("sub" claim), or null for background/system events.</summary>
public string? UserId { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Member : SoftDeleteEntity public class Member : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string FirstName_en { get; set; } = null!; public string FirstName_en { get; set; } = null!;
+3 -1
View File
@@ -1,6 +1,8 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Ministry public class Ministry : IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class MonthlyStatement : AuditableEntity public class MonthlyStatement : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int Year { get; set; } public int Year { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class OfferingSession : AuditableEntity public class OfferingSession : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public DateOnly SessionDate { get; set; } public DateOnly SessionDate { get; set; }
@@ -0,0 +1,78 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace ROLAC.API.Middleware;
/// <summary>
/// Catches any unhandled exception from the downstream pipeline, logs it (which flows through
/// the DB sink into SystemLogs at Error level with full stack + StatusCode 500), and returns a
/// clean RFC7807 problem+json response. Stack traces are never leaked to the client outside
/// Development. Registered as the FIRST middleware so it also catches auth/authorization faults.
/// </summary>
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// The client went away — not a server error; don't log as 500.
return;
}
catch (Exception ex)
{
await HandleAsync(context, ex);
}
}
private async Task HandleAsync(HttpContext context, Exception exception)
{
// Logged here → picked up by the DB sink (Error ≥ Warning floor) with full ex.ToString().
_logger.LogError(
exception,
"Unhandled exception for {Method} {Path} (traceId {TraceId})",
context.Request.Method, context.Request.Path, context.TraceIdentifier);
if (context.Response.HasStarted)
{
// Too late to write a clean body; the log row above is still captured.
return;
}
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
};
problem.Extensions["traceId"] = context.TraceIdentifier;
if (_env.IsDevelopment())
problem.Detail = exception.ToString();
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(problem, JsonSerializerOptions));
}
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}
@@ -885,6 +885,143 @@ namespace ROLAC.API.Migrations
b.ToTable("GivingCategories"); b.ToTable("GivingCategories");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Changes")
.HasColumnType("jsonb");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Summary")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Category", "Timestamp");
b.HasIndex("EntityName", "EntityId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("EventId")
.HasColumnType("integer");
b.Property<string>("Exception")
.HasColumnType("text");
b.Property<string>("HttpMethod")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RequestPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("StatusCode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Level");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Timestamp", "Level");
b.ToTable("SystemLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
+30 -1
View File
@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging.Abstractions;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Json; using ROLAC.API.Json;
using ROLAC.API.Middleware;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; var config = builder.Configuration;
@@ -19,10 +23,31 @@ var config = builder.Configuration;
// Database // Database
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserAccessor>();
builder.Services.AddScoped<AuditSaveChangesInterceptor>(); builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddScoped<AuditLogInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) => builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>())); .AddInterceptors(
sp.GetRequiredService<AuditSaveChangesInterceptor>(),
sp.GetRequiredService<AuditLogInterceptor>()));
// Dedicated context for log writes — NO interceptors and a silent logger factory, so persisting
// a log row produces no log events the DB sink would pick up (breaks recursion / log-storms).
builder.Services.AddDbContext<LogDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.UseLoggerFactory(NullLoggerFactory.Instance));
// ---------------------------------------------------------------------------
// System + audit logging (custom EF DB sink)
// ---------------------------------------------------------------------------
builder.Services.Configure<DatabaseLoggerOptions>(config.GetSection("Logging:Database"));
builder.Services.AddSingleton<SystemLogQueue>();
builder.Services.AddSingleton<ILoggerProvider, DbLoggerProvider>();
builder.Services.AddHostedService<LogWriterBackgroundService>();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();
builder.Services.AddScoped<ISystemLogQueryService, SystemLogQueryService>();
builder.Services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identity (API-only — no cookie auth; JWT is the default scheme) // Identity (API-only — no cookie auth; JWT is the default scheme)
@@ -200,6 +225,10 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
}); });
// First in the pipeline: catch every unhandled exception, log it to SystemLogs, and return
// a clean problem+json. Placed after UseForwardedHeaders so the logged client IP is correct.
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Apply migrations + seed on startup // Apply migrations + seed on startup
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
+32
View File
@@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -13,6 +15,7 @@ public class AuthService : IAuthService
private readonly ITokenService _tokenService; private readonly ITokenService _tokenService;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IPermissionService _permissions; private readonly IPermissionService _permissions;
private readonly IAuditLogger _audit;
private readonly int _refreshTokenExpiryDays; private readonly int _refreshTokenExpiryDays;
public AuthService( public AuthService(
@@ -20,12 +23,14 @@ public class AuthService : IAuthService
ITokenService tokenService, ITokenService tokenService,
AppDbContext db, AppDbContext db,
IPermissionService permissions, IPermissionService permissions,
IAuditLogger audit,
IConfiguration config) IConfiguration config)
{ {
_userManager = userManager; _userManager = userManager;
_tokenService = tokenService; _tokenService = tokenService;
_db = db; _db = db;
_permissions = permissions; _permissions = permissions;
_audit = audit;
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30"); _refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
} }
@@ -38,13 +43,22 @@ public class AuthService : IAuthService
{ {
var user = await _userManager.FindByEmailAsync(request.Email); var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null) if (user is null)
{
AuditLoginFailed(request.Email, "Unknown email", ipAddress);
throw new UnauthorizedAccessException("Invalid credentials."); throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!await _userManager.CheckPasswordAsync(user, request.Password)) if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id);
throw new UnauthorizedAccessException("Invalid credentials."); throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!user.IsActive) if (!user.IsActive)
{
AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id);
throw new UnauthorizedAccessException("Account is inactive."); throw new UnauthorizedAccessException("Account is inactive.");
}
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles); var accessToken = _tokenService.GenerateAccessToken(user, roles);
@@ -65,9 +79,22 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh); return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
} }
private void AuditLoginFailed(string email, string reason, string? ipAddress, string? userId = null)
=> _audit.Write(
AuditActions.LoginFailed, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: userId,
summary: $"Login failed ({reason}): {email}",
userId: userId, userEmail: email, ipAddress: ipAddress);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Refresh // Refresh
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -124,6 +151,11 @@ public class AuthService : IAuthService
{ {
token.RevokedAt = DateTime.UtcNow; token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Logout, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: token.UserId,
summary: "Logout", userId: token.UserId);
} }
} }
+15 -2
View File
@@ -4,7 +4,9 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Disbursement; using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -15,10 +17,11 @@ public class DisbursementService : IDisbursementService
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage; private readonly IFileStorage _storage;
private readonly ICheckPrintService _print; private readonly ICheckPrintService _print;
private readonly IAuditLogger _audit;
public DisbursementService(AppDbContext db, IHttpContextAccessor http, public DisbursementService(AppDbContext db, IHttpContextAccessor http,
IFileStorage storage, ICheckPrintService print) IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; _print = print; } { _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier // The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
// is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens). // is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
@@ -157,6 +160,11 @@ public class DisbursementService : IDisbursementService
result.Created.Add(new IssuedCheckDto result.Created.Add(new IssuedCheckDto
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount }); { CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
_audit.Write(
AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Check), entityId: check.Id.ToString(),
summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}");
} }
await tx.CommitAsync(); await tx.CommitAsync();
@@ -227,6 +235,11 @@ public class DisbursementService : IDisbursementService
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
_audit.Write(
AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning,
entityName: nameof(Check), entityId: c.Id.ToString(),
summary: $"Check #{c.CheckNumber} voided ({reason})");
} }
// ── Receipt e-signature ───────────────────────────────────────────────────── // ── Receipt e-signature ─────────────────────────────────────────────────────
+10 -2
View File
@@ -4,6 +4,8 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -13,9 +15,10 @@ public class ExpenseService : IExpenseService
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage; private readonly IFileStorage _storage;
private readonly IAuditLogger _audit;
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage) public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; } { _db = db; _http = http; _storage = storage; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
@@ -211,6 +214,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'."); if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
} }
public async Task RejectAsync(int id, string? reviewNotes) public async Task RejectAsync(int id, string? reviewNotes)
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Serializes audit before/after payloads to JSON and masks sensitive property names.
/// Shared by <see cref="AuditLogger"/> and the EF audit interceptor so masking is consistent.
/// </summary>
public static class AuditChangeSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Property names whose values are replaced with <see cref="MaskValue"/> wherever they appear.</summary>
private static readonly HashSet<string> SensitiveNames = new(StringComparer.OrdinalIgnoreCase)
{
"BankAccountNumber",
"BankRoutingNumber",
"PasswordHash",
"Password",
"SecurityStamp",
"ConcurrencyStamp",
};
public const string MaskValue = "***";
public static bool IsSensitive(string propertyName) => SensitiveNames.Contains(propertyName);
/// <summary>Builds the <c>{ before, after }</c> JSON; returns null when both sides are empty.</summary>
public static string? BuildChanges(object? before, object? after)
{
if (before is null && after is null)
return null;
var payload = new Dictionary<string, object?>();
if (before is not null) payload["before"] = MaskObject(before);
if (after is not null) payload["after"] = MaskObject(after);
return JsonSerializer.Serialize(payload, JsonOptions);
}
/// <summary>Serializes a value (e.g. a property dictionary built by the interceptor) to JSON.</summary>
public static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions);
/// <summary>
/// Masks a free-form object by reflecting over its public properties. Used for the explicit
/// IAuditLogger.Write path (the interceptor masks per-property as it builds its dictionary).
/// </summary>
private static object MaskObject(object value)
{
if (value is IDictionary<string, object?> dict)
{
var masked = new Dictionary<string, object?>();
foreach (var (key, val) in dict)
masked[key] = IsSensitive(key) ? MaskValue : val;
return masked;
}
var result = new Dictionary<string, object?>();
foreach (var prop in value.GetType().GetProperties())
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0)
continue;
result[prop.Name] = IsSensitive(prop.Name) ? MaskValue : prop.GetValue(value);
}
return result;
}
}
@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface IAuditLogQueryService
{
Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query);
Task<AuditLogDetailDto?> GetByIdAsync(long id);
AuditCatalogDto GetCatalog();
}
/// <summary>Read-only, paged access to the AuditLogs table via the dedicated LogDbContext.</summary>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
private readonly LogDbContext _db;
public AuditLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.AuditLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.Category)) rows = rows.Where(l => l.Category == query.Category);
if (!string.IsNullOrWhiteSpace(query.Action)) rows = rows.Where(l => l.Action == query.Action);
if (!string.IsNullOrWhiteSpace(query.EntityName)) rows = rows.Where(l => l.EntityName == query.EntityName);
if (!string.IsNullOrWhiteSpace(query.EntityId)) rows = rows.Where(l => l.EntityId == query.EntityId);
if (!string.IsNullOrWhiteSpace(query.UserId)) rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
(l.Summary != null && l.Summary.ToLower().Contains(term)) ||
(l.EntityName != null && l.EntityName.ToLower().Contains(term)) ||
(l.UserEmail != null && l.UserEmail.ToLower().Contains(term)));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new AuditLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
})
.ToListAsync();
return new PagedResult<AuditLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<AuditLogDetailDto?> GetByIdAsync(long id)
{
return await _db.AuditLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new AuditLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
Changes = l.Changes,
IpAddress = l.IpAddress,
CorrelationId = l.CorrelationId,
})
.FirstOrDefaultAsync();
}
public AuditCatalogDto GetCatalog() => new()
{
Categories = AuditCategories.All,
Actions = AuditActions.All,
Levels = Enum.GetNames<LogLevelEnum>(),
};
}
@@ -0,0 +1,52 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Scoped <see cref="IAuditLogger"/>: fills actor/request context from
/// <see cref="CurrentUserAccessor"/> and enqueues the row onto the shared queue (no direct DB
/// write, so it can't fail a business transaction or recurse through AppDbContext).
/// </summary>
public sealed class AuditLogger : IAuditLogger
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
public AuditLogger(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null)
{
var log = new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = level,
Action = action,
Category = category,
EntityName = entityName,
EntityId = entityId,
Summary = summary,
Changes = AuditChangeSerializer.BuildChanges(before, after),
UserId = userId ?? _currentUser.UserId,
UserEmail = userEmail ?? _currentUser.Email,
IpAddress = ipAddress ?? _currentUser.IpAddress,
CorrelationId = _currentUser.CorrelationId,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// One place to resolve the acting user + request context from the current HttpContext, so the
/// "sub" claim quirk (JWT uses NameClaimType="sub" + MapInboundClaims=false, leaving
/// ClaimTypes.NameIdentifier null) lives in a single spot. Used by the audit interceptor,
/// IAuditLogger, the exception middleware, and the timestamp-stamping interceptor.
/// </summary>
public sealed class CurrentUserAccessor
{
private readonly IHttpContextAccessor _http;
public CurrentUserAccessor(IHttpContextAccessor http) => _http = http;
/// <summary>The acting user id, or null when unauthenticated / off the request thread.</summary>
public string? UserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub");
/// <summary>The acting user id, or "system" for background/unauthenticated work.</summary>
public string UserIdOrSystem => UserId ?? "system";
public string? Email => _http.HttpContext?.User.FindFirstValue("email");
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
public string? CorrelationId => _http.HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,27 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Bound from configuration section <c>Logging:Database</c>. Controls what the DB sink persists.
/// </summary>
public sealed class DatabaseLoggerOptions
{
/// <summary>The minimum level actually written to the SystemLogs table. Default: Warning.</summary>
public MsLogLevel MinimumLevel { get; set; } = MsLogLevel.Warning;
/// <summary>
/// Category prefixes never persisted — prevents recursion and log-storms. The DB sink
/// excludes EF/Npgsql (their SQL firehose), the ASP.NET request firehose, and its own
/// writer namespace.
/// </summary>
public string[] ExcludedCategories { get; set; } =
[
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging",
];
}
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ROLAC.API.Entities.Logging;
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton <see cref="ILoggerProvider"/> that turns framework/app log events into
/// <see cref="SystemLog"/> rows enqueued onto <see cref="SystemLogQueue"/>. It depends only on
/// singletons (queue, options, IHttpContextAccessor) and NEVER touches a DbContext — that is
/// what makes the enqueue-only design safe from the singleton logging pipeline.
/// </summary>
[ProviderAlias("Database")]
public sealed class DbLoggerProvider : ILoggerProvider
{
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly ConcurrentDictionary<string, DbLogger> _loggers = new();
public DbLoggerProvider(
SystemLogQueue queue, IOptions<DatabaseLoggerOptions> options, IHttpContextAccessor http)
{
_queue = queue;
_options = options.Value;
_http = http;
}
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, name => new DbLogger(name, _queue, _options, _http));
public void Dispose() => _loggers.Clear();
}
/// <summary>The per-category logger. Drops events below the floor or in excluded categories.</summary>
internal sealed class DbLogger : ILogger
{
private readonly string _category;
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly bool _excluded;
public DbLogger(
string category, SystemLogQueue queue, DatabaseLoggerOptions options, IHttpContextAccessor http)
{
_category = category;
_queue = queue;
_options = options;
_http = http;
_excluded = options.ExcludedCategories.Any(prefix =>
category.StartsWith(prefix, StringComparison.Ordinal));
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MsLogLevel logLevel) =>
!_excluded && logLevel != MsLogLevel.None && logLevel >= _options.MinimumLevel;
public void Log<TState>(
MsLogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var context = _http.HttpContext;
var log = new SystemLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelMap.FromMs(logLevel),
Category = _category,
EventId = eventId.Id == 0 ? null : eventId.Id,
Message = formatter(state, exception),
Exception = exception?.ToString(),
RequestPath = context?.Request.Path.Value,
HttpMethod = context?.Request.Method,
UserId = context?.User.FindFirst("sub")?.Value,
IpAddress = context?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = context?.TraceIdentifier,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,29 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Records audit events that don't flow through EF change tracking — security actions
/// (login/logout/role changes) and key business actions (check issued, expense approved, ...).
/// Data-change audits are produced automatically by <c>AuditLogInterceptor</c>; use this for the
/// semantic action + human summary the raw diff can't express.
/// </summary>
public interface IAuditLogger
{
/// <summary>
/// Build and enqueue an audit row. <paramref name="before"/>/<paramref name="after"/> are
/// serialized into the <c>Changes</c> JSON. Never throws — failures are dropped like all logs.
/// </summary>
void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null);
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// The single consumer that drains <see cref="SystemLogQueue"/> and batch-inserts rows through
/// the dedicated <see cref="LogDbContext"/> (a fresh DI scope per batch). Persistence failures
/// are swallowed to <c>Console.Error</c> only — they must never propagate back into the logging
/// pipeline or crash the host.
/// </summary>
public sealed class LogWriterBackgroundService : BackgroundService
{
private const int MaxBatchSize = 200;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(1);
private readonly SystemLogQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
public LogWriterBackgroundService(SystemLogQueue queue, IServiceScopeFactory scopeFactory)
{
_queue = queue;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var systemBatch = new List<SystemLog>(MaxBatchSize);
var auditBatch = new List<AuditLog>(MaxBatchSize);
try
{
await foreach (var envelope in _queue.ReadAllAsync(stoppingToken))
{
if (envelope.System is not null) systemBatch.Add(envelope.System);
if (envelope.Audit is not null) auditBatch.Add(envelope.Audit);
// Coalesce a short burst into one round-trip; flush on size or a brief idle.
if (systemBatch.Count + auditBatch.Count >= MaxBatchSize)
{
await FlushAsync(systemBatch, auditBatch, stoppingToken);
continue;
}
if (!await WaitForMoreAsync(FlushInterval, stoppingToken))
await FlushAsync(systemBatch, auditBatch, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — drain whatever is buffered.
}
await FlushAsync(systemBatch, auditBatch, CancellationToken.None);
}
/// <summary>Brief debounce so bursts coalesce; returns false once the window elapses.</summary>
private static async Task<bool> WaitForMoreAsync(TimeSpan window, CancellationToken token)
{
try
{
await Task.Delay(window, token);
return false;
}
catch (OperationCanceledException)
{
return false;
}
}
private async Task FlushAsync(
List<SystemLog> systemBatch, List<AuditLog> auditBatch, CancellationToken token)
{
if (systemBatch.Count == 0 && auditBatch.Count == 0)
return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LogDbContext>();
if (systemBatch.Count > 0) db.SystemLogs.AddRange(systemBatch);
if (auditBatch.Count > 0) db.AuditLogs.AddRange(auditBatch);
await db.SaveChangesAsync(token);
}
catch (Exception ex)
{
// Last resort: never throw out of the log writer. Include the inner exception —
// the EF wrapper message alone ("An error occurred while saving...") hides the cause.
var detail = ex.InnerException is null ? ex.Message : $"{ex.Message} -> {ex.InnerException.Message}";
await Console.Error.WriteLineAsync(
$"[LogWriter] Failed to persist {systemBatch.Count} system + {auditBatch.Count} audit rows: {detail}");
}
finally
{
systemBatch.Clear();
auditBatch.Clear();
}
}
}
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface ISystemLogQueryService
{
Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query);
Task<SystemLogDetailDto?> GetByIdAsync(long id);
}
/// <summary>Read-only, paged access to the SystemLogs table via the dedicated LogDbContext.</summary>
public sealed class SystemLogQueryService : ISystemLogQueryService
{
private readonly LogDbContext _db;
public SystemLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.SystemLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.Level is not null) rows = rows.Where(l => l.Level == query.Level);
else if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.UserId))
rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.CorrelationId))
rows = rows.Where(l => l.CorrelationId == query.CorrelationId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
l.Message.ToLower().Contains(term) || l.Category.ToLower().Contains(term));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new SystemLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
})
.ToListAsync();
return new PagedResult<SystemLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<SystemLogDetailDto?> GetByIdAsync(long id)
{
return await _db.SystemLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new SystemLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
EventId = l.EventId,
Exception = l.Exception,
IpAddress = l.IpAddress,
})
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,32 @@
using System.Threading.Channels;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton, bounded in-memory queue decoupling log producers (the ILogger hot path, the
/// audit interceptor, singleton services) from the single background DB writer. Enqueue is a
/// non-blocking <c>TryWrite</c>; when full the OLDEST entry is dropped (DropWrite) rather than
/// blocking a request thread or throwing — logging must never throw or stall business code.
/// Carries both SystemLog and AuditLog rows via a small union envelope.
/// </summary>
public sealed class SystemLogQueue
{
private readonly Channel<LogEnvelope> _channel =
Channel.CreateBounded<LogEnvelope>(new BoundedChannelOptions(capacity: 4096)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
public bool TryEnqueue(SystemLog log) => _channel.Writer.TryWrite(new LogEnvelope(log, null));
public bool TryEnqueue(AuditLog log) => _channel.Writer.TryWrite(new LogEnvelope(null, log));
public IAsyncEnumerable<LogEnvelope> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
/// <summary>Either a SystemLog or an AuditLog — exactly one is non-null.</summary>
public sealed record LogEnvelope(SystemLog? System, AuditLog? Audit);
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -10,7 +12,9 @@ public class MonthlyStatementService : IMonthlyStatementService
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; } private readonly IAuditLogger _audit;
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http, IAuditLogger audit)
{ _db = db; _http = http; _audit = audit; }
// See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests. // See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests.
private string CurrentUserId => private string CurrentUserId =>
@@ -66,6 +70,11 @@ public class MonthlyStatementService : IMonthlyStatementService
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found."); ?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId; s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.StatementFinalized, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(MonthlyStatement), entityId: s.Id.ToString(),
summary: $"Monthly statement {s.Year}-{s.Month:D2} finalized");
} }
private async Task RecomputeAsync(MonthlyStatement s) private async Task RecomputeAsync(MonthlyStatement s)
+30 -1
View File
@@ -1,9 +1,12 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using ROLAC.API.Authorization; using ROLAC.API.Authorization;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions; using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -36,11 +39,19 @@ public class PermissionService : IPermissionService
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly SystemLogQueue _logQueue;
private readonly IHttpContextAccessor _http;
public PermissionService(IServiceScopeFactory scopeFactory, IMemoryCache cache) public PermissionService(
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
SystemLogQueue logQueue,
IHttpContextAccessor http)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_cache = cache; _cache = cache;
_logQueue = logQueue;
_http = http;
} }
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action) public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
@@ -174,6 +185,24 @@ public class PermissionService : IPermissionService
await db.SaveChangesAsync(); await db.SaveChangesAsync();
Invalidate(); Invalidate();
// Singleton service can't use the scoped IAuditLogger — enqueue directly.
var user = _http.HttpContext?.User;
_logQueue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Warning,
Action = AuditActions.PermissionChanged,
Category = AuditCategories.Security,
EntityName = "Role",
EntityId = roleName,
Summary = $"Permissions updated for role '{roleName}'",
Changes = AuditChangeSerializer.BuildChanges(null, new { Role = roleName, Modules = rows }),
UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue("sub"),
UserEmail = user?.FindFirstValue("email"),
IpAddress = _http.HttpContext?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = _http.HttpContext?.TraceIdentifier,
});
} }
public void Invalidate() => _cache.Remove(CacheKey); public void Invalidate() => _cache.Remove(CacheKey);
@@ -5,6 +5,8 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.DTOs.Users; using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -12,11 +14,13 @@ public class UserManagementService : IUserManagementService
{ {
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IAuditLogger _audit;
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db) public UserManagementService(UserManager<AppUser> userManager, AppDbContext db, IAuditLogger audit)
{ {
_userManager = userManager; _userManager = userManager;
_db = db; _db = db;
_audit = audit;
} }
// ── GetPaged ───────────────────────────────────────────────────────────── // ── GetPaged ─────────────────────────────────────────────────────────────
@@ -154,6 +158,12 @@ public class UserManagementService : IUserManagementService
await _userManager.AddToRolesAsync(user, request.Roles); await _userManager.AddToRolesAsync(user, request.Roles);
_audit.Write(
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"User created: {user.Email} with roles [{string.Join(", ", request.Roles)}]",
after: new { user.Email, Roles = request.Roles });
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword }; return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
} }
@@ -182,6 +192,13 @@ public class UserManagementService : IUserManagementService
var toAdd = request.Roles.Except(currentRoles).ToList(); var toAdd = request.Roles.Except(currentRoles).ToList();
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove); if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd); if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
if (toRemove.Count > 0 || toAdd.Count > 0)
_audit.Write(
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Roles changed for {user.Email}",
before: new { Roles = currentRoles }, after: new { Roles = request.Roles });
} }
// ── Deactivate ─────────────────────────────────────────────────────────── // ── Deactivate ───────────────────────────────────────────────────────────
@@ -193,6 +210,11 @@ public class UserManagementService : IUserManagementService
user.IsActive = false; user.IsActive = false;
user.LockoutEnd = DateTimeOffset.MaxValue; user.LockoutEnd = DateTimeOffset.MaxValue;
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
_audit.Write(
AuditActions.UserDeactivated, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"User deactivated: {user.Email}");
} }
// ── ResetPassword ──────────────────────────────────────────────────────── // ── ResetPassword ────────────────────────────────────────────────────────
+11
View File
@@ -3,6 +3,17 @@
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
},
"Database": {
"MinimumLevel": "Warning",
"ExcludedCategories": [
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging"
]
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
+82 -15
View File
@@ -21,6 +21,8 @@ import { CheckRegisterPageComponent } from './features/disbursement/pages/check-
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
export const routes: Routes = [ export const routes: Routes = [
// Public routes // Public routes
@@ -39,85 +41,150 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }, {
path: 'dashboard',
component: DashboardComponent,
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
},
{ {
path: 'admin/members', path: 'admin/members',
component: MembersPageComponent, component: MembersPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Members, action: 'read' } }, data: {
permission: { module: PermissionModules.Members, action: 'read' },
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
},
}, },
{ {
path: 'admin/users', path: 'admin/users',
component: UsersPageComponent, component: UsersPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Users, action: 'read' } }, data: {
permission: { module: PermissionModules.Users, action: 'read' },
title: 'User Management', titleZh: '使用者管理', section: 'Admin',
},
}, },
{ {
path: 'admin/permissions', path: 'admin/permissions',
component: PermissionsPageComponent, component: PermissionsPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Permissions, action: 'read' } }, data: {
permission: { module: PermissionModules.Permissions, action: 'read' },
title: 'Role Permissions', titleZh: '權限設定', section: 'Admin',
},
},
{
path: 'admin/logs/system',
component: SystemLogsPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.SystemLogs, action: 'read' },
title: 'System Logs', titleZh: '系統日誌', section: 'Admin',
},
},
{
path: 'admin/logs/audit',
component: AuditLogsPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.AuditLogs, action: 'read' },
title: 'Audit Logs', titleZh: '稽核日誌', section: 'Admin',
},
}, },
{ {
path: 'finance/dashboard', path: 'finance/dashboard',
component: FinanceDashboardPageComponent, component: FinanceDashboardPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.FinanceDashboard, action: 'read' } }, data: {
permission: { module: PermissionModules.FinanceDashboard, action: 'read' },
title: 'Finance Dashboard', titleZh: '財務儀表板', section: 'Finance',
},
}, },
{ {
path: 'finance/giving-categories', path: 'finance/giving-categories',
component: GivingCategoriesPageComponent, component: GivingCategoriesPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.GivingCategories, action: 'read' } }, data: {
permission: { module: PermissionModules.GivingCategories, action: 'read' },
title: 'Giving Types', titleZh: '奉獻類型', section: 'Finance',
},
}, },
{ {
path: 'finance/givings', path: 'finance/givings',
component: GivingsPageComponent, component: GivingsPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Givings, action: 'read' } }, data: {
permission: { module: PermissionModules.Givings, action: 'read' },
title: 'Givings', titleZh: '單筆奉獻', section: 'Finance',
},
}, },
{ {
path: 'finance/offering-session', path: 'finance/offering-session',
component: OfferingSessionPageComponent, component: OfferingSessionPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.OfferingSessions, action: 'read' } }, data: {
permission: { module: PermissionModules.OfferingSessions, action: 'read' },
title: 'Sunday Offering Entry', titleZh: '主日奉獻錄入', section: 'Finance',
},
},
{
path: 'reimbursements',
component: MyReimbursementsPageComponent,
data: { title: 'My Reimbursements', titleZh: '我的報銷', section: 'Finance' },
}, },
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
{ {
path: 'finance/expenses', path: 'finance/expenses',
component: ExpensesPageComponent, component: ExpensesPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Expenses, action: 'read' } }, data: {
permission: { module: PermissionModules.Expenses, action: 'read' },
title: 'Expenses', titleZh: '支出', section: 'Finance',
},
}, },
{ {
path: 'finance/expense-categories', path: 'finance/expense-categories',
component: ExpenseCategoriesPageComponent, component: ExpenseCategoriesPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.ExpenseCategories, action: 'read' } }, data: {
permission: { module: PermissionModules.ExpenseCategories, action: 'read' },
title: 'Expense Categories', titleZh: '費用類別', section: 'Finance',
},
}, },
{ {
path: 'finance/monthly-statement', path: 'finance/monthly-statement',
component: MonthlyStatementPageComponent, component: MonthlyStatementPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.MonthlyStatements, action: 'read' } }, data: {
permission: { module: PermissionModules.MonthlyStatements, action: 'read' },
title: 'Monthly Statement', titleZh: '月報表', section: 'Finance',
},
}, },
{ {
path: 'finance/disbursements', path: 'finance/disbursements',
component: DisbursementPageComponent, component: DisbursementPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } }, data: {
permission: { module: PermissionModules.Disbursements, action: 'read' },
title: 'Disbursement Management', titleZh: '支票開立', section: 'Finance',
},
}, },
{ {
path: 'finance/check-register', path: 'finance/check-register',
component: CheckRegisterPageComponent, component: CheckRegisterPageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.Disbursements, action: 'read' } }, data: {
permission: { module: PermissionModules.Disbursements, action: 'read' },
title: 'Check Register', titleZh: '支票登記簿', section: 'Finance',
},
}, },
{ {
path: 'finance/church-profile', path: 'finance/church-profile',
component: ChurchProfilePageComponent, component: ChurchProfilePageComponent,
canActivate: [PermissionGuard], canActivate: [PermissionGuard],
data: { permission: { module: PermissionModules.ChurchProfile, action: 'read' } }, data: {
permission: { module: PermissionModules.ChurchProfile, action: 'read' },
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
},
}, },
] ]
}, },
@@ -29,6 +29,8 @@ export const PermissionModules = {
Disbursements: 'Disbursements', Disbursements: 'Disbursements',
MealAttendance: 'MealAttendance', MealAttendance: 'MealAttendance',
Permissions: 'Permissions', Permissions: 'Permissions',
SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs',
} as const; } as const;
/** A required permission, used in route data and the *appHasPermission directive. */ /** A required permission, used in route data and the *appHasPermission directive. */
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Check Register / 支票登記簿</h2>
</header>
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Search Search
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Church Profile / 教會資料</h2>
</header>
<div *ngIf="model" class="max-w-3xl"> <div *ngIf="model" class="max-w-3xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2"> <label class="flex flex-col gap-1 md:col-span-2">
@@ -1,10 +1,9 @@
<div class="page"> <div class="page">
<header class="page-header flex items-center justify-between"> <ng-template appPageHeaderActions>
<h2>Disbursement Management / 支票開立</h2>
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()"> <button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
Issue Checks ({{ selectedCount }}) Issue Checks ({{ selectedCount }})
</button> </button>
</header> </ng-template>
<p class="text-sm mb-3" style="color:#6b7280;"> <p class="text-sm mb-3" style="color:#6b7280;">
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each. Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
@@ -1,3 +0,0 @@
.page-header {
margin-bottom: 0.5rem;
}
@@ -6,13 +6,14 @@ import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DisbursementApiService } from '../../services/disbursement-api.service'; import { DisbursementApiService } from '../../services/disbursement-api.service';
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component'; import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model'; import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
interface PayeeRow extends PayeeGroupDto { key: string; } interface PayeeRow extends PayeeGroupDto { key: string; }
@Component({ @Component({
selector: 'app-disbursement-page', selector: 'app-disbursement-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent], imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent, PageHeaderActionsDirective],
templateUrl: './disbursement-page.component.html', templateUrl: './disbursement-page.component.html',
styleUrls: ['./disbursement-page.component.scss'], styleUrls: ['./disbursement-page.component.scss'],
}) })
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Expense Categories / 費用類別</h2>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Left: Category Groups --> <!-- Left: Category Groups -->
@@ -1,10 +1,3 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.panel-header { .panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Expenses</h2>
</header>
<!-- Filter toolbar --> <!-- Filter toolbar -->
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Monthly Statements</h2>
</header>
<!-- Filter toolbar --> <!-- Filter toolbar -->
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -1,8 +1,7 @@
<div class="page"> <div class="page">
<header class="page-header"> <ng-template appPageHeaderActions>
<h2>My Reimbursements</h2>
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button> <button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
</header> </ng-template>
<kendo-grid [data]="rows" [loading]="loading"> <kendo-grid [data]="rows" [loading]="loading">
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column> <kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
@@ -6,11 +6,12 @@ import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto } from '../../models/expense.model'; import { ExpenseListItemDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs'; import { switchMap, of } from 'rxjs';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-my-reimbursements-page', selector: 'app-my-reimbursements-page',
standalone: true, standalone: true,
imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent], imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent, PageHeaderActionsDirective],
templateUrl: './my-reimbursements-page.component.html', templateUrl: './my-reimbursements-page.component.html',
styleUrls: ['./my-reimbursements-page.component.scss'], styleUrls: ['./my-reimbursements-page.component.scss'],
}) })
@@ -1,11 +1,19 @@
<div class="fin"> <div class="fin">
<!-- Page header --> <!-- Range filter — projected into the shared system header's right slot -->
<header class="fin__head"> <ng-template appPageHeaderActions>
<div> <div class="chips">
<span class="fin__eyebrow">River of Life · Finance</span> <button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
<h1 class="fin__title">Finance Dashboard <span>財務儀表板</span></h1> <button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
</div> </div>
</header> <div class="range">
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
<span class="range__sep"></span>
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
</div>
</ng-template>
<!-- Top band: hero balance + supporting stats (all-time) --> <!-- Top band: hero balance + supporting stats (all-time) -->
<section class="fin__band rise" style="--d: 0ms"> <section class="fin__band rise" style="--d: 0ms">
@@ -36,22 +44,6 @@
</div> </div>
</section> </section>
<!-- Range filter -->
<section class="fin__filter rise" style="--d: 80ms">
<div class="chips">
<button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
</div>
<div class="range">
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
<span class="range__sep"></span>
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
</div>
</section>
<!-- Analytics grid --> <!-- Analytics grid -->
<section class="fin__grid"> <section class="fin__grid">
<!-- 2.1 Income vs Expense --> <!-- 2.1 Income vs Expense -->
@@ -21,34 +21,6 @@
min-height: 100%; min-height: 100%;
} }
.fin__head {
margin-bottom: 22px;
}
.fin__eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--kendo-color-primary, #0279cf);
margin-bottom: 6px;
}
.fin__title {
font-size: clamp(26px, 3.2vw, 36px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
span {
font-weight: 400;
color: var(--ink-soft);
margin-left: 8px;
font-size: 0.62em;
}
}
/* ---------- Top band ---------- */ /* ---------- Top band ---------- */
.fin__band { .fin__band {
display: grid; display: grid;
@@ -174,21 +146,7 @@
.stat--income .stat__value { color: var(--income); } .stat--income .stat__value { color: var(--income); }
.stat--expense .stat__value { color: var(--expense); } .stat--expense .stat__value { color: var(--expense); }
/* ---------- Filter ---------- */ /* ---------- Filter (projected into the system header's actions slot) ---------- */
.fin__filter {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 22px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--line);
backdrop-filter: blur(6px);
}
.chips { display: flex; gap: 6px; flex-wrap: wrap; } .chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip { .chip {
@@ -10,6 +10,7 @@ import { ExpenseApiService } from '../../../expense/services/expense-api.service
import { ExpenseListItemDto } from '../../../expense/models/expense.model'; import { ExpenseListItemDto } from '../../../expense/models/expense.model';
import { FinanceDashboardApiService } from '../../services/finance-dashboard-api.service'; import { FinanceDashboardApiService } from '../../services/finance-dashboard-api.service';
import { FinanceSummaryDto, PieSlice, DrillLevel, Crumb } from '../../models/finance-dashboard.model'; import { FinanceSummaryDto, PieSlice, DrillLevel, Crumb } from '../../models/finance-dashboard.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
// Expense scope for the dashboard: Paid + Approved (shared by the pies and the detail grid). // Expense scope for the dashboard: Paid + Approved (shared by the pies and the detail grid).
const DASHBOARD_STATUSES = 'Paid,Approved'; const DASHBOARD_STATUSES = 'Paid,Approved';
@@ -18,7 +19,7 @@ type QuickRange = 'month' | 'lastMonth' | 'year' | null;
@Component({ @Component({
selector: 'app-finance-dashboard-page', selector: 'app-finance-dashboard-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, ChartsModule, DateInputsModule, GridModule, ButtonsModule], imports: [CommonModule, FormsModule, ChartsModule, DateInputsModule, GridModule, ButtonsModule, PageHeaderActionsDirective],
templateUrl: './finance-dashboard-page.component.html', templateUrl: './finance-dashboard-page.component.html',
styleUrls: ['./finance-dashboard-page.component.scss'], styleUrls: ['./finance-dashboard-page.component.scss'],
}) })
@@ -1,13 +1,10 @@
<div class="page"> <div class="page">
<header class="page-header"> <ng-template appPageHeaderActions>
<h2>Giving Types / 奉獻類型</h2>
<div class="header-actions">
<label class="inactive-toggle"> <label class="inactive-toggle">
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive <input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
</label> </label>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button> <button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
</div> </ng-template>
</header>
<kendo-grid [data]="data" [loading]="isLoading"> <kendo-grid [data]="data" [loading]="isLoading">
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column> <kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
@@ -1,10 +1,3 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -9,11 +9,12 @@ import { GivingCategoryApiService } from '../../services/giving-category-api.ser
import { import {
GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest, GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest,
} from '../../models/giving.model'; } from '../../models/giving.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-giving-categories-page', selector: 'app-giving-categories-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule], imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule, PageHeaderActionsDirective],
templateUrl: './giving-categories-page.component.html', templateUrl: './giving-categories-page.component.html',
styleUrls: ['./giving-categories-page.component.scss'], styleUrls: ['./giving-categories-page.component.scss'],
}) })
@@ -1,8 +1,7 @@
<div class="page"> <div class="page">
<header class="page-header"> <ng-template appPageHeaderActions>
<h2>Givings / 單筆奉獻</h2>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button> <button kendoButton themeColor="primary" (click)="openAdd()">+ Add Giving</button>
</header> </ng-template>
<div class="filters"> <div class="filters">
<kendo-textbox placeholder="Search name / check # / notes" [(ngModel)]="search" (keydown.enter)="onSearch()"></kendo-textbox> <kendo-textbox placeholder="Search name / check # / notes" [(ngModel)]="search" (keydown.enter)="onSearch()"></kendo-textbox>
@@ -1,2 +1 @@
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; } .filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
@@ -15,6 +15,7 @@ import {
GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult, GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult,
} from '../../models/giving.model'; } from '../../models/giving.model';
import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists'; import { PAYMENT_METHOD_OPTIONS } from '../../../../shared/i18n/option-lists';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
/** Flattened member item with a single displayName field for the dropdown. */ /** Flattened member item with a single displayName field for the dropdown. */
interface MemberOption { interface MemberOption {
@@ -27,7 +28,7 @@ interface MemberOption {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule,
DropDownsModule, DialogsModule, DateInputsModule, DropDownsModule, DialogsModule, DateInputsModule, PageHeaderActionsDirective,
], ],
templateUrl: './givings-page.component.html', templateUrl: './givings-page.component.html',
styleUrls: ['./givings-page.component.scss'], styleUrls: ['./givings-page.component.scss'],
@@ -1,10 +1,4 @@
<div class="off"> <div class="off">
<!-- Header (always) -->
<header class="off__head">
<span class="off__eyebrow">River of Life · Offering</span>
<h1 class="off__title">Sunday Offering Entry <span>主日奉獻錄入</span></h1>
</header>
<!-- ============================ LANDING ============================ --> <!-- ============================ LANDING ============================ -->
<ng-container *ngIf="mode === 'landing'"> <ng-container *ngIf="mode === 'landing'">
<!-- Start card --> <!-- Start card -->
@@ -21,33 +21,6 @@
min-height: 100%; min-height: 100%;
} }
.off__head { margin-bottom: 22px; }
.off__eyebrow {
display: inline-block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--kendo-color-primary, #0279cf);
margin-bottom: 6px;
}
.off__title {
font-size: clamp(26px, 3.2vw, 36px);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
span {
font-weight: 400;
color: var(--ink-soft);
margin-left: 8px;
font-size: 0.62em;
}
}
/* ---------- Card ---------- */ /* ---------- Card ---------- */
.card { .card {
background: var(--card-bg); background: var(--card-bg);
@@ -0,0 +1,83 @@
/** Mirrors the C# DTOs.Shared.PagedResult<T>. */
export interface PagedResult<T> {
items: T[];
totalCount: number;
page: number;
pageSize: number;
totalPages: number;
}
// ── System logs ───────────────────────────────────────────────────────────────
export interface SystemLogListItem {
id: number;
timestamp: string;
level: string;
category: string;
message: string;
hasException: boolean;
statusCode?: number | null;
requestPath?: string | null;
httpMethod?: string | null;
userId?: string | null;
correlationId?: string | null;
}
export interface SystemLogDetail extends SystemLogListItem {
eventId?: number | null;
exception?: string | null;
ipAddress?: string | null;
}
export interface SystemLogQuery {
page?: number;
pageSize?: number;
from?: string;
to?: string;
minLevel?: string;
level?: string;
search?: string;
userId?: string;
correlationId?: string;
}
// ── Audit logs ────────────────────────────────────────────────────────────────
export interface AuditLogListItem {
id: number;
timestamp: string;
level: string;
action: string;
category: string;
entityName?: string | null;
entityId?: string | null;
summary?: string | null;
userId?: string | null;
userEmail?: string | null;
}
export interface AuditLogDetail extends AuditLogListItem {
changes?: string | null;
ipAddress?: string | null;
correlationId?: string | null;
}
export interface AuditLogQuery {
page?: number;
pageSize?: number;
from?: string;
to?: string;
category?: string;
action?: string;
entityName?: string;
entityId?: string;
userId?: string;
minLevel?: string;
search?: string;
}
export interface AuditCatalog {
categories: string[];
actions: string[];
levels: string[];
}
@@ -0,0 +1,83 @@
<div class="page">
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search / 搜尋
<kendo-textbox placeholder="Summary / entity / user" [(ngModel)]="search"
(keydown.enter)="applyFilter()"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Category / 類別
<kendo-dropdownlist [data]="categories" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="category" [defaultItem]="{ value: null, label: 'All Categories/全部類別' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Action / 動作
<kendo-dropdownlist [data]="actions" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="action" [defaultItem]="{ value: null, label: 'All Actions/全部動作' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
From / 起
<kendo-datepicker [(ngModel)]="from"></kendo-datepicker>
</label>
<label class="flex flex-col gap-1">
To / 迄
<kendo-datepicker [(ngModel)]="to"></kendo-datepicker>
</label>
<button kendoButton themeColor="primary" (click)="applyFilter()">Apply / 套用</button>
</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column title="Time / 時間" [width]="170">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.timestamp | date:'short' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Level / 等級" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="levelClass(dataItem.level)">{{ dataItem.level }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="category" title="Category / 類別" [width]="120"></kendo-grid-column>
<kendo-grid-column field="action" title="Action / 動作" [width]="140"></kendo-grid-column>
<kendo-grid-column title="Entity / 對象" [width]="160">
<ng-template kendoGridCellTemplate let-dataItem>
<span *ngIf="dataItem.entityName">{{ dataItem.entityName }}<span *ngIf="dataItem.entityId"> #{{ dataItem.entityId }}</span></span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="summary" title="Summary / 摘要"></kendo-grid-column>
<kendo-grid-column title="User / 使用者" [width]="180">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.userEmail || dataItem.userId }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="" [width]="90">
<ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton fillMode="flat" themeColor="primary" (click)="view(dataItem)">View</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="Audit Log #{{ detail.id }}" [width]="720" (close)="detail = null">
<div class="p-2 flex flex-col gap-2 text-sm">
<div class="grid grid-cols-2 gap-2">
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
<div><strong>Level:</strong> <span [class]="levelClass(detail.level)">{{ detail.level }}</span></div>
<div><strong>Category:</strong> {{ detail.category }}</div>
<div><strong>Action:</strong> {{ detail.action }}</div>
<div *ngIf="detail.entityName"><strong>Entity:</strong> {{ detail.entityName }} <span *ngIf="detail.entityId">#{{ detail.entityId }}</span></div>
<div *ngIf="detail.userEmail || detail.userId"><strong>User:</strong> {{ detail.userEmail || detail.userId }}</div>
<div *ngIf="detail.ipAddress"><strong>IP:</strong> {{ detail.ipAddress }}</div>
<div class="col-span-2" *ngIf="detail.summary"><strong>Summary:</strong> {{ detail.summary }}</div>
<div class="col-span-2" *ngIf="detail.correlationId"><strong>Correlation:</strong> {{ detail.correlationId }}</div>
</div>
<ng-container *ngIf="detailChanges">
<div><strong>Changes (before → after)</strong></div>
<pre class="detail-block">{{ detailChanges }}</pre>
</ng-container>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="detail = null">Close / 關閉</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,31 @@
.page { padding: 0; }
.log-level {
display: inline-block;
min-width: 78px;
text-align: center;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
.log-level--trace,
.log-level--debug { background: #9ca3af; }
.log-level--information { background: #2563eb; }
.log-level--warning { background: #d97706; }
.log-level--error { background: #dc2626; }
.log-level--critical { background: #7f1d1d; }
.detail-block {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 360px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
}
@@ -0,0 +1,100 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { AUDIT_CATEGORY_OPTIONS, LOG_LEVEL_OPTIONS } from '../../../../shared/i18n/option-lists';
import { LoggingApiService } from '../../services/logging-api.service';
import { AuditLogListItem, AuditLogDetail, AuditLogQuery } from '../../models/logging.model';
@Component({
selector: 'app-audit-logs-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule,
DropDownsModule, InputsModule, DateInputsModule, DialogsModule,
],
templateUrl: './audit-logs-page.component.html',
styleUrls: ['./audit-logs-page.component.scss'],
})
export class AuditLogsPageComponent implements OnInit {
rows: AuditLogListItem[] = [];
total = 0;
page = 1;
pageSize = 20;
loading = false;
readonly categories = AUDIT_CATEGORY_OPTIONS;
readonly levels = LOG_LEVEL_OPTIONS;
actions: { value: string; label: string }[] = [];
category: string | null = null;
action: string | null = null;
search = '';
from: Date | null = null;
to: Date | null = null;
detail: AuditLogDetail | null = null;
detailChanges = '';
constructor(private api: LoggingApiService) {}
ngOnInit(): void {
this.api.getAuditCatalog().subscribe(c => {
this.actions = c.actions.map(a => ({ value: a, label: a }));
});
this.load();
}
load(): void {
this.loading = true;
const query: AuditLogQuery = {
page: this.page,
pageSize: this.pageSize,
category: this.category ?? undefined,
action: this.action ?? undefined,
search: this.search || undefined,
from: this.toLocalDate(this.from),
to: this.toLocalDate(this.to),
};
this.api.getAuditLogs(query).subscribe({
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
error: () => (this.loading = false),
});
}
get skip(): number { return (this.page - 1) * this.pageSize; }
applyFilter(): void { this.page = 1; this.load(); }
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
view(row: AuditLogListItem): void {
this.api.getAuditLog(row.id).subscribe(d => {
this.detail = d;
this.detailChanges = this.prettyJson(d.changes);
});
}
levelClass(level: string): string { return 'log-level log-level--' + level.toLowerCase(); }
/** Pretty-print the stored Changes JSON; fall back to the raw string if it isn't JSON. */
private prettyJson(raw: string | null | undefined): string {
if (!raw) return '';
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
private toLocalDate(d: Date | null): string | undefined {
if (!d) return undefined;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
@@ -0,0 +1,75 @@
<div class="page">
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search / 搜尋
<kendo-textbox placeholder="Message / category" [(ngModel)]="search"
(keydown.enter)="applyFilter()"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Min Level / 最低等級
<kendo-dropdownlist [data]="levels" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="minLevel" [defaultItem]="{ value: null, label: 'All Levels/全部等級' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
From / 起
<kendo-datepicker [(ngModel)]="from"></kendo-datepicker>
</label>
<label class="flex flex-col gap-1">
To / 迄
<kendo-datepicker [(ngModel)]="to"></kendo-datepicker>
</label>
<button kendoButton themeColor="primary" (click)="applyFilter()">Apply / 套用</button>
</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column title="Time / 時間" [width]="170">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.timestamp | date:'short' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Level / 等級" [width]="110">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="levelClass(dataItem.level)">{{ dataItem.level }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="category" title="Source / 來源" [width]="240"></kendo-grid-column>
<kendo-grid-column title="Message / 訊息">
<ng-template kendoGridCellTemplate let-dataItem>
<span class="msg">{{ dataItem.message }}</span>
<span *ngIf="dataItem.hasException" class="exc-flag" title="Has exception"></span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="statusCode" title="Status" [width]="90"></kendo-grid-column>
<kendo-grid-column title="" [width]="90">
<ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton fillMode="flat" themeColor="primary" (click)="view(dataItem)">View</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="System Log #{{ detail.id }}" [width]="720" (close)="detail = null">
<div class="p-2 flex flex-col gap-2 text-sm">
<div class="grid grid-cols-2 gap-2">
<div><strong>Time:</strong> {{ detail.timestamp | date:'medium' }}</div>
<div><strong>Level:</strong> <span [class]="levelClass(detail.level)">{{ detail.level }}</span></div>
<div class="col-span-2"><strong>Source:</strong> {{ detail.category }}</div>
<div *ngIf="detail.httpMethod"><strong>Request:</strong> {{ detail.httpMethod }} {{ detail.requestPath }}</div>
<div *ngIf="detail.statusCode"><strong>Status:</strong> {{ detail.statusCode }}</div>
<div *ngIf="detail.userId"><strong>User:</strong> {{ detail.userId }}</div>
<div *ngIf="detail.ipAddress"><strong>IP:</strong> {{ detail.ipAddress }}</div>
<div class="col-span-2" *ngIf="detail.correlationId"><strong>Correlation:</strong> {{ detail.correlationId }}</div>
</div>
<div><strong>Message</strong></div>
<pre class="detail-block">{{ detail.message }}</pre>
<ng-container *ngIf="detail.exception">
<div><strong>Exception / Stack Trace</strong></div>
<pre class="detail-block detail-block--exc">{{ detail.exception }}</pre>
</ng-container>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="detail = null">Close / 關閉</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,46 @@
.page { padding: 0; }
.msg {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exc-flag {
margin-left: 0.25rem;
color: #b45309;
}
.log-level {
display: inline-block;
min-width: 78px;
text-align: center;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: #fff;
}
.log-level--trace,
.log-level--debug { background: #9ca3af; }
.log-level--information { background: #2563eb; }
.log-level--warning { background: #d97706; }
.log-level--error { background: #dc2626; }
.log-level--critical { background: #7f1d1d; }
.detail-block {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 320px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
}
.detail-block--exc {
background: #fef2f2;
border-color: #fecaca;
}
@@ -0,0 +1,81 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { LOG_LEVEL_OPTIONS } from '../../../../shared/i18n/option-lists';
import { LoggingApiService } from '../../services/logging-api.service';
import { SystemLogListItem, SystemLogDetail, SystemLogQuery } from '../../models/logging.model';
@Component({
selector: 'app-system-logs-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule,
DropDownsModule, InputsModule, DateInputsModule, DialogsModule,
],
templateUrl: './system-logs-page.component.html',
styleUrls: ['./system-logs-page.component.scss'],
})
export class SystemLogsPageComponent implements OnInit {
rows: SystemLogListItem[] = [];
total = 0;
page = 1;
pageSize = 20;
loading = false;
readonly levels = LOG_LEVEL_OPTIONS;
// UI-bound filter state; dates are Date objects converted to yyyy-MM-dd on send.
minLevel: string | null = null;
search = '';
from: Date | null = null;
to: Date | null = null;
detail: SystemLogDetail | null = null;
constructor(private api: LoggingApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.loading = true;
const query: SystemLogQuery = {
page: this.page,
pageSize: this.pageSize,
minLevel: this.minLevel ?? undefined,
search: this.search || undefined,
from: this.toLocalDate(this.from),
to: this.toLocalDate(this.to),
};
this.api.getSystemLogs(query).subscribe({
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
error: () => (this.loading = false),
});
}
get skip(): number { return (this.page - 1) * this.pageSize; }
applyFilter(): void { this.page = 1; this.load(); }
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
view(row: SystemLogListItem): void {
this.api.getSystemLog(row.id).subscribe(d => (this.detail = d));
}
levelClass(level: string): string {
return 'log-level log-level--' + level.toLowerCase();
}
/** Local yyyy-MM-dd (never toISOString — that shifts the day by timezone). */
private toLocalDate(d: Date | null): string | undefined {
if (!d) return undefined;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
}
@@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
PagedResult, SystemLogListItem, SystemLogDetail, SystemLogQuery,
AuditLogListItem, AuditLogDetail, AuditLogQuery, AuditCatalog,
} from '../models/logging.model';
@Injectable({ providedIn: 'root' })
export class LoggingApiService {
private readonly systemEndpoint: string;
private readonly auditEndpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.systemEndpoint = apiConfig.getApiUrl('system-logs');
this.auditEndpoint = apiConfig.getApiUrl('audit-logs');
}
private toParams(q: Record<string, unknown>): HttpParams {
let p = new HttpParams();
for (const [key, value] of Object.entries(q)) {
if (value !== undefined && value !== null && value !== '') {
p = p.set(key, String(value));
}
}
return p;
}
// ── System logs ─────────────────────────────────────────────────────────────
getSystemLogs(query: SystemLogQuery): Observable<PagedResult<SystemLogListItem>> {
return this.http.get<PagedResult<SystemLogListItem>>(
this.systemEndpoint, { params: this.toParams(query as Record<string, unknown>) });
}
getSystemLog(id: number): Observable<SystemLogDetail> {
return this.http.get<SystemLogDetail>(`${this.systemEndpoint}/${id}`);
}
// ── Audit logs ──────────────────────────────────────────────────────────────
getAuditLogs(query: AuditLogQuery): Observable<PagedResult<AuditLogListItem>> {
return this.http.get<PagedResult<AuditLogListItem>>(
this.auditEndpoint, { params: this.toParams(query as Record<string, unknown>) });
}
getAuditLog(id: number): Observable<AuditLogDetail> {
return this.http.get<AuditLogDetail>(`${this.auditEndpoint}/${id}`);
}
getAuditCatalog(): Observable<AuditCatalog> {
return this.http.get<AuditCatalog>(`${this.auditEndpoint}/catalog`);
}
}
@@ -1,10 +1,8 @@
<div class="k-p-4"> <div class="k-p-4">
<!-- Toolbar --> <ng-template appPageHeaderActions>
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4">
<h2 class="k-m-0">Member Management</h2>
<button kendoButton themeColor="primary" (click)="openAddDialog()">+ Add Member</button> <button kendoButton themeColor="primary" (click)="openAddDialog()">+ Add Member</button>
</div> </ng-template>
<!-- Filters --> <!-- Filters -->
<div class="k-d-flex k-gap-3 k-mb-4"> <div class="k-d-flex k-gap-3 k-mb-4">
@@ -14,6 +14,7 @@ import {
PagedResult, memberDisplayName PagedResult, memberDisplayName
} from '../../models/member.model'; } from '../../models/member.model';
import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists'; import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-members-page', selector: 'app-members-page',
@@ -21,7 +22,7 @@ import { MEMBER_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
imports: [ imports: [
CommonModule, FormsModule, GridModule, InputsModule, CommonModule, FormsModule, GridModule, InputsModule,
ButtonsModule, IndicatorsModule, DropDownsModule, ButtonsModule, IndicatorsModule, DropDownsModule,
MemberFormDialogComponent, CreateUserDialogComponent, MemberFormDialogComponent, CreateUserDialogComponent, PageHeaderActionsDirective,
], ],
templateUrl: './members-page.component.html', templateUrl: './members-page.component.html',
styleUrls: ['./members-page.component.scss'], styleUrls: ['./members-page.component.scss'],
@@ -1,12 +1,11 @@
<div class="k-p-4"> <div class="k-p-4">
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4"> <ng-template appPageHeaderActions>
<h2 class="k-m-0">Role Permissions</h2>
<button kendoButton themeColor="primary" <button kendoButton themeColor="primary"
[disabled]="!selectedRole || isSuperAdminSelected || isSaving" [disabled]="!selectedRole || isSuperAdminSelected || isSaving"
(click)="save()"> (click)="save()">
{{ isSaving ? 'Saving...' : 'Save Changes' }} {{ isSaving ? 'Saving...' : 'Save Changes' }}
</button> </button>
</div> </ng-template>
<p class="k-mb-4" style="color:#666"> <p class="k-mb-4" style="color:#666">
Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply Choose a role, then grant Read / Write / Delete / Approve per module. Changes apply
@@ -12,12 +12,14 @@ import {
PermissionMatrixDto, PermissionMatrixDto,
RolePermissionRow, RolePermissionRow,
} from '../../../../core/models/permission.model'; } from '../../../../core/models/permission.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-permissions-page', selector: 'app-permissions-page',
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, IndicatorsModule, CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, IndicatorsModule,
PageHeaderActionsDirective,
], ],
templateUrl: './permissions-page.component.html', templateUrl: './permissions-page.component.html',
styleUrls: ['./permissions-page.component.scss'], styleUrls: ['./permissions-page.component.scss'],
@@ -1,11 +1,8 @@
<div class="k-p-4"> <div class="k-p-4">
<div class="k-d-flex k-justify-content-between k-align-items-center k-mb-4"> <ng-template appPageHeaderActions>
<h2 class="k-m-0">User Management</h2>
<div class="k-d-flex k-gap-2">
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button> <button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
<button kendoButton (click)="testAuth()">Test Auth</button> <button kendoButton (click)="testAuth()">Test Auth</button>
</div> </ng-template>
</div>
<!-- Auth test result (dev only) --> <!-- Auth test result (dev only) -->
<div *ngIf="authTestResult" class="k-mb-3 k-p-2" style="background:#f0f4ff;border-radius:4px;font-size:12px"> <div *ngIf="authTestResult" class="k-mb-3 k-p-2" style="background:#f0f4ff;border-radius:4px;font-size:12px">
@@ -13,6 +13,7 @@ import {
UserListItemDto, UserDto, CreateUserRequest, UpdateUserRequest, PagedResult UserListItemDto, UserDto, CreateUserRequest, UpdateUserRequest, PagedResult
} from '../../models/user.model'; } from '../../models/user.model';
import { ApiConfigService } from '../../../../core/services/api-config.service'; import { ApiConfigService } from '../../../../core/services/api-config.service';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-users-page', selector: 'app-users-page',
@@ -20,6 +21,7 @@ import { ApiConfigService } from '../../../../core/services/api-config.service';
imports: [ imports: [
CommonModule, FormsModule, GridModule, InputsModule, CommonModule, FormsModule, GridModule, InputsModule,
ButtonsModule, IndicatorsModule, EditUserDialogComponent, CreateUserDialogComponent, ButtonsModule, IndicatorsModule, EditUserDialogComponent, CreateUserDialogComponent,
PageHeaderActionsDirective,
], ],
templateUrl: './users-page.component.html', templateUrl: './users-page.component.html',
styleUrls: ['./users-page.component.scss'], styleUrls: ['./users-page.component.scss'],
@@ -69,7 +69,7 @@
</ng-container> </ng-container>
</div> </div>
<div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection"> <div class="nav-section" *ngIf="showMemberAdminSection || showUserAdminSection || showLogsAdminSection">
<h4 *ngIf="!sidebarCollapsed">Administration</h4> <h4 *ngIf="!sidebarCollapsed">Administration</h4>
<ng-container *ngFor="let item of memberAdminNavItems"> <ng-container *ngFor="let item of memberAdminNavItems">
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)" <a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
@@ -89,6 +89,15 @@
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span> <span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
</a> </a>
</ng-container> </ng-container>
<ng-container *ngFor="let item of logsAdminNavItems">
<a class="nav-item" [class.active]="item.active" *ngIf="isVisible(item)"
[title]="item.text" (click)="navigateTo(item.path)">
<div class="nav-icon">
<kendo-svgicon [icon]="item.icon"></kendo-svgicon>
</div>
<span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
</a>
</ng-container>
</div> </div>
<div class="nav-section" *ngIf="showFinanceSection"> <div class="nav-section" *ngIf="showFinanceSection">
@@ -163,7 +172,7 @@
<!-- Main Content Area --> <!-- Main Content Area -->
<main [class]="mainContentClass"> <main [class]="mainContentClass">
<!-- Top Header --> <!-- Unified system header (shared by every child page) -->
<header class="top-header"> <header class="top-header">
<div class="header-left"> <div class="header-left">
<button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu" <button class="mobile-menu-btn" (click)="toggleSidebar()" *ngIf="isMobile" title="Toggle menu"
@@ -174,29 +183,17 @@
<line x1="3" y1="18" x2="21" y2="18"></line> <line x1="3" y1="18" x2="21" y2="18"></line>
</svg> </svg>
</button> </button>
<div class="breadcrumb"> <div class="app-header">
<span class="breadcrumb-item">{{ currentPageTitle }}</span> <span class="app-header__eyebrow">River of Life · {{ currentSection }}</span>
<h1 class="app-header__title">
{{ currentPageTitle }}
<span *ngIf="currentPageTitleZh">{{ currentPageTitleZh }}</span>
</h1>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right app-header__actions">
<div class="header-actions"> <ng-container *ngTemplateOutlet="pageHeader.actions()"></ng-container>
<!-- <button class="action-btn" title="Notifications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<div class="notification-badge" *ngIf="unreadNotifications > 0">{{ unreadNotifications }}
</div>
</button>
<button class="action-btn" title="Search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button> -->
</div>
</div> </div>
</header> </header>
@@ -483,11 +483,12 @@
} }
.top-header { .top-header {
padding: 1.5rem 2rem; padding: 1.25rem 2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1); border-bottom: 1px solid rgba(0, 0, 0, 0.1);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.8);
} }
@@ -495,6 +496,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
min-width: 0;
} }
.mobile-menu-btn { .mobile-menu-btn {
@@ -518,14 +520,6 @@
} }
} }
.breadcrumb {
.breadcrumb-item {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
}
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;

Some files were not shown because too many files have changed in this diff Show More