Added audit log

This commit is contained in:
2025-07-06 16:35:46 +02:00
parent 10913b0a21
commit 827e0eae6c
23 changed files with 312 additions and 131 deletions

View File

@@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions {
services.TryAddScoped<IHopFrameAuthHandler, DefaultAuthHandler>();
services.TryAddScoped<ICallbackEmitter, CallbackEmitter>();
services.AddScoped<ISearchExpressionBuilder, SearchExpressionBuilder>();
services.AddScoped<IPrimaryKeyFinder, PrimaryKeyFinder>();
return services;
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
using HopFrame.Core.Config;
namespace HopFrame.Core.Services;
public interface IPrimaryKeyFinder {
PropertyInfo? GetPrimaryKeyInfo(TableConfig config);
}

View File

@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using HopFrame.Core.Config;
namespace HopFrame.Core.Services.Implementations;
internal sealed class PrimaryKeyFinder(IContextExplorer explorer) : IPrimaryKeyFinder {
public PropertyInfo? GetPrimaryKeyInfo(TableConfig config) {
if (config.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty;
}
return config.TableType
.GetProperties()
.FirstOrDefault(prop => prop
.GetCustomAttributes(true)
.Any(attr => attr is KeyAttribute));
}
}

View File

@@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
namespace HopFrame.Web.AuditLogging;
public sealed class AuditLogContext(DbContextOptions<AuditLogContext> options) : DbContext(options) {
public DbSet<AuditLogEntry> AuditLog { get; set; }
}

View File

@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace HopFrame.Web.AuditLogging;
public sealed class AuditLogEntry {
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; init; }
[MaxLength(255)]
public required string User { get; init; }
[MaxLength(255)]
public required string Table { get; init; }
[MaxLength(255)]
public required string Record { get; init; }
public required AuditLogType Type { get; init; }
public DateTime CreatedAt { get; set; } = DateTime.Now;
}
public enum AuditLogType : byte {
Create = 0x00,
Update = 0x01,
Delete = 0x02
}

View File

@@ -0,0 +1,72 @@
using HopFrame.Core.Config;
using HopFrame.Core.Services;
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Events;
namespace HopFrame.Web.AuditLogging;
internal sealed class AuditLogPlugin(AuditLogContext context, IContextExplorer explorer, IPrimaryKeyFinder keyFinder) {
[EventHandler]
public void OnInitialized(TableInitializedEvent e) {
if (e.Table.TableType != typeof(AuditLogEntry)) return;
e.DefaultButtons.ShowAddEntityButton = false;
e.DefaultButtons.ShowDeleteButton = false;
e.PluginButtons.Clear();
}
[EventHandler]
public async Task OnCreate(CreateEntryEvent e) {
if (e.Table.TableType == typeof(AuditLogEntry)) return;
var record = new AuditLogEntry {
Type = AuditLogType.Create,
Table = e.Table.DisplayName,
Record = PrintEntity(e.Entity, e.Table),
User = e.Username
};
await context.AuditLog.AddAsync(record);
await context.SaveChangesAsync();
}
[EventHandler]
public async Task OnUpdate(UpdateEntryEvent e) {
if (e.Table.TableType == typeof(AuditLogEntry)) return;
var record = new AuditLogEntry {
Type = AuditLogType.Update,
Table = e.Table.DisplayName,
Record = PrintEntity(e.Entity, e.Table),
User = e.Username
};
await context.AuditLog.AddAsync(record);
await context.SaveChangesAsync();
}
[EventHandler]
public async Task OnDelete(DeleteEntryEvent e) {
if (e.Table.TableType == typeof(AuditLogEntry)) return;
var record = new AuditLogEntry {
Type = AuditLogType.Delete,
Table = e.Table.DisplayName,
Record = PrintEntity(e.Entity, e.Table),
User = e.Username
};
await context.AuditLog.AddAsync(record);
await context.SaveChangesAsync();
}
private string PrintEntity(object entity, TableConfig config) {
var manager = explorer.GetTableManager(config.TableType);
var key = keyFinder.GetPrimaryKeyInfo(config);
return key?.GetValue(entity)?.ToString() ?? string.Empty;
}
}

View File

