@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
|||||||
@@ -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,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,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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
|||||||
@@ -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,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; }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
-4
@@ -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
|
||||||
|
|||||||
-4
@@ -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">
|
||||||
|
|||||||
+2
-3
@@ -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.
|
||||||
|
|||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
.page-header {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-1
@@ -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'],
|
||||||
})
|
})
|
||||||
|
|||||||
-4
@@ -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 -->
|
||||||
|
|||||||
-7
@@ -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">
|
||||||
|
|||||||
-4
@@ -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">
|
||||||
|
|||||||
+2
-3
@@ -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>
|
||||||
|
|||||||
+2
-1
@@ -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'],
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-22
@@ -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 -->
|
||||||
|
|||||||
+1
-43
@@ -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 {
|
||||||
|
|||||||
+2
-1
@@ -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'],
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-9
@@ -1,13 +1,10 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<header class="page-header">
|
<ng-template appPageHeaderActions>
|
||||||
<h2>Giving Types / 奉獻類型</h2>
|
<label class="inactive-toggle">
|
||||||
<div class="header-actions">
|
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
|
||||||
<label class="inactive-toggle">
|
</label>
|
||||||
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
|
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
|
||||||
</label>
|
</ng-template>
|
||||||
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|||||||
-7
@@ -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;
|
||||||
|
|||||||
+2
-1
@@ -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'],
|
||||||
|
|||||||
-6
@@ -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 -->
|
||||||
|
|||||||
-27
@@ -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'],
|
||||||
|
|||||||
+2
-3
@@ -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>
|
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
|
||||||
<div class="k-d-flex k-gap-2">
|
<button kendoButton (click)="testAuth()">Test Auth</button>
|
||||||
<button kendoButton themeColor="primary" (click)="openCreateDialog()">+ Add New User</button>
|
</ng-template>
|
||||||
<button kendoButton (click)="testAuth()">Test Auth</button>
|
|
||||||
</div>
|
|
||||||
</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
Reference in New Issue
Block a user