diff --git a/.idea/.idea.HopFrame/.idea/workspace.xml b/.idea/.idea.HopFrame/.idea/workspace.xml
index 85f3cbc..646e170 100644
--- a/.idea/.idea.HopFrame/.idea/workspace.xml
+++ b/.idea/.idea.HopFrame/.idea/workspace.xml
@@ -11,7 +11,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -52,53 +76,13 @@
}
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
{}
{
@@ -128,7 +112,7 @@
"RunOnceActivity.git.unshallow": "true",
"b5f11219-dfc4-47a1-b02c-90ab603034fb.executor": "Debug",
"dcdf1689-dc07-47e4-8824-2e60a4fbf301.executor": "Debug",
- "git-widget-placeholder": "feature/test-reports",
+ "git-widget-placeholder": "!37 on feature/audit-logging",
"list.type.of.created.stylesheet": "CSS",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
@@ -267,14 +251,7 @@
-
-
-
- 1737208088933
-
-
-
- 1737208088933
+
@@ -660,7 +637,15 @@
1751750495636
-
+
+
+ 1751803366875
+
+
+
+ 1751803366876
+
+
@@ -690,7 +675,6 @@
-
@@ -715,6 +699,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/HopFrame.Core/ServiceCollectionExtensions.cs b/src/HopFrame.Core/ServiceCollectionExtensions.cs
index 0d9ead1..271978c 100644
--- a/src/HopFrame.Core/ServiceCollectionExtensions.cs
+++ b/src/HopFrame.Core/ServiceCollectionExtensions.cs
@@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions {
services.TryAddScoped();
services.TryAddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
diff --git a/src/HopFrame.Core/Services/IPrimaryKeyFinder.cs b/src/HopFrame.Core/Services/IPrimaryKeyFinder.cs
new file mode 100644
index 0000000..26ba1c8
--- /dev/null
+++ b/src/HopFrame.Core/Services/IPrimaryKeyFinder.cs
@@ -0,0 +1,8 @@
+using System.Reflection;
+using HopFrame.Core.Config;
+
+namespace HopFrame.Core.Services;
+
+public interface IPrimaryKeyFinder {
+ PropertyInfo? GetPrimaryKeyInfo(TableConfig config);
+}
\ No newline at end of file
diff --git a/src/HopFrame.Core/Services/Implementations/PrimaryKeyFinder.cs b/src/HopFrame.Core/Services/Implementations/PrimaryKeyFinder.cs
new file mode 100644
index 0000000..2f78c57
--- /dev/null
+++ b/src/HopFrame.Core/Services/Implementations/PrimaryKeyFinder.cs
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web/AuditLogging/AuditLogContext.cs b/src/HopFrame.Web/AuditLogging/AuditLogContext.cs
new file mode 100644
index 0000000..d6d35ea
--- /dev/null
+++ b/src/HopFrame.Web/AuditLogging/AuditLogContext.cs
@@ -0,0 +1,9 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace HopFrame.Web.AuditLogging;
+
+public sealed class AuditLogContext(DbContextOptions options) : DbContext(options) {
+
+ public DbSet AuditLog { get; set; }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web/AuditLogging/AuditLogEntry.cs b/src/HopFrame.Web/AuditLogging/AuditLogEntry.cs
new file mode 100644
index 0000000..14cf054
--- /dev/null
+++ b/src/HopFrame.Web/AuditLogging/AuditLogEntry.cs
@@ -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
+}
diff --git a/src/HopFrame.Web/AuditLogging/AuditLogPlugin.cs b/src/HopFrame.Web/AuditLogging/AuditLogPlugin.cs
new file mode 100644
index 0000000..e4f9716
--- /dev/null
+++ b/src/HopFrame.Web/AuditLogging/AuditLogPlugin.cs
@@ -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;
+ }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web/AuditLogging/ConfiguratorExtensions.cs b/src/HopFrame.Web/AuditLogging/ConfiguratorExtensions.cs
new file mode 100644
index 0000000..df2c6d6
--- /dev/null
+++ b/src/HopFrame.Web/AuditLogging/ConfiguratorExtensions.cs
@@ -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 optionsBuilder) {
+ services.AddDbContext(optionsBuilder);
+
+ configurator
+ .AddDbContext()
+ .Table(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();
+
+ return configurator;
+ }
+
+}
\ No newline at end of file
diff --git a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor
index 0302d9c..5b16e28 100644
--- a/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor
+++ b/src/HopFrame.Web/Components/Dialogs/HopFrameEditor.razor
@@ -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
diff --git a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
index f649f7e..8496c20 100644
--- a/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
+++ b/src/HopFrame.Web/Components/Pages/HopFrameTablePage.razor
@@ -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)))) {
@foreach (var button in _pluginButtons.Where(pb => pb.IsForTable(_config)).Where(pb => pb.Position == PluginButtonPosition.OnEntry)) {
@@ -226,10 +226,9 @@
private List _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(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!
diff --git a/src/HopFrame.Web/Plugins/Events/EntryEvent.cs b/src/HopFrame.Web/Plugins/Events/EntryEvent.cs
index 89ada2f..4188731 100644
--- a/src/HopFrame.Web/Plugins/Events/EntryEvent.cs
+++ b/src/HopFrame.Web/Plugins/Events/EntryEvent.cs
@@ -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; }
}
diff --git a/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs b/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs
index d15d8d5..d13fe4d 100644
--- a/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs
+++ b/src/HopFrame.Web/Plugins/Events/HopFrameEventArgs.cs
@@ -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 sender) : HopFrameEventArgs(sender) where TSender : class {
+public abstract class HopFrameEventArgs(TSender sender, string user) : HopFrameEventArgs(sender, user) where TSender : class {
public TSender Sender => (TSender)InternalSender;
}
-public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender)
- : HopFrameEventArgs(sender) {
+public abstract class HopFrameTablePageEventArgs(HopFrameTablePage sender, string user)
+ : HopFrameEventArgs(sender, user) {
public required TableConfig Table { get; init; }
}
-public abstract class HopFrameEditorEventArgs(HopFrameEditor sender)
- : HopFrameEventArgs(sender) {
+public abstract class HopFrameEditorEventArgs(HopFrameEditor sender, string user)
+ : HopFrameEventArgs(sender, user) {
public required TableConfig Table { get; init; }
}
diff --git a/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs b/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs
index 44570ed..3baf8b3 100644
--- a/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs
+++ b/src/HopFrame.Web/Plugins/Events/PageChangeEvent.cs
@@ -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; }
diff --git a/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs b/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs
index 384197e..9f5a679 100644
--- a/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs
+++ b/src/HopFrame.Web/Plugins/Events/ReloadEvent.cs
@@ -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) {
}
\ No newline at end of file
diff --git a/src/HopFrame.Web/Plugins/Events/SearchEvent.cs b/src/HopFrame.Web/Plugins/Events/SearchEvent.cs
index b012b10..e61bec7 100644
--- a/src/HopFrame.Web/Plugins/Events/SearchEvent.cs
+++ b/src/HopFrame.Web/Plugins/Events/SearchEvent.cs
@@ -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