@L["Agent keys issued and revoked, caps exceeded, refused moves, changes to people's accounts and to routing rules, the newest first. Every one is also in the audit log."]
+
+
+}
+
+@code {
+ public const int PageSize = 50;
+
+ private IReadOnlyList _events = [];
+ private int _total;
+ private int _page;
+ private int _offset;
+ private string? _error;
+
+ [SupplyParameterFromQuery(Name = "page")]
+ private int? PageNumber { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ _page = Math.Max(1, PageNumber ?? 1);
+ _offset = (int)Math.Min((_page - 1L) * PageSize, int.MaxValue - PageSize);
+
+ var listed = await AuditLists.ListAsync(new AuditEventListQuery { Operations = SecurityEvents.Operations, Offset = _offset, Limit = PageSize });
+ if (listed.IsSuccess)
+ {
+ (_events, _total) = (listed.Value.Items, listed.Value.Total);
+ }
+ else
+ {
+ // The error code is the resource key; the reader's own message is the text until a resource exists.
+ _error = string.Join(" ", listed.Errors.Select(error =>
+ {
+ var localized = L[error.Code];
+ return localized.ResourceNotFound ? error.Message : localized.Value;
+ }));
+ }
+ }
+
+ private static string Href(int page) => page > 1 ? $"security?page={page}" : "security";
+
+ private string Describe(string operation) => operation switch
+ {
+ "agent_credential.issued" => L["An agent key was issued"],
+ "agent_credential.revoked" => L["An agent key was revoked"],
+ "agent.calls_per_period_exceeded" => L["An agent went over its calls cap"],
+ "agent.change_sets_per_day_exceeded" => L["An agent went over its change sets per day"],
+ "agent.items_per_change_set_exceeded" => L["An agent went over its items per change set"],
+ "agent.transition_refused" => L["An agent tried a move only people make"],
+ "user.created" => L["A person was added"],
+ "user.disabled" => L["A person was disabled"],
+ "user.enabled" => L["A person was enabled again"],
+ "user.password_reset_issued" => L["A password reset link was issued"],
+ "user.password_reset" => L["A password was reset"],
+ "user.login_linked" => L["An external sign-in was linked"],
+ "routing_rule.created" => L["A routing rule was created"],
+ "routing_rule.revised" => L["A routing rule was revised"],
+ "routing_rule.activated" => L["A routing rule was activated"],
+ "routing_rule.deactivated" => L["A routing rule was deactivated"],
+ _ => operation,
+ };
+}
diff --git a/src/FluentCMS.Core/Audit/AuditErrorCodes.cs b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs
index 316824732..28942a9e2 100644
--- a/src/FluentCMS.Core/Audit/AuditErrorCodes.cs
+++ b/src/FluentCMS.Core/Audit/AuditErrorCodes.cs
@@ -6,4 +6,9 @@ public static class AuditErrorCodes
public const string SubjectTypeMissing = "audit_subject_type_missing";
public const string SubjectIdMissing = "audit_subject_id_missing";
public const string ExportFormatUnknown = "audit_export_format_unknown";
+ public const string OperationsMissing = "audit_operations_missing";
+
+ // The same codes the other lists of the desk use for a window out of range.
+ public const string OffsetInvalid = "offset_invalid";
+ public const string LimitInvalid = "limit_invalid";
}
diff --git a/src/FluentCMS.Core/Audit/IAuditEventLists.cs b/src/FluentCMS.Core/Audit/IAuditEventLists.cs
new file mode 100644
index 000000000..169ad9a7f
--- /dev/null
+++ b/src/FluentCMS.Core/Audit/IAuditEventLists.cs
@@ -0,0 +1,51 @@
+using FluentCMS.Core.Results;
+
+namespace FluentCMS.Core.Audit;
+
+// Pages of the log for screens, newest first, such as the desk's security events (SEC-08). IAuditReader streams the log oldest first
+// for exports; this reads one page and the total. A list appends no event.
+public interface IAuditEventLists
+{
+ Task> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default);
+
+ // How many events the query selects, whatever its offset and limit.
+ Task> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default);
+}
+
+public sealed record AuditEventList(IReadOnlyList Items, int Total);
+
+// The events with any of the given operations, optionally only those at or after a time, newest first.
+public sealed record AuditEventListQuery
+{
+ public const int MaxLimit = 200;
+
+ public required IReadOnlyCollection Operations { get; init; }
+
+ public DateTimeOffset? Since { get; init; }
+
+ public int Offset { get; init; }
+
+ public int Limit { get; init; } = 50;
+
+ public Result Validate()
+ {
+ var errors = new List();
+
+ if (Operations is null || Operations.Count == 0 || Operations.Any(string.IsNullOrWhiteSpace))
+ {
+ errors.Add(new Error(AuditErrorCodes.OperationsMissing, "A list names one or more operations to list."));
+ }
+
+ if (Offset < 0)
+ {
+ errors.Add(new Error(AuditErrorCodes.OffsetInvalid, $"The offset is {Offset}; it is 0 or more."));
+ }
+
+ if (Limit is < 1 or > MaxLimit)
+ {
+ errors.Add(new Error(AuditErrorCodes.LimitInvalid, $"The limit is {Limit}; it is from 1 to {MaxLimit}."));
+ }
+
+ return errors.Count == 0 ? Result.Success() : Result.Failure(errors);
+ }
+}
diff --git a/src/FluentCMS.Core/Audit/SecurityEvents.cs b/src/FluentCMS.Core/Audit/SecurityEvents.cs
new file mode 100644
index 000000000..0a71ada6e
--- /dev/null
+++ b/src/FluentCMS.Core/Audit/SecurityEvents.cs
@@ -0,0 +1,26 @@
+namespace FluentCMS.Core.Audit;
+
+// The security-relevant operations the desk lists in its own view (SEC-08): agent credentials, caps and refused moves, people's
+// accounts, and routing rules. Each is appended by the lane that owns it; an operation no code appends yet simply matches nothing.
+public static class SecurityEvents
+{
+ public static IReadOnlyList Operations { get; } =
+ [
+ "agent_credential.issued",
+ "agent_credential.revoked",
+ "agent.calls_per_period_exceeded",
+ "agent.change_sets_per_day_exceeded",
+ "agent.items_per_change_set_exceeded",
+ "agent.transition_refused",
+ "user.created",
+ "user.disabled",
+ "user.enabled",
+ "user.password_reset_issued",
+ "user.password_reset",
+ "user.login_linked",
+ "routing_rule.created",
+ "routing_rule.revised",
+ "routing_rule.activated",
+ "routing_rule.deactivated",
+ ];
+}
diff --git a/src/FluentCMS.Core/Desk/DeskQueues.cs b/src/FluentCMS.Core/Desk/DeskQueues.cs
index e720405c7..c470aaabf 100644
--- a/src/FluentCMS.Core/Desk/DeskQueues.cs
+++ b/src/FluentCMS.Core/Desk/DeskQueues.cs
@@ -47,6 +47,17 @@ public async Task>> CountAsync(Cancel
return Result>.Success(counts);
}
+ public async Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default)
+ {
+ if (queue == DeskQueue.StaleFacts)
+ {
+ var stale = await facts.StaleAsync(cancellationToken: cancellationToken);
+ return stale.IsSuccess ? Result.Success(stale.Value.Count) : Result.Failure(stale.Errors);
+ }
+
+ return await lists.CountAsync(Query(queue, clock.GetUtcNow()), cancellationToken);
+ }
+
public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default)
{
if (!ChangeSetQueues.Contains(queue))
diff --git a/src/FluentCMS.Core/Desk/IDeskQueues.cs b/src/FluentCMS.Core/Desk/IDeskQueues.cs
index 73edc6344..acc85156f 100644
--- a/src/FluentCMS.Core/Desk/IDeskQueues.cs
+++ b/src/FluentCMS.Core/Desk/IDeskQueues.cs
@@ -21,6 +21,9 @@ public interface IDeskQueues
// How many sets or facts each of the six queues holds now.
Task>> CountAsync(CancellationToken cancellationToken = default);
+ // How many one queue holds, for a count shown on every page, such as awaiting me in the header (DSK-05).
+ Task> CountAsync(DeskQueue queue, CancellationToken cancellationToken = default);
+
// A page of a queue of change sets; stale facts is listed by ListStaleFactsAsync.
Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default);
diff --git a/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs b/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs
new file mode 100644
index 000000000..3a428a6c2
--- /dev/null
+++ b/src/FluentCMS.Infrastructure/Audit/AuditEventLists.cs
@@ -0,0 +1,74 @@
+using System.Runtime.CompilerServices;
+using FluentCMS.Core.Audit;
+using FluentCMS.Core.Results;
+using FluentCMS.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace FluentCMS.Infrastructure.Audit;
+
+// SQL rather than LINQ because EF Core will not compare or order a DateTimeOffset on SQLite, which stores it as text; stored
+// timestamps are all UTC, so the text sorts in time order, as in AuditReader. Every value travels as a parameter.
+internal sealed class AuditEventLists(FluentCmsDbContext context) : IAuditEventLists
+{
+ public async Task> ListAsync(AuditEventListQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var valid = query.Validate();
+ if (!valid.IsSuccess)
+ {
+ return Result.Failure(valid.Errors);
+ }
+
+ var (filter, parameters) = Filter(query);
+ var limit = Parameter(parameters, query.Limit);
+ var offset = Parameter(parameters, query.Offset);
+ var rows = await context.AuditEvents
+ .FromSql(FormattableStringFactory.Create(
+ $"SELECT * FROM \"AuditEvents\" WHERE {filter} ORDER BY \"Timestamp\" DESC, \"Id\" DESC LIMIT {limit} OFFSET {offset}",
+ [.. parameters]))
+ .AsNoTracking()
+ .ToListAsync(cancellationToken);
+
+ var total = await CountRowsAsync(query, cancellationToken);
+ return Result.Success(new AuditEventList([.. rows.Select(row => row.ToEvent())], total));
+ }
+
+ public async Task> CountAsync(AuditEventListQuery query, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ var valid = query.Validate();
+ return valid.IsSuccess
+ ? Result.Success(await CountRowsAsync(query, cancellationToken))
+ : Result.Failure(valid.Errors);
+ }
+
+ private Task CountRowsAsync(AuditEventListQuery query, CancellationToken cancellationToken)
+ {
+ var (filter, parameters) = Filter(query);
+ return context.AuditEvents
+ .FromSql(FormattableStringFactory.Create($"SELECT * FROM \"AuditEvents\" WHERE {filter}", [.. parameters]))
+ .CountAsync(cancellationToken);
+ }
+
+ private static (string Filter, List