Implemented audit logging
This commit is contained in:
@@ -1,11 +1,39 @@
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using Castle.DynamicProxy;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
|
||||
namespace PostFundManagement.Application.Interceptors;
|
||||
|
||||
public sealed class AuditInterceptor(AuditService auditService, IHttpContextAccessor httpContextAccessor) : IAsyncInterceptor
|
||||
{
|
||||
public void InterceptSynchronous(IInvocation invocation) => invocation.Proceed();
|
||||
public void InterceptSynchronous(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
AuditAction(invocation, invocation.ReturnValue, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuditAction(invocation, null, ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void InterceptAsynchronous(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous(invocation);
|
||||
@@ -13,49 +41,177 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
public void InterceptAsynchronous<TResult>(IInvocation invocation) =>
|
||||
invocation.ReturnValue = InternalInterceptAsynchronous<TResult>(invocation);
|
||||
|
||||
private static async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
private async Task InternalInterceptAsynchronous(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
|
||||
var task = (Task)invocation.ReturnValue;
|
||||
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
|
||||
{
|
||||
invocation.Proceed();
|
||||
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
var resultObj = await task.ConfigureAwait(false);
|
||||
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null) return resultObj;
|
||||
|
||||
if (resultObj is IResultBase result && result.IsSuccess)
|
||||
if (auditAttr == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = ResolveActionName(method.Name);
|
||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(invocation.Method.GetParameters(), invocation.Arguments);
|
||||
var performedBy = ResolveUserId();
|
||||
invocation.Proceed();
|
||||
var t = (Task)invocation.ReturnValue;
|
||||
await t.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task)invocation.ReturnValue;
|
||||
await task.ConfigureAwait(false);
|
||||
await AuditActionAsync(invocation, null, null).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TResult> InternalInterceptAsynchronous<TResult>(IInvocation invocation)
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var auditAttr = method.GetCustomAttribute<AuditAttribute>()
|
||||
?? invocation.Method.GetCustomAttribute<AuditAttribute>();
|
||||
|
||||
if (auditAttr == null)
|
||||
{
|
||||
invocation.Proceed();
|
||||
var t = (Task<TResult>)invocation.ReturnValue;
|
||||
return await t.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
TResult resultObj;
|
||||
try
|
||||
{
|
||||
invocation.Proceed();
|
||||
var task = (Task<TResult>)invocation.ReturnValue;
|
||||
resultObj = await task.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await AuditActionAsync(invocation, null, ex).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
await AuditActionAsync(invocation, resultObj, null).ConfigureAwait(false);
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
private async Task AuditActionAsync(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
var method = invocation.MethodInvocationTarget ?? invocation.Method;
|
||||
var action = ResolveActionName(method.Name);
|
||||
var entityName = ResolveEntityName(method, resultObj, invocation.Arguments);
|
||||
var entityId = ResolveEntityId(resultObj, invocation.Arguments);
|
||||
var changes = BuildPayloadSummary(method.GetParameters(), invocation.Arguments);
|
||||
var performedBy = ResolveUserId();
|
||||
var tenantId = ResolveTenantId(invocation.Arguments);
|
||||
|
||||
if (exception != null)
|
||||
{
|
||||
var errorDetails = $"[Exception] {exception.GetType().Name}: {exception.Message}\n{exception.StackTrace}";
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy).ConfigureAwait(false);
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
catch { /* eat all exceptions, don't interrupt program flow */ }
|
||||
}
|
||||
|
||||
return resultObj;
|
||||
if (resultObj is IResultBase result)
|
||||
{
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
else if (result.IsFailed)
|
||||
{
|
||||
var errorMessages = new List<string>();
|
||||
if (result.Reasons != null)
|
||||
{
|
||||
foreach (var reason in result.Reasons)
|
||||
{
|
||||
if (reason is IExceptionalError exceptionalError)
|
||||
errorMessages.Add($"{exceptionalError.Message} ({exceptionalError.Exception.GetType().Name}: {exceptionalError.Exception.Message})\n{exceptionalError.Exception.StackTrace}");
|
||||
else if (reason is IError error)
|
||||
errorMessages.Add(error.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var rawErrorType = result.Reasons?.FirstOrDefault()?.GetType().Name.Replace("Error", string.Empty);
|
||||
var errorType = string.IsNullOrWhiteSpace(rawErrorType) || rawErrorType == "Reason" ? "Error" : rawErrorType;
|
||||
var errorsString = string.Join("; ", errorMessages);
|
||||
var errorDetails = $"[{errorType}] {errorsString}";
|
||||
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: errorDetails,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await auditService.LogActionAsync(
|
||||
entityId: entityId,
|
||||
entityName: entityName,
|
||||
action: action,
|
||||
changes: changes,
|
||||
performedBy: performedBy,
|
||||
error: null,
|
||||
tenantId: tenantId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
}
|
||||
}
|
||||
|
||||
private void AuditAction(IInvocation invocation, object? resultObj, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
AuditActionAsync(invocation, resultObj, exception).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* eat all exceptions, don't interrupt program flow */
|
||||
}
|
||||
}
|
||||
|
||||
private static long? ResolveTenantId(object[] args)
|
||||
{
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg == null || arg is CancellationToken or IFormFile) continue;
|
||||
var prop = arg.GetType().GetProperty("TenantId");
|
||||
if (prop != null)
|
||||
{
|
||||
var val = prop.GetValue(arg);
|
||||
if (val is long l && l > 0) return l;
|
||||
if (val is int i && i > 0) return i;
|
||||
if (val is string s && long.TryParse(s, out var parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveActionName(string methodName) =>
|
||||
@@ -73,7 +229,7 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
{
|
||||
var genericDef = returnType.GetGenericTypeDefinition();
|
||||
|
||||
if (genericDef == typeof(Task<>) || genericDef == typeof(Task<>))
|
||||
if (genericDef == typeof(Task<>))
|
||||
innerType = returnType.GetGenericArguments()[0];
|
||||
}
|
||||
|
||||
@@ -208,4 +364,4 @@ public sealed class AuditInterceptor(AuditService auditService, IHttpContextAcce
|
||||
|
||||
return Guid.TryParse(sub, out var guid) ? guid : Guid.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Errors;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
@@ -10,27 +10,22 @@ namespace PostFundManagement.Application.Services.Shared;
|
||||
public sealed class AuditService(IDbContextFactory<ApplicationDbContext> contextFactory, ITenantContextService tenantContextService) : IService
|
||||
{
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var pagination = new Pagination { Page = 1, PageSize = 500, Index = 1 };
|
||||
|
||||
var logs = await context.AuditLogs
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
await GetAuditLogsAsync(new Pagination { Page = 1, PageSize = 500, Index = 1 }, cancellationToken);
|
||||
|
||||
public async Task<Result<AuditLog[]>> GetAuditLogsAsync(Pagination pagination, CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result<AuditLog[]>>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var logs = await context.AuditLogs
|
||||
var query = context.AuditLogs.AsQueryable();
|
||||
|
||||
if (!tenantContextService.IsAdmin)
|
||||
{
|
||||
var currentTenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L;
|
||||
query = query.Where(o => o.TenantId == currentTenantId);
|
||||
}
|
||||
|
||||
var logs = await query
|
||||
.OrderByDescending(o => o.Id)
|
||||
.Skip((pagination.Page - 1) * pagination.PageSize)
|
||||
.Take(pagination.PageSize)
|
||||
@@ -39,11 +34,23 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
return Result.Ok(logs.Select(p => p.Map()).ToArray());
|
||||
}));
|
||||
|
||||
public async Task<Result> LogActionAsync(string entityId, string entityName, string action, string[] changes, Guid performedBy, CancellationToken cancellationToken = default) =>
|
||||
public async Task<Result> LogActionAsync(
|
||||
string entityId,
|
||||
string entityName,
|
||||
string action,
|
||||
string[] changes,
|
||||
Guid performedBy,
|
||||
string? error = null,
|
||||
long? tenantId = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await Result.Try((Func<Task<Result>>)(async () =>
|
||||
{
|
||||
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var resolvedTenantId = (tenantId.HasValue && tenantId.Value > 0)
|
||||
? tenantId.Value
|
||||
: (tenantContextService.TenantId > 0 ? tenantContextService.TenantId : 1L);
|
||||
|
||||
context.Add(new Domain.Entities.AuditLog
|
||||
{
|
||||
EntityId = entityId,
|
||||
@@ -52,7 +59,8 @@ public sealed class AuditService(IDbContextFactory<ApplicationDbContext> context
|
||||
EntityName = entityName,
|
||||
PerformedBy = performedBy,
|
||||
Timestamp = DateTime.UtcNow,
|
||||
TenantId = tenantContextService.TenantId > 0 ? tenantContextService.TenantId : null,
|
||||
TenantId = resolvedTenantId,
|
||||
Error = error,
|
||||
});
|
||||
|
||||
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
Award as AwardIcon,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
AlertOctagon,
|
||||
Layers,
|
||||
Activity,
|
||||
Calendar,
|
||||
@@ -370,8 +372,14 @@ export const SettingsPage: React.FC = () => {
|
||||
const [auditSearchTerm, setAuditSearchTerm] = useState('');
|
||||
const [auditEntityFilter, setAuditEntityFilter] = useState<string>('all');
|
||||
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
|
||||
const [auditTypeFilter, setAuditTypeFilter] = useState<'all' | 'audits' | 'exceptions'>('all');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditPageSize, setAuditPageSize] = useState(10);
|
||||
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
|
||||
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
|
||||
const [selectedExceptionLog, setSelectedExceptionLog] = useState<AuditLog | null>(null);
|
||||
const [isExceptionDetailModalOpen, setIsExceptionDetailModalOpen] = useState(false);
|
||||
const [copiedExceptionTrace, setCopiedExceptionTrace] = useState(false);
|
||||
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
|
||||
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
|
||||
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
|
||||
@@ -402,11 +410,14 @@ export const SettingsPage: React.FC = () => {
|
||||
isFetching: isFetchingAuditLogs,
|
||||
} = useQuery({
|
||||
queryKey: ['audit-logs'],
|
||||
queryFn: () => getAuditLogs(1, 100),
|
||||
queryFn: () => getAuditLogs(1, 500),
|
||||
});
|
||||
|
||||
const isLogException = (log: AuditLog) => Boolean(log.error || log.isError);
|
||||
|
||||
const auditExportColumns: ExportColumn<AuditLog>[] = [
|
||||
{ header: 'Event ID', key: 'id' },
|
||||
{ header: 'Event Type', key: 'error', format: (val, row) => (val || row?.isError ? 'EXCEPTION' : 'AUDIT') },
|
||||
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
|
||||
{ header: 'Action', key: 'action', format: (val) => val || '—' },
|
||||
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
|
||||
@@ -421,10 +432,11 @@ export const SettingsPage: React.FC = () => {
|
||||
},
|
||||
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
|
||||
{
|
||||
header: 'Changes',
|
||||
header: 'Changes / Arguments',
|
||||
key: 'changes',
|
||||
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
|
||||
},
|
||||
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
|
||||
];
|
||||
|
||||
const uniqueAuditEntities = Array.from(
|
||||
@@ -435,6 +447,13 @@ export const SettingsPage: React.FC = () => {
|
||||
);
|
||||
|
||||
const filteredAuditLogs = auditLogs.filter((log) => {
|
||||
const isEx = isLogException(log);
|
||||
if (auditTypeFilter === 'audits' && isEx) {
|
||||
return false;
|
||||
}
|
||||
if (auditTypeFilter === 'exceptions' && !isEx) {
|
||||
return false;
|
||||
}
|
||||
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
@@ -449,6 +468,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const entityId = String(log.entityId || '').toLowerCase();
|
||||
const performedBy = (log.performedBy || '').toLowerCase();
|
||||
const tenant = String(log.tenantId || '').toLowerCase();
|
||||
const err = (log.error || '').toLowerCase();
|
||||
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
|
||||
return (
|
||||
id.includes(q) ||
|
||||
@@ -457,6 +477,7 @@ export const SettingsPage: React.FC = () => {
|
||||
entityId.includes(q) ||
|
||||
performedBy.includes(q) ||
|
||||
tenant.includes(q) ||
|
||||
err.includes(q) ||
|
||||
changes.includes(q)
|
||||
);
|
||||
});
|
||||
@@ -468,6 +489,13 @@ export const SettingsPage: React.FC = () => {
|
||||
handleSort: handleAuditSort,
|
||||
} = useTableSort(filteredAuditLogs, 'timestamp', 'desc');
|
||||
|
||||
const totalAuditRecords = sortedAuditLogs.length;
|
||||
const totalAuditPages = Math.max(1, Math.ceil(totalAuditRecords / auditPageSize));
|
||||
const paginatedAuditLogs = React.useMemo(() => {
|
||||
const start = (auditPage - 1) * auditPageSize;
|
||||
return sortedAuditLogs.slice(start, start + auditPageSize);
|
||||
}, [sortedAuditLogs, auditPage, auditPageSize]);
|
||||
|
||||
const userExportColumns: ExportColumn<User>[] = [
|
||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||
{ header: 'User Identifier', key: 'id' },
|
||||
@@ -1532,7 +1560,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={auditSearchTerm}
|
||||
onChange={(e) => setAuditSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditSearchTerm(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
|
||||
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
@@ -1543,7 +1574,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Entity:</span>
|
||||
<select
|
||||
value={auditEntityFilter}
|
||||
onChange={(e) => setAuditEntityFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditEntityFilter(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Entities</option>
|
||||
@@ -1568,7 +1602,10 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Action:</span>
|
||||
<select
|
||||
value={auditActionFilter}
|
||||
onChange={(e) => setAuditActionFilter(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAuditActionFilter(e.target.value);
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Actions</option>
|
||||
@@ -1580,13 +1617,31 @@ export const SettingsPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Type:</span>
|
||||
<select
|
||||
value={auditTypeFilter}
|
||||
onChange={(e) => {
|
||||
setAuditTypeFilter(e.target.value as 'all' | 'audits' | 'exceptions');
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||
>
|
||||
<option value="all">All Types ({auditLogs.length})</option>
|
||||
<option value="audits">Audits Only ({auditLogs.filter((l) => !l.error && !l.isError).length})</option>
|
||||
<option value="exceptions">Exceptions Only ({auditLogs.filter((l) => Boolean(l.error || l.isError)).length})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAuditSearchTerm('');
|
||||
setAuditEntityFilter('all');
|
||||
setAuditActionFilter('all');
|
||||
setAuditTypeFilter('all');
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="px-2.5 py-1.5 text-xs text-slate-400 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
@@ -1624,12 +1679,18 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-4 h-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<span>Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})</span>
|
||||
{auditLogs.some((l) => Boolean(l.error || l.isError)) && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
{auditLogs.filter((l) => Boolean(l.error || l.isError)).length} Exceptions
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<HelpTooltip
|
||||
title="Audit Trail"
|
||||
content="Authoritative record of state changes and system activities."
|
||||
content="Authoritative record of state changes, exceptions, and system activities."
|
||||
size="sm"
|
||||
placement="right"
|
||||
/>
|
||||
@@ -1671,13 +1732,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-slate-500">
|
||||
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
|
||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
|
||||
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all' || auditTypeFilter !== 'all'
|
||||
? 'No audit log entries match your filter criteria.'
|
||||
: 'No audit records found.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedAuditLogs.map((log) => {
|
||||
paginatedAuditLogs.map((log) => {
|
||||
const isException = Boolean(log.error || log.isError);
|
||||
const badge = getActionBadge(log.action);
|
||||
const isExpanded = !!expandedAuditLogIds[log.id];
|
||||
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
|
||||
@@ -1689,8 +1751,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<React.Fragment key={log.id}>
|
||||
<tr
|
||||
onClick={() => toggleExpandAuditLog(log.id)}
|
||||
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
|
||||
isExpanded ? 'bg-slate-800/30' : ''
|
||||
className={`transition-colors cursor-pointer border-l-4 ${
|
||||
isException
|
||||
? isExpanded
|
||||
? 'border-l-rose-500 bg-rose-950/25 hover:bg-rose-950/35'
|
||||
: 'border-l-rose-500 bg-rose-950/15 hover:bg-rose-950/25'
|
||||
: isExpanded
|
||||
? 'border-l-blue-500 bg-slate-800/40 hover:bg-slate-800/50'
|
||||
: 'border-l-transparent hover:bg-slate-800/40'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3.5 px-3 text-center">
|
||||
@@ -1705,7 +1773,13 @@ export const SettingsPage: React.FC = () => {
|
||||
>
|
||||
<ChevronRight
|
||||
className={`w-3.5 h-3.5 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
|
||||
isExpanded
|
||||
? isException
|
||||
? 'rotate-90 text-rose-400'
|
||||
: 'rotate-90 text-blue-400'
|
||||
: isException
|
||||
? 'text-rose-400/80'
|
||||
: 'text-slate-500'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
@@ -1714,12 +1788,33 @@ export const SettingsPage: React.FC = () => {
|
||||
{formatDate(log.timestamp)}
|
||||
</td>
|
||||
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
|
||||
#{log.id}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>#{log.id}</span>
|
||||
{isException && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider bg-rose-950 text-rose-300 border border-rose-700/60 shadow-xs"
|
||||
title="Exception captured"
|
||||
>
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-rose-400 shrink-0" />
|
||||
Exception
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
{isException ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border bg-rose-500/10 text-rose-400 border-rose-500/30">
|
||||
<AlertCircle className="w-3 h-3 text-rose-400 shrink-0" />
|
||||
<span>{log.action || 'EXCEPTION'}</span>
|
||||
<span className="ml-0.5 px-1 py-0.2 rounded bg-rose-500/20 text-[9px] font-bold text-rose-300 uppercase">
|
||||
FAIL
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="py-3.5 px-4"
|
||||
@@ -1749,18 +1844,34 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3.5 px-5 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAuditLog(log);
|
||||
setIsAuditDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-blue-400" />
|
||||
Inspect
|
||||
</button>
|
||||
{isException ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedExceptionLog(log);
|
||||
setIsExceptionDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-rose-300 bg-rose-500/10 hover:bg-rose-500/20 border border-rose-500/30 rounded-lg transition cursor-pointer"
|
||||
title="Inspect exception details"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
Inspect Error
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAuditLog(log);
|
||||
setIsAuditDetailModalOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3 h-3 text-blue-400" />
|
||||
Inspect
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1771,11 +1882,21 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
{isException ? (
|
||||
<AlertOctagon className="w-4 h-4 text-rose-400 shrink-0" />
|
||||
) : (
|
||||
<Layers className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
)}
|
||||
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Recorded Changes Content (Event #{log.id})
|
||||
{isException
|
||||
? `Captured Exception & Invocation Payload (Event #${log.id})`
|
||||
: `Recorded Changes Content (Event #${log.id})`}
|
||||
</h4>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold shrink-0">
|
||||
<span className={`text-[10px] font-mono px-2 py-0.5 rounded-full font-semibold shrink-0 ${
|
||||
isException
|
||||
? 'bg-rose-500/10 text-rose-400 border border-rose-500/20'
|
||||
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
|
||||
}`}>
|
||||
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1804,6 +1925,32 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exception Diagnostic Banner inside expanded shelf */}
|
||||
{log.error && (
|
||||
<div className="p-3 bg-rose-950/40 border border-rose-800/60 rounded-xl space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-rose-300 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
|
||||
Captured Exception Diagnostic Trace
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(log.error || '');
|
||||
success('Copied', 'Exception details copied to clipboard.');
|
||||
}}
|
||||
className="text-[10px] text-rose-300 hover:text-white bg-rose-900/50 hover:bg-rose-900 px-2 py-0.5 rounded border border-rose-700/60 transition cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" /> Copy Details
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs font-mono text-rose-200 whitespace-pre-wrap break-all bg-black/40 p-2.5 rounded-lg border border-rose-900/40 max-h-40 overflow-y-auto leading-relaxed">
|
||||
{log.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Changes List */}
|
||||
{Array.isArray(log.changes) && log.changes.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
@@ -1823,7 +1970,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
|
||||
No itemized field changes content recorded for this event.
|
||||
No itemized field changes or invocation arguments recorded for this event.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1837,6 +1984,64 @@ export const SettingsPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{totalAuditRecords > 0 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-5 py-3 border-t border-slate-800/80 bg-slate-900/40 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>
|
||||
Showing <strong className="text-white font-mono">{Math.min((auditPage - 1) * auditPageSize + 1, totalAuditRecords)}</strong> to{' '}
|
||||
<strong className="text-white font-mono">{Math.min(auditPage * auditPageSize, totalAuditRecords)}</strong> of{' '}
|
||||
<strong className="text-white font-mono">{totalAuditRecords}</strong> records
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-400">Per page:</span>
|
||||
<select
|
||||
value={auditPageSize}
|
||||
onChange={(e) => {
|
||||
setAuditPageSize(Number(e.target.value));
|
||||
setAuditPage(1);
|
||||
}}
|
||||
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
|
||||
disabled={auditPage <= 1}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
title="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="px-2 font-mono text-slate-300">
|
||||
{auditPage} / {totalAuditPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
|
||||
disabled={auditPage >= totalAuditPages}
|
||||
className="p-1 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
title="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1990,7 +2195,195 @@ export const SettingsPage: React.FC = () => {
|
||||
</Modal>
|
||||
|
||||
{/* ============================================================== */}
|
||||
{/* Modal: Dispatch Invitation */}
|
||||
{/* Modal: Exception Detail Inspection Modal */}
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
isOpen={isExceptionDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsExceptionDetailModalOpen(false);
|
||||
setSelectedExceptionLog(null);
|
||||
setCopiedExceptionTrace(false);
|
||||
}}
|
||||
title={`Captured Exception: Event #${selectedExceptionLog?.id || ''}`}
|
||||
subtitle="Runtime exception diagnostics and invocation telemetry"
|
||||
maxWidth="2xl"
|
||||
>
|
||||
{selectedExceptionLog && (
|
||||
<div className="space-y-4 text-xs min-w-0">
|
||||
{/* Header Alert Box */}
|
||||
<div className="p-4 bg-rose-950/30 border border-rose-500/30 rounded-xl space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-rose-900/50 pb-2.5 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-rose-500/20 border border-rose-500/30 flex items-center justify-center text-rose-400 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-white font-bold text-sm block">System Exception Captured</span>
|
||||
<span className="text-[11px] text-rose-300/80 font-mono">
|
||||
Action: <strong className="text-white">{selectedExceptionLog.action || 'Unknown'}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/40">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-rose-400" />
|
||||
FAILED EXECUTION
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Entity and Context Details */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1">
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity / Target:</span>
|
||||
<span className="text-white font-semibold">
|
||||
{selectedExceptionLog.entityName || 'General'}{' '}
|
||||
{selectedExceptionLog.entityId ? `(#${selectedExceptionLog.entityId})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
|
||||
<span className="font-mono text-slate-300">{formatDate(selectedExceptionLog.timestamp)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Triggered By:</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className="font-mono text-slate-200 text-[11px] truncate" title={selectedExceptionLog.performedBy || 'System'}>
|
||||
{getUserEmail(selectedExceptionLog.performedBy) !== '—'
|
||||
? getUserEmail(selectedExceptionLog.performedBy)
|
||||
: (selectedExceptionLog.performedBy || 'System')}
|
||||
</span>
|
||||
{selectedExceptionLog.performedBy && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleCopyUserId(selectedExceptionLog.performedBy, e)}
|
||||
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
|
||||
title={`Copy User ID: ${selectedExceptionLog.performedBy}`}
|
||||
>
|
||||
{copiedUserId === selectedExceptionLog.performedBy ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
|
||||
<span className="font-mono text-slate-300">
|
||||
{selectedExceptionLog.tenantId ? `Tenant #${selectedExceptionLog.tenantId}` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message & Stack Trace Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-rose-400" />
|
||||
Exception Diagnostic Message & Trace
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const textToCopy = selectedExceptionLog.error || 'No error details recorded.';
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setCopiedExceptionTrace(true);
|
||||
success('Exception Copied', 'Exception trace copied to clipboard.');
|
||||
setTimeout(() => setCopiedExceptionTrace(false), 2000);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white border border-slate-700 text-[11px] transition cursor-pointer"
|
||||
>
|
||||
{copiedExceptionTrace ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-400" />
|
||||
<span>Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 text-blue-400" />
|
||||
<span>Copy Trace</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#050810] border border-rose-900/50 rounded-xl p-4 overflow-hidden shadow-inner">
|
||||
<pre className="font-mono text-rose-400 text-xs whitespace-pre-wrap break-all max-h-72 overflow-y-auto leading-relaxed selection:bg-rose-900 selection:text-white">
|
||||
{selectedExceptionLog.error || 'No stack trace or error message captured.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invocation Arguments / Parameters */}
|
||||
<div className="space-y-2">
|
||||
<span className="text-slate-300 font-bold uppercase text-[11px] flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-blue-400" />
|
||||
Invocation Arguments & Parameters
|
||||
</span>
|
||||
|
||||
{Array.isArray(selectedExceptionLog.changes) && selectedExceptionLog.changes.length > 0 ? (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto pr-1">
|
||||
{selectedExceptionLog.changes.map((param, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-slate-900/90 border border-slate-800 text-xs font-mono"
|
||||
>
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
|
||||
#{idx + 1}
|
||||
</span>
|
||||
<span className="break-all text-slate-300 flex-1 leading-relaxed">
|
||||
{param}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-center text-slate-500 font-mono text-[11px]">
|
||||
No parameters or arguments recorded for this invocation.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Raw JSON Data Viewer */}
|
||||
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 font-semibold text-[11px] uppercase">
|
||||
Raw Exception Record (JSON):
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(JSON.stringify(selectedExceptionLog, null, 2));
|
||||
success('Copied', 'JSON payload copied to clipboard.');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>Copy JSON</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-rose-300/90 font-mono text-[10.5px] overflow-x-auto whitespace-pre-wrap break-all max-h-40 min-w-0">
|
||||
{JSON.stringify(selectedExceptionLog, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-end pt-3 border-t border-slate-800 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExceptionDetailModalOpen(false);
|
||||
setSelectedExceptionLog(null);
|
||||
}}
|
||||
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-xl transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{/* ============================================================== */}
|
||||
<Modal
|
||||
isOpen={isSendInviteModalOpen}
|
||||
|
||||
@@ -346,6 +346,8 @@ export interface AuditLog {
|
||||
changes?: string[] | null;
|
||||
performedBy: string;
|
||||
timestamp: string;
|
||||
error?: string | null;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
export interface UserFilterRequest {
|
||||
|
||||
@@ -17,5 +17,7 @@ public sealed class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
|
||||
builder.Property(f => f.Changes).IsRequired(false);
|
||||
builder.Property(f => f.PerformedBy).IsRequired();
|
||||
builder.Property(f => f.Timestamp).IsRequired().HasDefaultValueSql("now()");
|
||||
builder.Property(f => f.Error).IsRequired(false);
|
||||
builder.Ignore(f => f.IsError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ public static class Mappers
|
||||
EntityId = entity.EntityId,
|
||||
EntityName = entity.EntityName,
|
||||
PerformedBy = entity.PerformedBy,
|
||||
Timestamp = entity.Timestamp
|
||||
Timestamp = entity.Timestamp,
|
||||
Error = entity.Error
|
||||
};
|
||||
|
||||
public static Award Map(this Entities.Award entity) => new()
|
||||
@@ -398,4 +399,4 @@ public static class Mappers
|
||||
Role = entity.Role,
|
||||
IdNumber = entity.IdNumber
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,8 @@ public class AuditLog
|
||||
public Guid PerformedBy { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
public string? Error { get; set; }
|
||||
|
||||
public bool IsError => !string.IsNullOrWhiteSpace(Error);
|
||||
}
|
||||
|
||||
+2476
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedErrorToAuditLog : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Error",
|
||||
table: "AuditLogs",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Error",
|
||||
table: "AuditLogs");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -125,6 +125,9 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("PerformedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
@@ -537,6 +537,8 @@ export interface AuditLog {
|
||||
timestamp?: string;
|
||||
performedBy?: string;
|
||||
details?: string;
|
||||
error?: string | null;
|
||||
isError?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Castle.DynamicProxy;
|
||||
using FluentResults;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PostFundManagement.Application.Interceptors;
|
||||
using PostFundManagement.Application.Services.Shared;
|
||||
using PostFundManagement.Domain;
|
||||
using PostFundManagement.Domain.Abstractions;
|
||||
using PostFundManagement.Domain.Attributes;
|
||||
using PostFundManagement.Domain.Extensions;
|
||||
using PostFundManagement.Infrastructure.Database;
|
||||
using Entities = PostFundManagement.Domain.Entities;
|
||||
using Models = PostFundManagement.Domain.Models;
|
||||
|
||||
namespace PostFundManagement.Tests;
|
||||
|
||||
public class AuditLogTests
|
||||
{
|
||||
[Fact]
|
||||
public void AuditLog_WhenErrorNull_IsErrorIsFalse()
|
||||
{
|
||||
var model = new Models.AuditLog
|
||||
{
|
||||
Id = 1,
|
||||
TenantId = 1,
|
||||
Action = "Test",
|
||||
Error = null
|
||||
};
|
||||
|
||||
Assert.False(model.IsError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLog_WhenErrorPopulated_IsErrorIsTrue()
|
||||
{
|
||||
var model = new Models.AuditLog
|
||||
{
|
||||
Id = 2,
|
||||
TenantId = 1,
|
||||
Action = "Test",
|
||||
Error = "[ValidationError] Something went wrong"
|
||||
};
|
||||
|
||||
Assert.True(model.IsError);
|
||||
Assert.Equal("[ValidationError] Something went wrong", model.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuditLog_Map_CorrectlyMapsErrorField()
|
||||
{
|
||||
var entity = new Entities.AuditLog
|
||||
{
|
||||
Id = 10,
|
||||
TenantId = 3,
|
||||
Action = "RegisterAward",
|
||||
Changes = ["amount: 50000"],
|
||||
EntityId = "AW-100",
|
||||
EntityName = "Award",
|
||||
PerformedBy = Guid.NewGuid(),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Error = "[Exception] InvalidOperationException: Award already exists"
|
||||
};
|
||||
|
||||
var mapped = entity.Map();
|
||||
|
||||
Assert.Equal(entity.Id, mapped.Id);
|
||||
Assert.Equal(entity.TenantId, mapped.TenantId);
|
||||
Assert.Equal(entity.Action, mapped.Action);
|
||||
Assert.Equal(entity.Changes, mapped.Changes);
|
||||
Assert.Equal(entity.EntityId, mapped.EntityId);
|
||||
Assert.Equal(entity.EntityName, mapped.EntityName);
|
||||
Assert.Equal(entity.PerformedBy, mapped.PerformedBy);
|
||||
Assert.Equal(entity.Timestamp, mapped.Timestamp);
|
||||
Assert.Equal(entity.Error, mapped.Error);
|
||||
Assert.True(mapped.IsError);
|
||||
}
|
||||
|
||||
public interface ITestService
|
||||
{
|
||||
[Audit]
|
||||
Task<Result> DoSuccessfulActionAsync(string param);
|
||||
|
||||
[Audit]
|
||||
Task<Result> DoFailedActionAsync(string param);
|
||||
|
||||
[Audit]
|
||||
Task<Result> ThrowExceptionAsync(string param);
|
||||
}
|
||||
|
||||
public class TestService : ITestService
|
||||
{
|
||||
public Task<Result> DoSuccessfulActionAsync(string param) =>
|
||||
Task.FromResult(Result.Ok());
|
||||
|
||||
public Task<Result> DoFailedActionAsync(string param) =>
|
||||
Task.FromResult(Result.Fail(new Error("Database connection timed out")));
|
||||
|
||||
public Task<Result> ThrowExceptionAsync(string param) =>
|
||||
throw new InvalidOperationException("Fatal system error occurred");
|
||||
}
|
||||
|
||||
private sealed class DummyTenantContextService : ITenantContextService
|
||||
{
|
||||
public string? TenantName => "TestTenant";
|
||||
public long TenantId { get; }
|
||||
public bool ByPassTenantQueryFilter { get; set; }
|
||||
public bool IsAdmin { get; }
|
||||
public bool HasTenantFilter => !IsAdmin;
|
||||
|
||||
public DummyTenantContextService(long tenantId, bool isAdmin)
|
||||
{
|
||||
TenantId = tenantId;
|
||||
IsAdmin = isAdmin;
|
||||
ByPassTenantQueryFilter = isAdmin;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestDbContextFactory(DbContextOptions<ApplicationDbContext> options, ITenantContextService tenantService) : IDbContextFactory<ApplicationDbContext>
|
||||
{
|
||||
public ApplicationDbContext CreateDbContext() => new(options, tenantService);
|
||||
public Task<ApplicationDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new ApplicationDbContext(options, tenantService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditInterceptor_WhenActionFails_LogsErrorDetails()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
|
||||
var dbContextFactory = new TestDbContextFactory(options, tenantService);
|
||||
var auditService = new AuditService(dbContextFactory, tenantService);
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("tenant_id", "5")
|
||||
};
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
|
||||
var proxyGenerator = new ProxyGenerator();
|
||||
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
|
||||
var target = new TestService();
|
||||
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
|
||||
|
||||
// Act
|
||||
var result = await proxy.DoFailedActionAsync("test_payload");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsFailed);
|
||||
|
||||
await using var verifyContext = new ApplicationDbContext(options, tenantService);
|
||||
var logs = await verifyContext.AuditLogs.ToListAsync();
|
||||
Assert.Single(logs);
|
||||
var log = logs[0];
|
||||
Assert.Equal("DoFailedAction", log.Action);
|
||||
Assert.Equal(5, log.TenantId);
|
||||
Assert.Equal(userId, log.PerformedBy);
|
||||
Assert.NotNull(log.Error);
|
||||
Assert.Contains("Database connection timed out", log.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditInterceptor_WhenActionThrowsException_LogsExceptionAndRethrows()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
|
||||
var dbContextFactory = new TestDbContextFactory(options, tenantService);
|
||||
var auditService = new AuditService(dbContextFactory, tenantService);
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("tenant_id", "5")
|
||||
};
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
|
||||
var proxyGenerator = new ProxyGenerator();
|
||||
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
|
||||
var target = new TestService();
|
||||
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => proxy.ThrowExceptionAsync("test_payload"));
|
||||
|
||||
await using var verifyContext = new ApplicationDbContext(options, tenantService);
|
||||
var logs = await verifyContext.AuditLogs.ToListAsync();
|
||||
Assert.Single(logs);
|
||||
var log = logs[0];
|
||||
Assert.Equal("ThrowException", log.Action);
|
||||
Assert.Equal(5, log.TenantId);
|
||||
Assert.Equal(userId, log.PerformedBy);
|
||||
Assert.NotNull(log.Error);
|
||||
Assert.Contains("InvalidOperationException", log.Error);
|
||||
Assert.Contains("Fatal system error occurred", log.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditInterceptor_WhenActionSucceeds_LogsWithoutError()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var tenantService = new DummyTenantContextService(tenantId: 5, isAdmin: false);
|
||||
var dbContextFactory = new TestDbContextFactory(options, tenantService);
|
||||
var auditService = new AuditService(dbContextFactory, tenantService);
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim("tenant_id", "5")
|
||||
};
|
||||
httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
|
||||
var proxyGenerator = new ProxyGenerator();
|
||||
var interceptor = new AuditInterceptor(auditService, httpContextAccessor);
|
||||
var target = new TestService();
|
||||
var proxy = proxyGenerator.CreateInterfaceProxyWithTarget<ITestService>(target, interceptor);
|
||||
|
||||
// Act
|
||||
var result = await proxy.DoSuccessfulActionAsync("test_payload");
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
|
||||
await using var verifyContext = new ApplicationDbContext(options, tenantService);
|
||||
var logs = await verifyContext.AuditLogs.ToListAsync();
|
||||
Assert.Single(logs);
|
||||
var log = logs[0];
|
||||
Assert.Equal("DoSuccessfulAction", log.Action);
|
||||
Assert.Equal(5, log.TenantId);
|
||||
Assert.Equal(userId, log.PerformedBy);
|
||||
Assert.Null(log.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditService_GetAuditLogsAsync_NonAdmin_FiltersByTenant()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var tenantService = new DummyTenantContextService(tenantId: 2, isAdmin: false);
|
||||
await using (var dbContext = new ApplicationDbContext(options, tenantService))
|
||||
{
|
||||
dbContext.AuditLogs.AddRange(
|
||||
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
|
||||
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
|
||||
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
|
||||
);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var dbContextFactory = new TestDbContextFactory(options, tenantService);
|
||||
var auditService = new AuditService(dbContextFactory, tenantService);
|
||||
|
||||
// Act
|
||||
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Single(result.Value);
|
||||
Assert.Equal(2, result.Value[0].TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuditService_GetAuditLogsAsync_Tenant1Admin_SeesAllTenantsLogs()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var tenantService = new DummyTenantContextService(tenantId: 1, isAdmin: true);
|
||||
await using (var dbContext = new ApplicationDbContext(options, tenantService))
|
||||
{
|
||||
dbContext.AuditLogs.AddRange(
|
||||
new Entities.AuditLog { Id = 1, TenantId = 1, Action = "A1", EntityId = "1", EntityName = "E1", PerformedBy = Guid.NewGuid() },
|
||||
new Entities.AuditLog { Id = 2, TenantId = 2, Action = "A2", EntityId = "2", EntityName = "E2", PerformedBy = Guid.NewGuid() },
|
||||
new Entities.AuditLog { Id = 3, TenantId = 3, Action = "A3", EntityId = "3", EntityName = "E3", PerformedBy = Guid.NewGuid() }
|
||||
);
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var dbContextFactory = new TestDbContextFactory(options, tenantService);
|
||||
var auditService = new AuditService(dbContextFactory, tenantService);
|
||||
|
||||
// Act
|
||||
var result = await auditService.GetAuditLogsAsync(Pagination.Create(1, 10));
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(3, result.Value.Length);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user