From aa9ba5a6e489400f7d5f4acc03b7306fcac395a7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 08:17:08 +0000 Subject: [PATCH 1/5] Improve user management: remove custom audit log, fix bugs, enhance UX Remove the Admin module's custom audit logging (AuditService, AuditLogEntry, ActivityTimeline, activity tab) since the dedicated AuditLogs module now handles this automatically via HTTP middleware and entity change tracking. Bug fixes: - Add missing fields to AdminUserDto (twoFactorEnabled, accessFailedCount, lastLoginAt) that the frontend was rendering as undefined - Add password match validation on UsersCreate form - Prevent admins from locking/deactivating their own account (server + UI) - Surface validation errors on create user form via onError callback Performance: - Fix N+1 query in GetUsersPagedAsync by batch-loading roles with a single join query instead of per-user GetRolesAsync calls UX improvements: - Upgrade Users list to DataGridPage component (consistent with Roles page) - Add status filter (active/locked/deactivated) and role filter dropdowns - Show "You" badge and created date on user edit page - Show locked badge on user header when applicable --- .../AdminPermissions.cs | 1 - .../AuditLogEntryDto.cs | 12 - .../src/SimpleModule.Admin/AdminDbContext.cs | 22 -- .../src/SimpleModule.Admin/AdminModule.cs | 4 - .../Endpoints/Admin/AdminRolesEndpoint.cs | 80 +---- .../Endpoints/Admin/AdminUsersEndpoint.cs | 110 ++----- .../Entities/AuditLogEntry.cs | 11 - .../Entities/AuditLogEntryConfiguration.cs | 18 -- .../SimpleModule.Admin/Pages/Admin/Users.tsx | 284 ++++++++++-------- .../Pages/Admin/UsersCreate.tsx | 21 +- .../Pages/Admin/UsersEdit.tsx | 53 +--- .../Pages/components/ActivityTimeline.tsx | 76 ----- .../Services/AuditService.cs | 26 -- .../SimpleModule.Admin.csproj | 1 - .../Views/Admin/UsersActivityEndpoint.cs | 78 ----- .../Views/Admin/UsersEditEndpoint.cs | 55 +--- .../Views/Admin/UsersEndpoint.cs | 19 +- modules/Admin/src/SimpleModule.Admin/types.ts | 14 +- .../Integration/AdminPermissionsTests.cs | 4 +- .../Integration/AdminUsersEndpointTests.cs | 23 +- .../AdminUserDto.cs | 3 + .../IUserAdminContracts.cs | 8 +- .../SimpleModule.Users/UserAdminService.cs | 68 ++++- 23 files changed, 329 insertions(+), 662 deletions(-) delete mode 100644 modules/Admin/src/SimpleModule.Admin.Contracts/AuditLogEntryDto.cs delete mode 100644 modules/Admin/src/SimpleModule.Admin/AdminDbContext.cs delete mode 100644 modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntry.cs delete mode 100644 modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntryConfiguration.cs delete mode 100644 modules/Admin/src/SimpleModule.Admin/Pages/components/ActivityTimeline.tsx delete mode 100644 modules/Admin/src/SimpleModule.Admin/Services/AuditService.cs delete mode 100644 modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs diff --git a/modules/Admin/src/SimpleModule.Admin.Contracts/AdminPermissions.cs b/modules/Admin/src/SimpleModule.Admin.Contracts/AdminPermissions.cs index 028fde7d..3ac752c1 100644 --- a/modules/Admin/src/SimpleModule.Admin.Contracts/AdminPermissions.cs +++ b/modules/Admin/src/SimpleModule.Admin.Contracts/AdminPermissions.cs @@ -6,5 +6,4 @@ public sealed class AdminPermissions : IModulePermissions { public const string ManageUsers = "Admin.ManageUsers"; public const string ManageRoles = "Admin.ManageRoles"; - public const string ViewAuditLog = "Admin.ViewAuditLog"; } diff --git a/modules/Admin/src/SimpleModule.Admin.Contracts/AuditLogEntryDto.cs b/modules/Admin/src/SimpleModule.Admin.Contracts/AuditLogEntryDto.cs deleted file mode 100644 index 89032ec1..00000000 --- a/modules/Admin/src/SimpleModule.Admin.Contracts/AuditLogEntryDto.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace SimpleModule.Admin.Contracts; - -public class AuditLogEntryDto -{ - public long Id { get; set; } - public string UserId { get; set; } = string.Empty; - public string PerformedByUserId { get; set; } = string.Empty; - public string PerformedByName { get; set; } = string.Empty; - public string Action { get; set; } = string.Empty; - public string? Details { get; set; } - public DateTimeOffset Timestamp { get; set; } -} diff --git a/modules/Admin/src/SimpleModule.Admin/AdminDbContext.cs b/modules/Admin/src/SimpleModule.Admin/AdminDbContext.cs deleted file mode 100644 index 7b72ff34..00000000 --- a/modules/Admin/src/SimpleModule.Admin/AdminDbContext.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Options; -using SimpleModule.Admin.Contracts; -using SimpleModule.Admin.Entities; -using SimpleModule.Database; - -namespace SimpleModule.Admin; - -public class AdminDbContext( - DbContextOptions options, - IOptions dbOptions -) : DbContext(options) -{ - public DbSet AuditLogEntries => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - modelBuilder.ApplyConfigurationsFromAssembly(typeof(AdminDbContext).Assembly); - modelBuilder.ApplyModuleSchema(AdminConstants.ModuleName, dbOptions.Value); - } -} diff --git a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs index 79cadd4a..bb508314 100644 --- a/modules/Admin/src/SimpleModule.Admin/AdminModule.cs +++ b/modules/Admin/src/SimpleModule.Admin/AdminModule.cs @@ -1,10 +1,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SimpleModule.Admin.Contracts; -using SimpleModule.Admin.Services; using SimpleModule.Core; using SimpleModule.Core.Menu; -using SimpleModule.Database; namespace SimpleModule.Admin; @@ -13,8 +11,6 @@ public class AdminModule : IModule { public void ConfigureServices(IServiceCollection services, IConfiguration configuration) { - services.AddModuleDbContext(configuration, AdminConstants.ModuleName); - services.AddScoped(); } public void ConfigureMenu(IMenuBuilder menus) diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs index d61a8a90..bfd27de5 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminRolesEndpoint.cs @@ -1,9 +1,7 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using SimpleModule.Admin.Services; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; using SimpleModule.Users.Contracts; @@ -27,8 +25,7 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string? description, HttpContext context, IRoleAdminContracts roleAdmin, - IPermissionContracts permissionContracts, - AuditService audit + IPermissionContracts permissionContracts ) => { var trimmedName = name.Trim(); @@ -52,15 +49,6 @@ await permissionContracts.SetPermissionsForRoleAsync( ); } - var adminUserId = - context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await audit.LogAsync( - role.Id, - adminUserId, - "RoleCreated", - $"Role '{trimmedName}' created" - ); - return TypedResults.Redirect($"/admin/roles/{role.Id}/edit"); } ) @@ -74,9 +62,7 @@ async Task ( string id, [FromForm] string name, [FromForm] string? description, - HttpContext context, - IRoleAdminContracts roleAdmin, - AuditService audit + IRoleAdminContracts roleAdmin ) => { var role = await roleAdmin.GetRoleByIdAsync(id); @@ -85,20 +71,7 @@ AuditService audit var trimmedName = name.Trim(); var trimmedDescription = description?.Trim() is { Length: > 0 } d ? d : null; - await roleAdmin.UpdateRoleAsync( - id, - trimmedName, - trimmedDescription - ); - - var adminUserId = - context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await audit.LogAsync( - id, - adminUserId, - "RoleUpdated", - $"Role '{trimmedName}' updated" - ); + await roleAdmin.UpdateRoleAsync(id, trimmedName, trimmedDescription); return TypedResults.Redirect($"/admin/roles/{id}/edit?tab=details"); } @@ -113,8 +86,7 @@ async Task ( string id, HttpContext context, IRoleAdminContracts roleAdmin, - IPermissionContracts permissionContracts, - AuditService audit + IPermissionContracts permissionContracts ) => { var role = await roleAdmin.GetRoleByIdAsync(id); @@ -128,37 +100,6 @@ AuditService audit .ToHashSet(StringComparer.Ordinal); var roleId = RoleId.From(id); - var currentPermissions = await permissionContracts.GetPermissionsForRoleAsync( - roleId - ); - - var adminUserId = - context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - - // Audit removed permissions - foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) - { - await audit.LogAsync( - id, - adminUserId, - "RolePermissionRemoved", - $"Permission '{perm}' removed from role '{role.Name}'" - ); - } - - // Audit added permissions - foreach ( - var perm in newPermissions.Where(p => !currentPermissions.Contains(p)) - ) - { - await audit.LogAsync( - id, - adminUserId, - "RolePermissionAdded", - $"Permission '{perm}' added to role '{role.Name}'" - ); - } - await permissionContracts.SetPermissionsForRoleAsync(roleId, newPermissions); return TypedResults.Redirect($"/admin/roles/{id}/edit?tab=permissions"); @@ -171,10 +112,8 @@ await audit.LogAsync( "/{id}", async Task ( string id, - HttpContext context, IRoleAdminContracts roleAdmin, - IPermissionContracts permissionContracts, - AuditService audit + IPermissionContracts permissionContracts ) => { var role = await roleAdmin.GetRoleByIdAsync(id); @@ -191,15 +130,6 @@ AuditService audit var roleId = RoleId.From(id); await permissionContracts.SetPermissionsForRoleAsync(roleId, []); - var adminUserId = - context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await audit.LogAsync( - id, - adminUserId, - "RoleDeleted", - $"Role '{role.Name}' deleted" - ); - await roleAdmin.DeleteRoleAsync(id); return TypedResults.Redirect("/admin/roles"); diff --git a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs index c05a5dd9..39737bb0 100644 --- a/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminUsersEndpoint.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; -using SimpleModule.Admin.Services; using SimpleModule.Core; using SimpleModule.Permissions.Contracts; using SimpleModule.Users.Contracts; @@ -28,12 +27,9 @@ public void Map(IEndpointRouteBuilder app) [FromForm] string password, [FromForm] bool emailConfirmed, HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var form = await context.Request.ReadFormAsync(); var filteredRoles = form["roles"] .Where(r => !string.IsNullOrEmpty(r)) @@ -51,8 +47,6 @@ AuditService audit var user = await userAdmin.CreateUserWithPasswordAsync(request); - await audit.LogAsync(user.Id, adminId, "UserCreated", $"Created user {email}"); - return TypedResults.Redirect($"/admin/users/{user.Id}/edit"); } ); @@ -65,13 +59,9 @@ async Task ( [FromForm] string displayName, [FromForm] string email, [FromForm] string? emailConfirmed, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var request = new UpdateAdminUserRequest { DisplayName = displayName, @@ -80,12 +70,6 @@ AuditService audit }; await userAdmin.UpdateUserDetailsAsync(UserId.From(id), request); - await audit.LogAsync( - id, - adminId, - "UserUpdated", - $"Updated user details for {email}" - ); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details"); } @@ -97,12 +81,9 @@ await audit.LogAsync( async Task ( string id, HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var form = await context.Request.ReadFormAsync(); var newRoles = form["roles"] .Where(r => !string.IsNullOrEmpty(r)) @@ -110,12 +91,6 @@ AuditService audit .ToList(); await userAdmin.SetUserRolesAsync(UserId.From(id), newRoles); - await audit.LogAsync( - id, - adminId, - "RolesUpdated", - $"Set roles to [{string.Join(", ", newRoles)}]" - ); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=roles"); } @@ -127,11 +102,9 @@ await audit.LogAsync( async Task ( string id, HttpContext context, - IPermissionContracts permissionContracts, - AuditService audit + IPermissionContracts permissionContracts ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; var userId = UserId.From(id); var form = await context.Request.ReadFormAsync(); @@ -140,30 +113,6 @@ AuditService audit .Select(p => p!) .ToHashSet(); - var currentPermissions = await permissionContracts.GetPermissionsForUserAsync( - userId - ); - - foreach (var perm in currentPermissions.Where(p => !newPermissions.Contains(p))) - { - await audit.LogAsync( - id, - adminId, - "PermissionRevoked", - $"Revoked permission {perm}" - ); - } - - foreach (var perm in newPermissions.Where(p => !currentPermissions.Contains(p))) - { - await audit.LogAsync( - id, - adminId, - "PermissionGranted", - $"Granted permission {perm}" - ); - } - await permissionContracts.SetPermissionsForUserAsync(userId, newPermissions); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=roles"); @@ -176,15 +125,10 @@ await audit.LogAsync( async Task ( string id, [FromForm] string newPassword, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await userAdmin.ResetPasswordAsync(UserId.From(id), newPassword); - await audit.LogAsync(id, adminId, "PasswordReset"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); } @@ -196,14 +140,16 @@ AuditService audit async Task ( string id, HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + if (id == adminId) + return TypedResults.BadRequest( + new { error = "You cannot lock your own account." } + ); await userAdmin.LockAccountAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "AccountLocked"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); } @@ -214,15 +160,10 @@ AuditService audit "/{id}/unlock", async Task ( string id, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await userAdmin.UnlockAccountAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "AccountUnlocked"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); } @@ -233,15 +174,10 @@ AuditService audit "/{id}/force-reverify", async Task ( string id, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await userAdmin.ForceEmailReverificationAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "EmailReverified"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); } @@ -252,15 +188,10 @@ AuditService audit "/{id}/disable-2fa", async Task ( string id, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await userAdmin.DisableTwoFactorAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "TwoFactorDisabled"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=security"); } @@ -272,14 +203,16 @@ AuditService audit async Task ( string id, HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; + if (id == adminId) + return TypedResults.BadRequest( + new { error = "You cannot deactivate your own account." } + ); await userAdmin.DeactivateAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "UserDeactivated"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details"); } @@ -290,15 +223,10 @@ AuditService audit "/{id}/reactivate", async Task ( string id, - HttpContext context, - IUserAdminContracts userAdmin, - AuditService audit + IUserAdminContracts userAdmin ) => { - var adminId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - await userAdmin.ReactivateAsync(UserId.From(id)); - await audit.LogAsync(id, adminId, "UserReactivated"); return TypedResults.Redirect($"/admin/users/{id}/edit?tab=details"); } diff --git a/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntry.cs b/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntry.cs deleted file mode 100644 index 36c3425a..00000000 --- a/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntry.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SimpleModule.Admin.Entities; - -public class AuditLogEntry -{ - public long Id { get; set; } - public string UserId { get; set; } = string.Empty; - public string PerformedByUserId { get; set; } = string.Empty; - public string Action { get; set; } = string.Empty; - public string? Details { get; set; } - public DateTimeOffset Timestamp { get; set; } = DateTimeOffset.UtcNow; -} diff --git a/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntryConfiguration.cs b/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntryConfiguration.cs deleted file mode 100644 index d80de436..00000000 --- a/modules/Admin/src/SimpleModule.Admin/Entities/AuditLogEntryConfiguration.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace SimpleModule.Admin.Entities; - -public class AuditLogEntryConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.HasKey(e => e.Id); - builder.Property(e => e.UserId).IsRequired(); - builder.Property(e => e.PerformedByUserId).IsRequired(); - builder.Property(e => e.Action).IsRequired().HasMaxLength(100); - builder.Property(e => e.Details).HasMaxLength(4000); - builder.HasIndex(e => e.UserId); - builder.HasIndex(e => e.Timestamp); - } -} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx index 31bec12d..0c45175a 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx @@ -2,8 +2,8 @@ import { router } from '@inertiajs/react'; import { Badge, Button, + DataGridPage, Input, - PageShell, Table, TableBody, TableCell, @@ -30,6 +30,9 @@ interface Props { page: number; totalPages: number; totalCount: number; + allRoles: string[]; + filterStatus: string; + filterRole: string; } function userStatus(user: User) { @@ -38,149 +41,180 @@ function userStatus(user: User) { return { label: 'Active', variant: 'success' as const }; } -export default function Users({ users, search, page, totalPages, totalCount }: Props) { +export default function Users({ + users, + search, + page, + totalPages, + totalCount, + allRoles, + filterStatus, + filterRole, +}: Props) { const [searchValue, setSearchValue] = useState(search); + function navigate(params: Record) { + router.get( + '/admin/users', + { search: searchValue, page: 1, filterStatus, filterRole, ...params }, + { preserveState: true }, + ); + } + function handleSearch(e: FormEvent) { e.preventDefault(); - router.get('/admin/users', { search: searchValue, page: 1 }, { preserveState: true }); + navigate({ search: searchValue }); } function goToPage(p: number) { - router.get('/admin/users', { search: searchValue, page: p }, { preserveState: true }); + router.get( + '/admin/users', + { search: searchValue, page: p, filterStatus, filterRole }, + { preserveState: true }, + ); } - return ( - router.get('/admin/users/create')}>Create User} - > -
+ const filterBar = ( +
+ setSearchValue(e.target.value)} placeholder="Search by name or email..." className="flex-1" /> - + + + {allRoles.length > 0 && ( + + )} +
+ ); - - - - Name - Email - Roles - Status - Created - - - - - {users.map((user) => { - const status = userStatus(user); - return ( - - {user.displayName || '\u2014'} - - {user.email} - {!user.emailConfirmed && ( - - unverified - - )} - - -
- {user.roles.map((role) => ( - - {role} - - ))} -
-
- - {status.label} - - - {new Date(user.createdAt).toLocaleDateString()} - - - - + return ( + router.get('/admin/users/create')}>Create User} + data={users} + filterBar={filterBar} + emptyTitle="No users found" + emptyDescription={ + search ? `No users matching "${search}".` : 'Get started by creating your first user.' + } + emptyAction={ + !search ? ( + + ) : undefined + } + > + {(pageData) => ( + <> +
+ + + Name + Email + Roles + Status + Created + - ); - })} - -
- - {users.length === 0 && search && ( -

- No users found matching “{search}”. -

- )} + + + {pageData.map((user) => { + const status = userStatus(user); + return ( + + {user.displayName || '\u2014'} + + {user.email} + {!user.emailConfirmed && ( + + unverified + + )} + + +
+ {user.roles.map((role) => ( + + {role} + + ))} +
+
+ + {status.label} + + + {new Date(user.createdAt).toLocaleDateString()} + + + + +
+ ); + })} +
+ - {totalPages > 1 && ( -
- - Showing page {page} of {totalPages} ({totalCount} users) - -
- - {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( - - ))} - -
-
+ {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + )} -
+ ); } diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersCreate.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersCreate.tsx index 4a57a363..5870ec1f 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersCreate.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersCreate.tsx @@ -16,6 +16,7 @@ import { Input, Label, } from '@simplemodule/ui'; +import { useState } from 'react'; interface Role { id: string; @@ -28,10 +29,23 @@ interface Props { } export default function UsersCreate({ allRoles }: Props) { + const [formError, setFormError] = useState(null); + function handleSubmit(e: React.FormEvent) { e.preventDefault(); const formData = new FormData(e.currentTarget); - router.post('/admin/users', formData); + if (formData.get('password') !== formData.get('confirmPassword')) { + setFormError('Passwords do not match.'); + return; + } + setFormError(null); + router.post('/admin/users', formData, { + onError: () => { + setFormError( + 'Failed to create user. The email may already be in use or the password does not meet requirements.', + ); + }, + }); } return ( @@ -53,6 +67,11 @@ export default function UsersCreate({ allRoles }: Props) {
+ {formError && ( +
+ {formError} +
+ )} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx index 257e069b..02f0e306 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx @@ -26,7 +26,6 @@ import { Label, } from '@simplemodule/ui'; import { useState } from 'react'; -import { ActivityTimeline } from '../components/ActivityTimeline'; import { PermissionGroups } from '../components/PermissionGroups'; import { TabNav } from '../components/TabNav'; @@ -49,30 +48,20 @@ interface Role { description: string | null; } -interface ActivityEntry { - id: number; - action: string; - details: string | null; - performedBy: string; - timestamp: string; -} - interface Props { user: UserDetail; userRoles: string[]; userPermissions: string[]; allRoles: Role[]; permissionsByModule: Record; - activityLog: ActivityEntry[]; - activityTotal: number; tab: string; + currentUserId: string; } const tabs = [ { id: 'details', label: 'Details' }, { id: 'roles', label: 'Roles & Permissions' }, { id: 'security', label: 'Security' }, - { id: 'activity', label: 'Activity' }, ]; type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | null; @@ -83,22 +72,13 @@ export default function UsersEdit({ userPermissions, allRoles, permissionsByModule, - activityLog, - activityTotal, tab, + currentUserId, }: Props) { - const [activityEntries, setActivityEntries] = useState(activityLog); - const [activityPage, setActivityPage] = useState(1); const [confirmAction, setConfirmAction] = useState(null); const [passwordError, setPasswordError] = useState(null); - async function loadMoreActivity() { - const nextPage = activityPage + 1; - const res = await fetch(`/admin/users/${user.id}/activity?page=${nextPage}`); - const data = await res.json(); - setActivityEntries((prev) => [...prev, ...data.entries]); - setActivityPage(nextPage); - } + const isSelf = user.id === currentUserId; function handleConfirmAction() { switch (confirmAction) { @@ -153,7 +133,9 @@ export default function UsersEdit({

Edit User

+ {isSelf && You} {user.isDeactivated && Deactivated} + {user.isLockedOut && !user.isDeactivated && Locked}
@@ -212,6 +194,8 @@ export default function UsersEdit({ Reactivate Account + ) : isSelf ? ( +

You cannot deactivate your own account.

) : (

@@ -348,6 +332,8 @@ export default function UsersEdit({ Unlock Account

+ ) : isSelf ? ( +

You cannot lock your own account.

) : (

This account is active.

@@ -410,27 +396,18 @@ export default function UsersEdit({ {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}
+
+ Created: + + {new Date(user.createdAt).toLocaleString()} + +
)} - {tab === 'activity' && ( - - - Activity Log - - - - - - )} - !open && setConfirmAction(null)} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/components/ActivityTimeline.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/components/ActivityTimeline.tsx deleted file mode 100644 index 29d45c25..00000000 --- a/modules/Admin/src/SimpleModule.Admin/Pages/components/ActivityTimeline.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Button } from '@simplemodule/ui'; - -interface ActivityEntry { - id: number; - action: string; - details: string | null; - performedBy: string; - timestamp: string; -} - -interface ActivityTimelineProps { - entries: ActivityEntry[]; - total: number; - onLoadMore?: () => void; -} - -const actionLabels: Record = { - UserCreated: 'User created', - UserUpdated: 'Details updated', - UserDeactivated: 'Account deactivated', - UserReactivated: 'Account reactivated', - RoleAdded: 'Role added', - RoleRemoved: 'Role removed', - PermissionGranted: 'Permission granted', - PermissionRevoked: 'Permission revoked', - PasswordReset: 'Password reset', - AccountLocked: 'Account locked', - AccountUnlocked: 'Account unlocked', - EmailReverified: 'Email re-verification required', - TwoFactorDisabled: '2FA disabled', -}; - -function timeAgo(timestamp: string): string { - const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return new Date(timestamp).toLocaleDateString(); -} - -export function ActivityTimeline({ entries, total, onLoadMore }: ActivityTimelineProps) { - if (entries.length === 0) { - return

No activity recorded.

; - } - - return ( -
-
- {entries.map((entry) => ( -
-
-

- {actionLabels[entry.action] ?? entry.action} - {entry.details && ( - — {entry.details} - )} -

-

- by {entry.performedBy} · {timeAgo(entry.timestamp)} -

-
-
- ))} -
- {entries.length < total && onLoadMore && ( - - )} -
- ); -} diff --git a/modules/Admin/src/SimpleModule.Admin/Services/AuditService.cs b/modules/Admin/src/SimpleModule.Admin/Services/AuditService.cs deleted file mode 100644 index 6a3cbd5b..00000000 --- a/modules/Admin/src/SimpleModule.Admin/Services/AuditService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using SimpleModule.Admin.Entities; - -namespace SimpleModule.Admin.Services; - -public class AuditService(AdminDbContext db) -{ - public async Task LogAsync( - string userId, - string performedByUserId, - string action, - string? details = null - ) - { - db.AuditLogEntries.Add( - new AuditLogEntry - { - UserId = userId, - PerformedByUserId = performedByUserId, - Action = action, - Details = details, - Timestamp = DateTimeOffset.UtcNow, - } - ); - await db.SaveChangesAsync(); - } -} diff --git a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj index bcdfbdc3..29e6a69c 100644 --- a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj +++ b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj @@ -5,7 +5,6 @@ - diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs deleted file mode 100644 index 90c27c4e..00000000 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersActivityEndpoint.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; -using SimpleModule.Core; -using SimpleModule.Users.Contracts; - -namespace SimpleModule.Admin.Views.Admin; - -public class UsersActivityEndpoint : IEndpoint -{ - private const int PageSize = 20; - - public void Map(IEndpointRouteBuilder app) - { - app.MapGet( - "/admin/users/{id}/activity", - async ( - string id, - AdminDbContext adminDb, - IUserAdminContracts userAdmin, - int page = 1 - ) => - { - var entries = await adminDb - .AuditLogEntries.Where(e => e.UserId == id) - .OrderByDescending(e => e.Timestamp) - .Skip((page - 1) * PageSize) - .Take(PageSize) - .Select(e => new - { - e.Id, - e.Action, - e.Details, - e.PerformedByUserId, - e.Timestamp, - }) - .ToListAsync(); - - var performerIds = entries.Select(e => e.PerformedByUserId).Distinct().ToList(); - var performers = new Dictionary(); - foreach (var performerId in performerIds) - { - var performer = await userAdmin.GetAdminUserByIdAsync( - UserId.From(performerId) - ); - if (performer is not null) - { - performers[performerId] = performer.DisplayName; - } - } - - var total = await adminDb.AuditLogEntries.CountAsync(e => e.UserId == id); - - return TypedResults.Ok( - new - { - entries = entries.Select(e => new - { - e.Id, - e.Action, - e.Details, - performedBy = performers.GetValueOrDefault( - e.PerformedByUserId, - "Unknown" - ), - timestamp = e.Timestamp.ToString("O"), - }), - total, - page, - totalPages = (int)Math.Ceiling((double)total / PageSize), - } - ); - } - ) - .RequireAuthorization(policy => policy.RequireRole("Admin")); - } -} diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs index 93f5a9fb..f3aef998 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -1,7 +1,7 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.EntityFrameworkCore; using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; @@ -13,18 +13,16 @@ namespace SimpleModule.Admin.Views.Admin; [ViewPage("Admin/Admin/UsersEdit")] public class UsersEditEndpoint : IViewEndpoint { - private const int ActivityPageSize = 20; - public void Map(IEndpointRouteBuilder app) { app.MapGet( "/users/{id}/edit", async ( string id, + HttpContext context, IUserAdminContracts userAdmin, IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, - AdminDbContext adminDb, PermissionRegistry permissionRegistry, string? tab ) => @@ -46,50 +44,8 @@ await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) kvp => kvp.Value.ToList() ); - // Activity log (first page) - var activityLog = await adminDb - .AuditLogEntries.Where(e => e.UserId == id) - .OrderByDescending(e => e.Timestamp) - .Take(ActivityPageSize) - .Select(e => new - { - e.Id, - e.Action, - e.Details, - e.PerformedByUserId, - e.Timestamp, - }) - .ToListAsync(); - - // Resolve performer names for activity entries - var performerIds = activityLog - .Select(e => e.PerformedByUserId) - .Distinct() - .ToList(); - var performers = new Dictionary(); - foreach (var performerId in performerIds) - { - var performer = await userAdmin.GetAdminUserByIdAsync( - UserId.From(performerId) - ); - if (performer is not null) - { - performers[performerId] = performer.DisplayName; - } - } - - var activityWithNames = activityLog.Select(e => new - { - e.Id, - e.Action, - e.Details, - performedBy = performers.GetValueOrDefault(e.PerformedByUserId, "Unknown"), - timestamp = e.Timestamp.ToString("O"), - }); - - var activityTotal = await adminDb.AuditLogEntries.CountAsync(e => - e.UserId == id - ); + var currentUserId = + context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; return Inertia.Render( "Admin/Admin/UsersEdit", @@ -100,9 +56,8 @@ await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) userPermissions, allRoles, permissionsByModule, - activityLog = activityWithNames, - activityTotal, tab = tab ?? "details", + currentUserId, } ); } diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index 329d696a..c060a7d3 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -16,13 +16,27 @@ public void Map(IEndpointRouteBuilder app) "/users", async ( IUserAdminContracts userAdmin, + IRoleAdminContracts roleAdmin, IOptions options, string? search, + string? filterStatus, + string? filterRole, int page = 1 ) => { var pageSize = options.Value.UsersPageSize; - var result = await userAdmin.GetUsersPagedAsync(search, page, pageSize); + var usersTask = userAdmin.GetUsersPagedAsync( + search, + page, + pageSize, + filterStatus, + filterRole + ); + var rolesTask = roleAdmin.GetAllRolesAsync(); + await Task.WhenAll(usersTask, rolesTask); + + var result = usersTask.Result; + var allRoles = rolesTask.Result; var totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize); return Inertia.Render( @@ -34,6 +48,9 @@ public void Map(IEndpointRouteBuilder app) page = result.Page, totalPages, totalCount = result.TotalCount, + allRoles = allRoles.Select(r => r.Name).ToList(), + filterStatus = filterStatus ?? "", + filterRole = filterRole ?? "", } ); } diff --git a/modules/Admin/src/SimpleModule.Admin/types.ts b/modules/Admin/src/SimpleModule.Admin/types.ts index 140b48c4..5c316e30 100644 --- a/modules/Admin/src/SimpleModule.Admin/types.ts +++ b/modules/Admin/src/SimpleModule.Admin/types.ts @@ -1,14 +1,2 @@ // Auto-generated from [Dto] types — do not edit -export interface AdminPermissions { -} - -export interface AuditLogEntryDto { - id: number; - userId: string; - performedByUserId: string; - performedByName: string; - action: string; - details: string; - timestamp: string; -} - +export type AdminPermissions = {}; diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs index 97ab622e..c889f305 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminPermissionsTests.cs @@ -91,7 +91,7 @@ public async Task CreateRoleWithPermissions_AssignsPermissions() new KeyValuePair("name", roleName), new KeyValuePair("description", "Test"), new KeyValuePair("permissions", "Admin.ManageUsers"), - new KeyValuePair("permissions", "Admin.ViewAuditLog"), + new KeyValuePair("permissions", "Admin.ManageRoles"), } ); @@ -107,7 +107,7 @@ public async Task CreateRoleWithPermissions_AssignsPermissions() var perms = await permContracts.GetPermissionsForRoleAsync(RoleId.From(role!.Id)); perms.Should().HaveCount(2); perms.Should().Contain("Admin.ManageUsers"); - perms.Should().Contain("Admin.ViewAuditLog"); + perms.Should().Contain("Admin.ManageRoles"); } [Fact] diff --git a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs index 088bd276..f49af68f 100644 --- a/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs +++ b/modules/Admin/tests/SimpleModule.Admin.Tests/Integration/AdminUsersEndpointTests.cs @@ -88,7 +88,7 @@ public async Task GetUsersCreate_AsAdmin_Returns200() } [Fact( - Skip = "UsersEditEndpoint depends on PermissionRegistry and AdminDbContext which require full module initialization in test setup" + Skip = "UsersEditEndpoint depends on PermissionRegistry which requires full module initialization in test setup" )] public async Task GetUsersEdit_ExistingUser_Returns200() { @@ -190,16 +190,23 @@ public async Task ResetPassword_ValidData_Redirects() response.StatusCode.Should().Be(HttpStatusCode.Redirect); } - [Fact( - Skip = "UsersActivityEndpoint depends on AdminDbContext which requires full module initialization in test setup" - )] - public async Task GetActivity_ValidUser_Returns200() + [Fact] + public async Task LockUser_Self_ReturnsBadRequest() { - var userId = await SeedTestUserAsync(); var client = CreateAdminClient(); - var response = await client.GetAsync($"/admin/users/{userId}/activity"); + var response = await client.PostAsync("/admin/users/admin-test-id/lock", null); - response.StatusCode.Should().Be(HttpStatusCode.OK); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task DeactivateUser_Self_ReturnsBadRequest() + { + var client = CreateAdminClient(); + + var response = await client.PostAsync("/admin/users/admin-test-id/deactivate", null); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } } diff --git a/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs index 74f74337..ddc7826e 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/AdminUserDto.cs @@ -9,8 +9,11 @@ public class AdminUserDto public string DisplayName { get; set; } = string.Empty; public string? Email { get; set; } public bool EmailConfirmed { get; set; } + public bool TwoFactorEnabled { get; set; } public List Roles { get; set; } = []; public bool IsLockedOut { get; set; } public bool IsDeactivated { get; set; } + public int AccessFailedCount { get; set; } public string CreatedAt { get; set; } = string.Empty; + public string? LastLoginAt { get; set; } } diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs index d5cfd9d7..3844bfba 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/IUserAdminContracts.cs @@ -4,7 +4,13 @@ namespace SimpleModule.Users.Contracts; public interface IUserAdminContracts { - Task> GetUsersPagedAsync(string? search, int page, int pageSize); + Task> GetUsersPagedAsync( + string? search, + int page, + int pageSize, + string? filterStatus = null, + string? filterRole = null + ); Task GetAdminUserByIdAsync(UserId id); Task CreateUserWithPasswordAsync(CreateAdminUserRequest request); Task UpdateUserDetailsAsync(UserId id, UpdateAdminUserRequest request); diff --git a/modules/Users/src/SimpleModule.Users/UserAdminService.cs b/modules/Users/src/SimpleModule.Users/UserAdminService.cs index 31391cbe..6e8c5f0c 100644 --- a/modules/Users/src/SimpleModule.Users/UserAdminService.cs +++ b/modules/Users/src/SimpleModule.Users/UserAdminService.cs @@ -7,13 +7,16 @@ namespace SimpleModule.Users; public sealed class UserAdminService( - UserManager userManager + UserManager userManager, + UsersDbContext db ) : IUserAdminContracts { public async Task> GetUsersPagedAsync( string? search, int page, - int pageSize + int pageSize, + string? filterStatus = null, + string? filterRole = null ) { var query = userManager.Users.AsQueryable(); @@ -28,6 +31,44 @@ int pageSize ); } + // Status filter + var now = DateTimeOffset.UtcNow; + query = filterStatus switch + { + "active" => query.Where(u => + u.DeactivatedAt == null + && (!u.LockoutEnd.HasValue || u.LockoutEnd <= now) + ), + "locked" => query.Where(u => + u.DeactivatedAt == null && u.LockoutEnd.HasValue && u.LockoutEnd > now + ), + "deactivated" => query.Where(u => u.DeactivatedAt != null), + _ => query, + }; + + // Role filter + if (!string.IsNullOrWhiteSpace(filterRole)) + { + var roleId = await db.Roles + .Where(r => r.Name == filterRole) + .Select(r => r.Id) + .FirstOrDefaultAsync(); + + if (roleId is not null) + { + var userIdsInRole = db.UserRoles + .Where(ur => ur.RoleId == roleId) + .Select(ur => ur.UserId); + + query = query.Where(u => userIdsInRole.Contains(u.Id)); + } + else + { + // Role doesn't exist — return empty + query = query.Where(u => false); + } + } + var totalCount = await query.CountAsync(); var totalPages = (int)Math.Ceiling((double)totalCount / pageSize); page = Math.Clamp(page, 1, Math.Max(1, totalPages)); @@ -38,12 +79,20 @@ int pageSize .Take(pageSize) .ToListAsync(); - var items = new List(users.Count); - foreach (var user in users) - { - var roles = await userManager.GetRolesAsync(user); - items.Add(MapToAdminDto(user, roles.ToList())); - } + // Batch-load roles for all users in a single query instead of N+1 + var userIds = users.Select(u => u.Id).ToList(); + var userRoles = await db.UserRoles + .Where(ur => userIds.Contains(ur.UserId)) + .Join(db.Roles, ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .ToListAsync(); + + var rolesByUserId = userRoles + .GroupBy(x => x.UserId) + .ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList()); + + var items = users + .Select(u => MapToAdminDto(u, rolesByUserId.GetValueOrDefault(u.Id, []))) + .ToList(); return new PagedResult { @@ -206,9 +255,12 @@ private static AdminUserDto MapToAdminDto(ApplicationUser user, List rol DisplayName = user.DisplayName, Email = user.Email, EmailConfirmed = user.EmailConfirmed, + TwoFactorEnabled = user.TwoFactorEnabled, Roles = roles, IsLockedOut = user.LockoutEnd.HasValue && user.LockoutEnd > DateTimeOffset.UtcNow, IsDeactivated = user.DeactivatedAt.HasValue, + AccessFailedCount = user.AccessFailedCount, CreatedAt = user.CreatedAt.ToString("O"), + LastLoginAt = user.LastLoginAt?.ToString("O"), }; } From 0f431645078c6787dd828f36bb404555740cff8b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 08:34:52 +0000 Subject: [PATCH 2/5] Simplify: use design system Select, parallelize DB calls, reduce redundancy - Replace native navigate({ filterStatus: e.target.value })} - className="h-9 rounded-md border border-input bg-background px-3 text-sm" + + + + + + All statuses + Active + Locked + Deactivated + + {allRoles.length > 0 && ( - navigate({ filterRole: v === '__all__' ? '' : v })} > - - {allRoles.map((role) => ( - - ))} - + + + + + All roles + {allRoles.map((role) => ( + + {role} + + ))} + + )} ); @@ -198,7 +203,7 @@ export default function Users({ variant="ghost" size="sm" disabled={page <= 1} - onClick={() => goToPage(page - 1)} + onClick={() => navigate({ page: page - 1 })} > Previous @@ -206,7 +211,7 @@ export default function Users({ variant="ghost" size="sm" disabled={page >= totalPages} - onClick={() => goToPage(page + 1)} + onClick={() => navigate({ page: page + 1 })} > Next diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx index 02f0e306..547cff86 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx @@ -35,6 +35,7 @@ interface UserDetail { email: string; emailConfirmed: boolean; twoFactorEnabled: boolean; + roles: string[]; isLockedOut: boolean; isDeactivated: boolean; accessFailedCount: number; @@ -50,7 +51,6 @@ interface Role { interface Props { user: UserDetail; - userRoles: string[]; userPermissions: string[]; allRoles: Role[]; permissionsByModule: Record; @@ -68,7 +68,6 @@ type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | null; export default function UsersEdit({ user, - userRoles, userPermissions, allRoles, permissionsByModule, @@ -231,7 +230,7 @@ export default function UsersEdit({ id={`role-${role.id}`} name="roles" value={role.name ?? ''} - defaultChecked={userRoles.includes(role.name ?? '')} + defaultChecked={user.roles.includes(role.name ?? '')} /> diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs index cf951ca2..42996ab0 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -5,6 +5,7 @@ using SimpleModule.Core; using SimpleModule.Core.Authorization; using SimpleModule.Core.Inertia; +using SimpleModule.OpenIddict.Contracts; using SimpleModule.Permissions.Contracts; using SimpleModule.Users.Contracts; @@ -23,6 +24,7 @@ public void Map(IEndpointRouteBuilder app) IUserAdminContracts userAdmin, IRoleAdminContracts roleAdmin, IPermissionContracts permissionContracts, + IOpenIddictSessionContracts sessionContracts, PermissionRegistry permissionRegistry, string? tab ) => @@ -35,10 +37,12 @@ public void Map(IEndpointRouteBuilder app) var permsTask = permissionContracts.GetPermissionsForUserAsync( UserId.From(id) ); - await Task.WhenAll(rolesTask, permsTask); + var sessionsTask = sessionContracts.GetActiveSessionsForUserAsync(id); + await Task.WhenAll(rolesTask, permsTask, sessionsTask); var allRoles = rolesTask.Result; var userPermissions = permsTask.Result.ToList(); + var activeSessions = sessionsTask.Result; var permissionsByModule = permissionRegistry.ByModule.ToDictionary( kvp => kvp.Key, @@ -56,6 +60,7 @@ public void Map(IEndpointRouteBuilder app) userPermissions, allRoles, permissionsByModule, + activeSessions, tab = tab ?? "details", currentUserId, } diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs new file mode 100644 index 00000000..1b4321a2 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/IOpenIddictSessionContracts.cs @@ -0,0 +1,16 @@ +namespace SimpleModule.OpenIddict.Contracts; + +public interface IOpenIddictSessionContracts +{ + Task> GetActiveSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ); + + Task RevokeSessionAsync(string tokenId, CancellationToken cancellationToken = default); + + Task RevokeAllSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ); +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs new file mode 100644 index 00000000..3138bf22 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict.Contracts/UserSessionDto.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core; + +namespace SimpleModule.OpenIddict.Contracts; + +[Dto] +public class UserSessionDto +{ + public string TokenId { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public string? ApplicationName { get; set; } + public DateTimeOffset? CreationDate { get; set; } + public DateTimeOffset? ExpirationDate { get; set; } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs index bb8ca116..04f9f45d 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs @@ -101,6 +101,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config // Seed service services.AddHostedService(); + // Session management contracts + services.AddScoped(); + // Host-level contributions services.AddTransient, OpenIddictSwaggerGenSetup>(); services.AddTransient, OpenIddictSwaggerUISetup>(); diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs new file mode 100644 index 00000000..b4bdb6be --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSessionService.cs @@ -0,0 +1,99 @@ +using OpenIddict.Abstractions; +using SimpleModule.OpenIddict.Contracts; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SimpleModule.OpenIddict.Services; + +public sealed class OpenIddictSessionService( + IOpenIddictTokenManager tokenManager, + IOpenIddictApplicationManager appManager +) : IOpenIddictSessionContracts +{ + public async Task> GetActiveSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ) + { + var sessions = new List(); + var appNameCache = new Dictionary(); + + await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) + { + var type = await tokenManager.GetTypeAsync(token, cancellationToken); + if (type is not (TokenTypeHints.AccessToken or TokenTypeHints.RefreshToken)) + continue; + + var status = await tokenManager.GetStatusAsync(token, cancellationToken); + if (status != Statuses.Valid) + continue; + + var expiration = await tokenManager.GetExpirationDateAsync(token, cancellationToken); + if (expiration.HasValue && expiration.Value < DateTimeOffset.UtcNow) + continue; + + var appId = await tokenManager.GetApplicationIdAsync(token, cancellationToken); + string? appName = null; + if (appId is not null) + { + if (!appNameCache.TryGetValue(appId, out appName)) + { + var app = await appManager.FindByIdAsync(appId, cancellationToken); + if (app is not null) + appName = await appManager.GetDisplayNameAsync(app, cancellationToken); + appNameCache[appId] = appName; + } + } + + sessions.Add( + new UserSessionDto + { + TokenId = + await tokenManager.GetIdAsync(token, cancellationToken) ?? string.Empty, + Type = type ?? string.Empty, + ApplicationName = appName, + CreationDate = await tokenManager.GetCreationDateAsync( + token, + cancellationToken + ), + ExpirationDate = expiration, + } + ); + } + + return sessions; + } + + public async Task RevokeSessionAsync( + string tokenId, + CancellationToken cancellationToken = default + ) + { + var token = await tokenManager.FindByIdAsync(tokenId, cancellationToken); + if (token is not null) + { + await tokenManager.TryRevokeAsync(token, cancellationToken); + } + } + + public async Task RevokeAllSessionsForUserAsync( + string userId, + CancellationToken cancellationToken = default + ) + { + var tokensToRevoke = new List(); + + await foreach (var token in tokenManager.FindBySubjectAsync(userId, cancellationToken)) + { + var status = await tokenManager.GetStatusAsync(token, cancellationToken); + if (status == Statuses.Valid) + { + tokensToRevoke.Add(token); + } + } + + foreach (var token in tokensToRevoke) + { + await tokenManager.TryRevokeAsync(token, cancellationToken); + } + } +} From e5fa4e59b5a8a2c741318a269fb40c49c7f69e1c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sat, 28 Mar 2026 15:19:52 +0000 Subject: [PATCH 4/5] Fix build: remove AdminDbContext references from test fixture AdminDbContext was deleted when removing the custom audit log from the Admin module, but the test fixture still referenced it for SQLite replacement and database initialization. --- .../Infrastructure/LoadTestWebApplicationFactory.cs | 4 +--- .../Fixtures/SimpleModuleWebApplicationFactory.cs | 3 --- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs b/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs index 63a06ec1..7ca6394b 100644 --- a/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs +++ b/tests/SimpleModule.LoadTests/Infrastructure/LoadTestWebApplicationFactory.cs @@ -10,7 +10,6 @@ using OpenIddict.Abstractions; using OpenIddict.Server; using OpenIddict.Server.AspNetCore; -using SimpleModule.Admin; using SimpleModule.Admin.Contracts; using SimpleModule.AuditLogs; using SimpleModule.Core.Authorization; @@ -94,7 +93,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ReplaceWithFileDb(services, connectionString); ReplaceWithFileDb(services, connectionString); ReplaceWithFileDb(services, connectionString); - ReplaceWithFileDb(services, connectionString); ReplaceWithFileDb(services, connectionString); ReplaceWithFileDb(services, connectionString); ReplaceWithFileDb(services, connectionString); @@ -331,7 +329,7 @@ protected override void Dispose(bool disposing) FileStoragePermissions.View, FileStoragePermissions.Upload, FileStoragePermissions.Delete, PageBuilderPermissions.View, PageBuilderPermissions.Create, PageBuilderPermissions.Update, PageBuilderPermissions.Delete, PageBuilderPermissions.Publish, - AdminPermissions.ManageUsers, AdminPermissions.ManageRoles, AdminPermissions.ViewAuditLog, + AdminPermissions.ManageUsers, AdminPermissions.ManageRoles, OpenIddictPermissions.ManageClients, FeatureFlagsPermissions.View, FeatureFlagsPermissions.Manage, ]; diff --git a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs index 90668cfb..4d0aa5bc 100644 --- a/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs +++ b/tests/SimpleModule.Tests.Shared/Fixtures/SimpleModuleWebApplicationFactory.cs @@ -10,7 +10,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using SimpleModule.Admin; using SimpleModule.AuditLogs; using SimpleModule.Database; using SimpleModule.FileStorage; @@ -54,7 +53,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); - ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); ReplaceDbContext(services); @@ -148,7 +146,6 @@ private void EnsureModuleDatabasesCreated() sp.GetRequiredService().Database.EnsureCreated(); // Some module contexts may need explicit table creation if EnsureCreated // returns false (database already has tables from HostDbContext startup). - EnsureTablesCreated(sp); EnsureTablesCreated(sp); EnsureTablesCreated(sp); EnsureTablesCreated(sp); From 04c477a7411008d29ad9107e11fc98c14855bc5f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 30 Mar 2026 13:12:46 +0000 Subject: [PATCH 5/5] Fix CA1849: use await instead of .Result after Task.WhenAll --- .../src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs | 6 +++--- .../src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs index 42996ab0..97fafac9 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -40,9 +40,9 @@ public void Map(IEndpointRouteBuilder app) var sessionsTask = sessionContracts.GetActiveSessionsForUserAsync(id); await Task.WhenAll(rolesTask, permsTask, sessionsTask); - var allRoles = rolesTask.Result; - var userPermissions = permsTask.Result.ToList(); - var activeSessions = sessionsTask.Result; + var allRoles = await rolesTask; + var userPermissions = (await permsTask).ToList(); + var activeSessions = await sessionsTask; var permissionsByModule = permissionRegistry.ByModule.ToDictionary( kvp => kvp.Key, diff --git a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs index c060a7d3..15376caa 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEndpoint.cs @@ -35,8 +35,8 @@ public void Map(IEndpointRouteBuilder app) var rolesTask = roleAdmin.GetAllRolesAsync(); await Task.WhenAll(usersTask, rolesTask); - var result = usersTask.Result; - var allRoles = rolesTask.Result; + var result = await usersTask; + var allRoles = await rolesTask; var totalPages = (int)Math.Ceiling((double)result.TotalCount / pageSize); return Inertia.Render(