Skip to content

Commit 0320c03

Browse files
mpauloskyCopilot
andauthored
feat: UserAuditLogPanel — role change history view for admin users (#140) (#170)
- Add ListAuditEntriesQuery + handler in Domain.Features.Admin.AuditLog.Queries - Add UserAuditLogPanel.razor: paginated audit log table (10/page), relative timestamps with ISO tooltip, Assigned/Removed action badges, empty state - Wire HandleViewAuditLog / HandleCloseAuditLog in Users.razor admin page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6b13f0b commit 0320c03

3 files changed

Lines changed: 380 additions & 1 deletion

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// ============================================
2+
// Copyright (c) 2026. All rights reserved.
3+
// File Name : ListAuditEntriesQuery.cs
4+
// Company : mpaulosky
5+
// Author : Matthew Paulosky
6+
// Solution Name : IssueManager
7+
// Project Name : Domain
8+
// =============================================
9+
10+
using Domain.Abstractions;
11+
using Domain.Features.Admin.Abstractions;
12+
using Domain.Features.Admin.Models;
13+
14+
namespace Domain.Features.Admin.AuditLog.Queries;
15+
16+
/// <summary>
17+
/// Query to retrieve all role-change audit entries for a specific target user,
18+
/// returned in reverse-chronological order.
19+
/// </summary>
20+
public record ListAuditEntriesQuery(string TargetUserId)
21+
: IRequest<Result<IReadOnlyList<RoleChangeAuditEntry>>>;
22+
23+
/// <summary>
24+
/// Handler for <see cref="ListAuditEntriesQuery" />.
25+
/// </summary>
26+
public sealed class ListAuditEntriesQueryHandler
27+
: IRequestHandler<ListAuditEntriesQuery, Result<IReadOnlyList<RoleChangeAuditEntry>>>
28+
{
29+
private readonly IAuditLogRepository _auditLogRepository;
30+
private readonly ILogger<ListAuditEntriesQueryHandler> _logger;
31+
32+
public ListAuditEntriesQueryHandler(
33+
IAuditLogRepository auditLogRepository,
34+
ILogger<ListAuditEntriesQueryHandler> logger)
35+
{
36+
_auditLogRepository = auditLogRepository;
37+
_logger = logger;
38+
}
39+
40+
public async Task<Result<IReadOnlyList<RoleChangeAuditEntry>>> Handle(
41+
ListAuditEntriesQuery request,
42+
CancellationToken cancellationToken)
43+
{
44+
_logger.LogInformation(
45+
"Listing audit entries for target user '{TargetUserId}'",
46+
request.TargetUserId);
47+
48+
try
49+
{
50+
var entries = await _auditLogRepository.GetByTargetUserAsync(
51+
request.TargetUserId,
52+
cancellationToken);
53+
54+
var sorted = entries
55+
.OrderByDescending(e => e.Timestamp)
56+
.ToList()
57+
.AsReadOnly();
58+
59+
_logger.LogInformation(
60+
"Retrieved {Count} audit entry(ies) for target user '{TargetUserId}'",
61+
sorted.Count,
62+
request.TargetUserId);
63+
64+
return Result.Ok<IReadOnlyList<RoleChangeAuditEntry>>(sorted);
65+
}
66+
catch (Exception ex)
67+
{
68+
_logger.LogError(ex,
69+
"Error retrieving audit entries for target user '{TargetUserId}'",
70+
request.TargetUserId);
71+
72+
return Result.Fail<IReadOnlyList<RoleChangeAuditEntry>>(
73+
$"Failed to retrieve audit log: {ex.Message}");
74+
}
75+
}
76+
}
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
@* ============================================
2+
Copyright (c) 2026. All rights reserved.
3+
File Name : UserAuditLogPanel.razor
4+
Company : mpaulosky
5+
Author : Matthew Paulosky
6+
Solution Name : IssueManager
7+
Project Name : Web
8+
============================================ *@
9+
10+
@using Domain.Features.Admin.AuditLog.Queries
11+
@using Domain.Features.Admin.Models
12+
13+
@inject IMediator Mediator
14+
15+
@if (UserId is not null)
16+
{
17+
<div class="mt-6 bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
18+
<!-- Panel Header -->
19+
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
20+
<div>
21+
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
22+
Role Change History
23+
@if (!string.IsNullOrEmpty(UserEmail))
24+
{
25+
<span class="font-normal text-gray-500 dark:text-gray-400"> — @UserEmail</span>
26+
}
27+
</h3>
28+
</div>
29+
<button type="button"
30+
class="rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
31+
@onclick="HandleClose">
32+
<span class="sr-only">Close</span>
33+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
34+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
35+
</svg>
36+
</button>
37+
</div>
38+
39+
<!-- Panel Body -->
40+
<div class="px-6 py-4">
41+
@if (_isLoading)
42+
{
43+
<div class="flex justify-center py-8">
44+
<svg class="animate-spin h-6 w-6 text-primary-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
45+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
46+
<path class="opacity-75" fill="currentColor"
47+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
48+
</path>
49+
</svg>
50+
</div>
51+
}
52+
else if (!string.IsNullOrEmpty(_errorMessage))
53+
{
54+
<div class="rounded-md bg-red-50 dark:bg-red-900/50 p-4">
55+
<div class="flex">
56+
<svg class="h-5 w-5 text-red-400 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
57+
<path fill-rule="evenodd"
58+
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
59+
clip-rule="evenodd" />
60+
</svg>
61+
<p class="ml-3 text-sm text-red-700 dark:text-red-300">@_errorMessage</p>
62+
</div>
63+
</div>
64+
}
65+
else if (_allEntries.Count == 0)
66+
{
67+
<div class="text-center py-8">
68+
<svg class="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
69+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
70+
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
71+
</svg>
72+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No role changes recorded for this user</p>
73+
</div>
74+
}
75+
else
76+
{
77+
<div class="overflow-x-auto">
78+
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
79+
<thead class="bg-gray-50 dark:bg-gray-700">
80+
<tr>
81+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
82+
Timestamp
83+
</th>
84+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
85+
Action
86+
</th>
87+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
88+
Role
89+
</th>
90+
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
91+
Changed By
92+
</th>
93+
</tr>
94+
</thead>
95+
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
96+
@foreach (var entry in PagedEntries)
97+
{
98+
<tr>
99+
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
100+
<span title='@entry.Timestamp.ToLocalTime().ToString("O")'>
101+
@FormatRelativeTime(entry.Timestamp)
102+
</span>
103+
</td>
104+
<td class="px-4 py-3 whitespace-nowrap text-sm">
105+
@if (string.Equals(entry.Action, "assigned", StringComparison.OrdinalIgnoreCase))
106+
{
107+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
108+
Assigned
109+
</span>
110+
}
111+
else
112+
{
113+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
114+
Removed
115+
</span>
116+
}
117+
</td>
118+
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
119+
@entry.RoleName
120+
</td>
121+
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
122+
@(string.IsNullOrEmpty(entry.AdminUserName) ? entry.AdminUserId : entry.AdminUserName)
123+
</td>
124+
</tr>
125+
}
126+
</tbody>
127+
</table>
128+
</div>
129+
130+
<!-- Pagination -->
131+
@if (TotalPages > 1)
132+
{
133+
<div class="mt-4 flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4">
134+
<p class="text-sm text-gray-500 dark:text-gray-400">
135+
Showing <span class="font-medium">@((_currentPage - 1) * PageSize + 1)</span>
136+
to <span class="font-medium">@Math.Min(_currentPage * PageSize, _allEntries.Count)</span>
137+
of <span class="font-medium">@_allEntries.Count</span> entries
138+
</p>
139+
<div class="flex gap-2">
140+
<button type="button"
141+
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
142+
@onclick="PreviousPage"
143+
disabled="@(_currentPage <= 1)">
144+
Previous
145+
</button>
146+
<button type="button"
147+
class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
148+
@onclick="NextPage"
149+
disabled="@(_currentPage >= TotalPages)">
150+
Next
151+
</button>
152+
</div>
153+
</div>
154+
}
155+
}
156+
</div>
157+
</div>
158+
}
159+
160+
@code {
161+
private const int PageSize = 10;
162+
163+
private List<RoleChangeAuditEntry> _allEntries = [];
164+
private bool _isLoading;
165+
private string? _errorMessage;
166+
private int _currentPage = 1;
167+
private string? _lastLoadedUserId;
168+
169+
/// <summary>Gets or sets the Auth0 identifier of the user whose audit log to display.</summary>
170+
[Parameter]
171+
public string? UserId { get; set; }
172+
173+
/// <summary>Gets or sets the email address of the user, used in the panel title.</summary>
174+
[Parameter]
175+
public string? UserEmail { get; set; }
176+
177+
/// <summary>Gets or sets the callback invoked when the panel is closed.</summary>
178+
[Parameter]
179+
public EventCallback OnClose { get; set; }
180+
181+
private IEnumerable<RoleChangeAuditEntry> PagedEntries =>
182+
_allEntries.Skip((_currentPage - 1) * PageSize).Take(PageSize);
183+
184+
private int TotalPages =>
185+
(int)Math.Ceiling(_allEntries.Count / (double)PageSize);
186+
187+
protected override async Task OnParametersSetAsync()
188+
{
189+
if (UserId is not null && UserId != _lastLoadedUserId)
190+
{
191+
_lastLoadedUserId = UserId;
192+
_currentPage = 1;
193+
await LoadEntriesAsync();
194+
}
195+
}
196+
197+
private async Task LoadEntriesAsync()
198+
{
199+
if (string.IsNullOrEmpty(UserId))
200+
{
201+
return;
202+
}
203+
204+
_isLoading = true;
205+
_errorMessage = null;
206+
207+
try
208+
{
209+
var result = await Mediator.Send(
210+
new ListAuditEntriesQuery(UserId),
211+
CancellationToken.None);
212+
213+
if (result.Success)
214+
{
215+
_allEntries = [.. result.Value ?? []];
216+
}
217+
else
218+
{
219+
_errorMessage = result.Error ?? "Failed to load audit log.";
220+
}
221+
}
222+
catch (Exception ex)
223+
{
224+
_errorMessage = $"An unexpected error occurred: {ex.Message}";
225+
}
226+
finally
227+
{
228+
_isLoading = false;
229+
}
230+
}
231+
232+
private void PreviousPage()
233+
{
234+
if (_currentPage > 1)
235+
{
236+
_currentPage--;
237+
}
238+
}
239+
240+
private void NextPage()
241+
{
242+
if (_currentPage < TotalPages)
243+
{
244+
_currentPage++;
245+
}
246+
}
247+
248+
private Task HandleClose() => OnClose.InvokeAsync();
249+
250+
private static string FormatRelativeTime(DateTimeOffset timestamp)
251+
{
252+
var diff = DateTimeOffset.UtcNow - timestamp;
253+
254+
if (diff.TotalSeconds < 60)
255+
return "just now";
256+
257+
if (diff.TotalSeconds < 3600)
258+
{
259+
var m = (int)diff.TotalMinutes;
260+
return m == 1 ? "1 minute ago" : $"{m} minutes ago";
261+
}
262+
263+
if (diff.TotalSeconds < 86400)
264+
{
265+
var h = (int)diff.TotalHours;
266+
return h == 1 ? "1 hour ago" : $"{h} hours ago";
267+
}
268+
269+
if (diff.TotalSeconds < 2592000)
270+
{
271+
var d = (int)diff.TotalDays;
272+
return d == 1 ? "1 day ago" : $"{d} days ago";
273+
}
274+
275+
if (diff.TotalSeconds < 31536000)
276+
{
277+
var mo = (int)(diff.TotalDays / 30);
278+
return mo == 1 ? "1 month ago" : $"{mo} months ago";
279+
}
280+
281+
var y = (int)(diff.TotalDays / 365);
282+
return y == 1 ? "1 year ago" : $"{y} years ago";
283+
}
284+
}

0 commit comments

Comments
 (0)