@@ -0,0 +1,30 @@
using HopFrame.Core.Config;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace HopFrame.Web.AuditLogging;
public static class ConfiguratorExtensions {
public static HopFrameConfigurator AddAuditLogging(this HopFrameConfigurator configurator, IServiceCollection services, Action<DbContextOptionsBuilder> optionsBuilder) {
services.AddDbContext<AuditLogContext>(optionsBuilder);
configurator
.AddDbContext<AuditLogContext>()
.Table<AuditLogEntry>(table => {
table.Property(l => l.Id)
.List(false);
table.InnerConfig.Properties
.ForEach(p => p.Editable = false);
table.SetOrderIndex(int.MinValue);
table.SetDisplayName("Audit Log");
});
configurator.AddPlugin<AuditLogPlugin>();
return configurator;
}
}

View File

@@ -385,7 +385,7 @@
if (value is null && property.IsRequired)
errorList.Add($"{property.Name} is required");
var eventResult = await PluginOrchestrator.DispatchEvent(new ValidationEvent(this) {
var eventResult = await PluginOrchestrator.DispatchEvent(new ValidationEvent(this, await Handler.GetCurrentUserDisplayNameAsync()) {
Errors = errorList,
Property = property,
Table = Content.Config

View File

@@ -97,7 +97,7 @@
Sortable="@property.Sortable"/>
}
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy)) {
@if (DisplayActions && (_hasDeletePolicy || _hasUpdatePolicy) && (_buttonToggles.ShowEditButton || _buttonToggles.ShowDeleteButton && _pluginButtons.Any(pb => pb.IsForTable(_config)))) {
<TemplateColumn Title="Actions" Align="@Align.End" Style="min-height: 44px; min-width: max-content">
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) {
<FluentButton OnClick="() => button.Handler.Invoke(context, _config!)">
@@ -226,10 +226,9 @@
private List<PluginButton> _pluginButtons = new();
private DefaultButtonToggles _buttonToggles = new();
internal static HopFrameTablePage? CurrentInstance { get; private set; }
private string? _currentUser;
protected override void OnInitialized() {
CurrentInstance = this;
_config ??= Explorer.GetTable(TableDisplayName);
if (_config is null || (_config.Ignored && DialogData is null)) {
@@ -242,8 +241,10 @@
Navigator.NavigateTo("/admin", true);
return;
}
_currentUser = await Handler.GetCurrentUserDisplayNameAsync();
var eventResult = await PluginOrchestrator.DispatchEvent(new TableInitializedEvent(this) {
var eventResult = await PluginOrchestrator.DispatchEvent(new TableInitializedEvent(this, _currentUser!) {
Table = _config!
});
if (eventResult.IsCanceled) return;
@@ -283,7 +284,7 @@
await Task.Delay(500, _searchCancel.Token);
var eventResult = await PluginOrchestrator.DispatchEvent(new SearchEvent(this) {
var eventResult = await PluginOrchestrator.DispatchEvent(new SearchEvent(this, _currentUser!) {
SearchTerm = _searchTerm,
Table = _config!,
CurrentPage = _currentPage
@@ -333,7 +334,7 @@
public async Task Reload() {
_loading = true;
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this) {
var eventResult = await PluginOrchestrator.DispatchEvent(new ReloadEvent(this, _currentUser!) {
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) {
@@ -352,7 +353,7 @@
}
public async Task ChangePage(int page) {
var eventResult = await PluginOrchestrator.DispatchEvent(new PageChangeEvent(this) {
var eventResult = await PluginOrchestrator.DispatchEvent(new PageChangeEvent(this, _currentUser!) {
CurrentPage = _currentPage,
NewPage = page,
TotalPages = _totalPages,
@@ -371,17 +372,17 @@
Navigator.NavigateTo("/admin", true);
return;
}
var eventResult = await PluginOrchestrator.DispatchEvent(new DeleteEntryEvent(this) {
Entity = element,
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) return;
var dialog = await Dialogs.ShowConfirmationAsync("Do you really want to delete this entry?");
var result = await dialog.Result;
if (result.Cancelled) return;
var eventResult = await PluginOrchestrator.DispatchEvent(new DeleteEntryEvent(this, _currentUser!) {
Entity = element,
Table = _config!
}, _tokenSource.Token);
if (eventResult.IsCanceled) return;
await _manager!.DeleteItem(element);
await Emitter.DispatchCallback(CallbackTypes.DeleteEntry(_config!), element);
await Reload();
@@ -392,22 +393,6 @@
Navigator.NavigateTo("/admin", true);
return;
}
HopFrameTablePageEventArgs eventArgs;
if (element is null) {
eventArgs = new CreateEntryEvent(this) {
Table = _config!
};
}
else {
eventArgs = new UpdateEntryEvent(this) {
Table = _config!,
Entity = element
};
}
var eventResult = await PluginOrchestrator.DispatchEvent(eventArgs, _tokenSource.Token);
if (eventResult.IsCanceled) return;
var panel = await Dialogs.ShowPanelAsync<HopFrameEditor>(new EditorDialogData(_config!, element), new DialogParameters {
TrapFocus = false
@@ -416,6 +401,23 @@
var data = result.Data as EditorDialogData;
if (result.Cancelled) return;
HopFrameTablePageEventArgs eventArgs;
if (element is null) {
eventArgs = new CreateEntryEvent(this, _currentUser!) {
Table = _config!,
Entity = data!.CurrentObject!
};
}
else {
eventArgs = new UpdateEntryEvent(this, _currentUser!) {
Table = _config!,
Entity = data!.CurrentObject!
};
}
var eventResult = await PluginOrchestrator.DispatchEvent(eventArgs, _tokenSource.Token);
if (eventResult.IsCanceled) return;
if (element is null) {
await _manager!.AddItem(data!.CurrentObject!);
@@ -430,7 +432,7 @@
}
private void SelectItem(object item, bool selected) {
var eventResult = PluginOrchestrator.DispatchEvent(new SelectEntryEvent(this) {
var eventResult = PluginOrchestrator.DispatchEvent(new SelectEntryEvent(this, _currentUser!) {
Entity = item,
Selected = selected,
Table = _config!

View File

@@ -2,17 +2,19 @@
namespace HopFrame.Web.Plugins.Events;
public sealed class DeleteEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class DeleteEntryEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required object Entity { get; init; }
}
public sealed class CreateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender);
public sealed class UpdateEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class CreateEntryEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required object Entity { get; init; }
}
public sealed class SelectEntryEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class UpdateEntryEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required object Entity { get; init; }
}
public sealed class SelectEntryEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required object Entity { get; init; }
public required bool Selected { get; set; }
}

View File

@@ -4,24 +4,25 @@ using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public abstract class HopFrameEventArgs(object internalSender) {
public abstract class HopFrameEventArgs(object internalSender, string user) {
internal object InternalSender { get; } = internalSender;
public bool IsCanceled { get; protected set; }
public string Username { get; set; } = user;
public void SetCancelled(bool canceled) => IsCanceled = canceled;
}
public abstract class HopFrameEventArgs<TSender>(TSender sender) : HopFrameEventArgs(sender) where TSender : class {
public abstract class HopFrameEventArgs<TSender>(TSender sender, string user) : HopFrameEventArgs(sender, user) where TSender : class {
public TSender Sender => (TSender)InternalSender;
}
public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender)
: HopFrameEventArgs<HopFrameTablePage>(sender) {
public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender, string user)
: HopFrameEventArgs<HopFrameTablePage>(sender, user) {
public required TableConfig Table { get; init; }
}
public abstract class HopFrameEditorEventArgs(HopFrameEditor sender)
: HopFrameEventArgs<HopFrameEditor>(sender) {
public abstract class HopFrameEditorEventArgs(HopFrameEditor sender, string user)
: HopFrameEventArgs<HopFrameEditor>(sender, user) {
public required TableConfig Table { get; init; }
}

View File

@@ -2,7 +2,7 @@
namespace HopFrame.Web.Plugins.Events;
public sealed class PageChangeEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class PageChangeEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required int CurrentPage { get; init; }
public required int TotalPages { get; init; }
public required int NewPage { get; set; }

View File

@@ -2,6 +2,6 @@
namespace HopFrame.Web.Plugins.Events;
public sealed class ReloadEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class ReloadEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
}

View File

@@ -3,7 +3,7 @@ using HopFrame.Web.Components.Pages;
namespace HopFrame.Web.Plugins.Events;
public sealed class SearchEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public sealed class SearchEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public required string SearchTerm { get; set; }
public required int CurrentPage { get; init; }
internal IEnumerable<object>? SearchResult { get; set; }

View File

@@ -4,7 +4,7 @@ using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Plugins.Events;
public class TableInitializedEvent(HopFrameTablePage sender) : HopFrameTablePageEventArgs(sender) {
public class TableInitializedEvent(HopFrameTablePage sender, string user) : HopFrameTablePageEventArgs(sender, user) {
public List<PluginButton> PluginButtons { get; } = new();
public DefaultButtonToggles DefaultButtons { get; set; } = new();
@@ -93,7 +93,7 @@ public enum PluginButtonPosition {
OnEntry = 2
}
public struct DefaultButtonToggles() {
public class DefaultButtonToggles {
public bool ShowRefreshButton { get; set; } = true;
public bool ShowAddEntityButton { get; set; } = true;
public bool ShowDeleteButton { get; set; } = true;

View File

@@ -3,7 +3,7 @@ using HopFrame.Web.Components.Dialogs;
namespace HopFrame.Web.Plugins.Events;
public sealed class ValidationEvent(HopFrameEditor sender) : HopFrameEditorEventArgs(sender) {
public sealed class ValidationEvent(HopFrameEditor sender, string user) : HopFrameEditorEventArgs(sender, user) {
public required IList<string> Errors { get; init; }
public required PropertyConfig Property { get; init; }
}

View File

@@ -12,7 +12,7 @@ using Microsoft.FluentUI.AspNetCore.Components;
namespace HopFrame.Web.Plugins.Internal;
internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService toasts, IFileService files) {
internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService toasts, IFileService files, IPrimaryKeyFinder finder) {
public const char Separator = ';';
[EventHandler]
@@ -51,7 +51,7 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
}
private async Task Import(TableConfig table, HopFrameTablePage target) {
var file = await files.UploadFile();
var file = await files.UploadFile(target);
var stream = file.OpenReadStream();
var reader = new StreamReader(stream);
@@ -178,17 +178,8 @@ internal sealed class ExporterPlugin(IContextExplorer explorer, IToastService to
}
private Type? GetPrimaryKeyType(Type tableType) {
var table = explorer.GetTable(tableType);
if (table?.ContextConfig is RepositoryGroupConfig repoConfig) {
return repoConfig.KeyProperty.PropertyType;
}
return tableType
.GetProperties()
.FirstOrDefault(prop => prop
.GetCustomAttributes(true)
.Any(attr => attr is KeyAttribute))?
.PropertyType;
var table = explorer.GetTable(tableType)!;
return finder.GetPrimaryKeyInfo(table)?.PropertyType;
}
private object? ParseString(string input, Type targetType) {

View File

@@ -1,6 +1,7 @@
using HopFrame.Web.Plugins.Annotations;
using HopFrame.Web.Plugins.Events;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace HopFrame.Web.Plugins.Internal;
@@ -24,15 +25,18 @@ internal sealed class PluginOrchestrator(IServiceProvider services) : IPluginOrc
Handler = method
};
collection.AddSingleton(container);
collection.AddScoped(plugin);
}
collection.AddScoped(plugin);
}
public async Task<TEvent> DispatchEvent<TEvent>(TEvent @event, CancellationToken ct = new()) where TEvent : HopFrameEventArgs {
var eventContainers = services.GetRequiredService<IEnumerable<PluginEventContainer>>()
.Where(container => container.EventType == typeof(TEvent));
.Where(container => container.EventType == @event.GetType())
.ToArray();
var eventType = typeof(TEvent);
var eventType = @event.GetType();
var tokenType = typeof(CancellationToken);
foreach (var container in eventContainers) {
var plugin = services.GetRequiredService(container.Handler.DeclaringType!);

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Components.Forms;
using HopFrame.Web.Components.Pages;
using Microsoft.AspNetCore.Components.Forms;
namespace HopFrame.Web.Services;
@@ -18,6 +19,6 @@ public interface IFileService {
/// Allows the user to upload a file and returns the uploaded file for processing.
/// </summary>
/// <returns>A task that returns an IBrowserFile representing the uploaded file.</returns>
public Task<IBrowserFile> UploadFile();
public Task<IBrowserFile> UploadFile(HopFrameTablePage page);
}

View File

@@ -12,19 +12,16 @@ internal sealed class FileService(IJSRuntime runtime) : IFileService {
await runtime.InvokeVoidAsync("downloadFileFromStream", name, stream);
}
public Task<IBrowserFile> UploadFile() {
public Task<IBrowserFile> UploadFile(HopFrameTablePage page) {
var result = new TaskCompletionSource<IBrowserFile>();
if (HopFrameTablePage.CurrentInstance is null)
result.SetException(new InvalidOperationException("No table page visible"));
HopFrameTablePage.CurrentInstance!.OnFileUpload = files => {
page.OnFileUpload = files => {
result.SetResult(files.First());
HopFrameTablePage.CurrentInstance.OnFileUpload = null;
page.OnFileUpload = null;
return Task.CompletedTask;
};
runtime.InvokeVoidAsync("triggerClick", HopFrameTablePage.CurrentInstance.FileInputElement!.Element);
runtime.InvokeVoidAsync("triggerClick", page.FileInputElement!.Element);
return result.Task;
}