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/AdminSessionsEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs new file mode 100644 index 00000000..f2cbe8da --- /dev/null +++ b/modules/Admin/src/SimpleModule.Admin/Endpoints/Admin/AdminSessionsEndpoint.cs @@ -0,0 +1,46 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.OpenIddict.Contracts; + +namespace SimpleModule.Admin.Endpoints.Admin; + +public class AdminSessionsEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + var group = app.MapGroup("/admin/users/{id}/sessions") + .RequireAuthorization(policy => policy.RequireRole("Admin")) + .DisableAntiforgery(); + + // DELETE /admin/users/{id}/sessions/{tokenId} — Revoke individual session + group.MapDelete( + "/{tokenId}", + async Task ( + string id, + string tokenId, + IOpenIddictSessionContracts sessionContracts + ) => + { + await sessionContracts.RevokeSessionAsync(tokenId); + + return TypedResults.Redirect($"/admin/users/{id}/edit?tab=sessions"); + } + ); + + // DELETE /admin/users/{id}/sessions — Revoke all sessions + group.MapDelete( + "/", + async Task ( + string id, + IOpenIddictSessionContracts sessionContracts + ) => + { + await sessionContracts.RevokeAllSessionsForUserAsync(id); + + return TypedResults.Redirect($"/admin/users/{id}/edit?tab=sessions"); + } + ); + } +} 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..ea09b562 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Users.tsx @@ -2,8 +2,13 @@ import { router } from '@inertiajs/react'; import { Badge, Button, + DataGridPage, Input, - PageShell, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, Table, TableBody, TableCell, @@ -30,6 +35,9 @@ interface Props { page: number; totalPages: number; totalCount: number; + allRoles: string[]; + filterStatus: string; + filterRole: string; } function userStatus(user: User) { @@ -38,149 +46,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 handleSearch(e: FormEvent) { - e.preventDefault(); - router.get('/admin/users', { search: searchValue, page: 1 }, { preserveState: true }); + function navigate(params: Record) { + router.get( + '/admin/users', + { search: searchValue, page: 1, filterStatus, filterRole, ...params }, + { preserveState: true }, + ); } - function goToPage(p: number) { - router.get('/admin/users', { search: searchValue, page: p }, { preserveState: true }); + function handleSearch(e: FormEvent) { + e.preventDefault(); + navigate({ search: searchValue }); } - 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 + - ); - })} - -
+ + + {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()} + + + + +
+ ); + })} +
+ - {users.length === 0 && search && ( -

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

- )} - - {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..73ef7942 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/UsersEdit.tsx @@ -24,9 +24,14 @@ import { FieldGroup, Input, Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from '@simplemodule/ui'; import { useState } from 'react'; -import { ActivityTimeline } from '../components/ActivityTimeline'; import { PermissionGroups } from '../components/PermissionGroups'; import { TabNav } from '../components/TabNav'; @@ -36,6 +41,7 @@ interface UserDetail { email: string; emailConfirmed: boolean; twoFactorEnabled: boolean; + roles: string[]; isLockedOut: boolean; isDeactivated: boolean; accessFailedCount: number; @@ -49,56 +55,46 @@ interface Role { description: string | null; } -interface ActivityEntry { - id: number; - action: string; - details: string | null; - performedBy: string; - timestamp: string; +interface Session { + tokenId: string; + type: string; + applicationName: string | null; + creationDate: string | null; + expirationDate: string | null; } interface Props { user: UserDetail; - userRoles: string[]; userPermissions: string[]; allRoles: Role[]; permissionsByModule: Record; - activityLog: ActivityEntry[]; - activityTotal: number; + activeSessions: Session[]; tab: string; + currentUserId: string; } const tabs = [ { id: 'details', label: 'Details' }, { id: 'roles', label: 'Roles & Permissions' }, { id: 'security', label: 'Security' }, - { id: 'activity', label: 'Activity' }, + { id: 'sessions', label: 'Sessions' }, ]; -type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | null; +type ConfirmAction = 'deactivate' | 'reverify' | 'disable2fa' | 'revokeAll' | null; export default function UsersEdit({ user, - userRoles, userPermissions, allRoles, permissionsByModule, - activityLog, - activityTotal, + activeSessions, 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) { @@ -111,6 +107,9 @@ export default function UsersEdit({ case 'disable2fa': router.post(`/admin/users/${user.id}/disable-2fa`); break; + case 'revokeAll': + router.delete(`/admin/users/${user.id}/sessions`); + break; } setConfirmAction(null); } @@ -134,6 +133,12 @@ export default function UsersEdit({ description: 'This will disable 2FA and reset the authenticator for this user. Are you sure?', action: 'Disable 2FA', }, + revokeAll: { + title: 'Revoke All Sessions', + description: + 'This will invalidate all active sessions for this user. They will need to sign in again.', + action: 'Revoke All', + }, }; const dialogConfig = confirmAction ? confirmDialogConfig[confirmAction] : null; @@ -153,7 +158,9 @@ export default function UsersEdit({

Edit User

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

You cannot deactivate your own account.

) : (

@@ -247,7 +256,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 ?? '')} />

+ ) : isSelf ? ( +

You cannot lock your own account.

) : (

This account is active.

@@ -410,23 +421,81 @@ export default function UsersEdit({ {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Never'}
+
+ Created: + + {new Date(user.createdAt).toLocaleString()} + +
)} - {tab === 'activity' && ( + {tab === 'sessions' && ( - Activity Log +
+ Active Sessions + {activeSessions.length > 0 && ( + + )} +
- + {activeSessions.length === 0 ? ( +

No active sessions.

+ ) : ( + + + + Type + Application + Created + Expires + + + + + {activeSessions.map((session) => ( + + + + {session.type === 'refresh_token' ? 'Refresh' : 'Access'} + + + + {session.applicationName || '\u2014'} + + + {session.creationDate + ? new Date(session.creationDate).toLocaleString() + : '\u2014'} + + + {session.expirationDate + ? new Date(session.expirationDate).toLocaleString() + : 'Never'} + + + + + + ))} + +
+ )}
)} 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..65786d45 100644 --- a/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj +++ b/modules/Admin/src/SimpleModule.Admin/SimpleModule.Admin.csproj @@ -5,9 +5,9 @@ - + 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..97fafac9 100644 --- a/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Views/Admin/UsersEditEndpoint.cs @@ -1,10 +1,11 @@ +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; +using SimpleModule.OpenIddict.Contracts; using SimpleModule.Permissions.Contracts; using SimpleModule.Users.Contracts; @@ -13,18 +14,17 @@ 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, + IOpenIddictSessionContracts sessionContracts, PermissionRegistry permissionRegistry, string? tab ) => @@ -33,76 +33,36 @@ public void Map(IEndpointRouteBuilder app) if (user is null) return TypedResults.NotFound(); - var allRoles = await roleAdmin.GetAllRolesAsync(); + var rolesTask = roleAdmin.GetAllRolesAsync(); + var permsTask = permissionContracts.GetPermissionsForUserAsync( + UserId.From(id) + ); + var sessionsTask = sessionContracts.GetActiveSessionsForUserAsync(id); + await Task.WhenAll(rolesTask, permsTask, sessionsTask); - // User direct permissions - var userPermissions = ( - await permissionContracts.GetPermissionsForUserAsync(UserId.From(id)) - ).ToList(); + var allRoles = await rolesTask; + var userPermissions = (await permsTask).ToList(); + var activeSessions = await sessionsTask; - // Permission registry grouped by module var permissionsByModule = permissionRegistry.ByModule.ToDictionary( kvp => kvp.Key, 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", new { user, - userRoles = user.Roles, userPermissions, allRoles, permissionsByModule, - activityLog = activityWithNames, - activityTotal, + activeSessions, 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..15376caa 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 = await usersTask; + var allRoles = await rolesTask; 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/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); + } + } +} 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..99456ff6 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 { @@ -146,8 +195,9 @@ public async Task LockAccountAsync(UserId id) var user = await userManager.FindByIdAsync(id.Value) ?? throw new NotFoundException("User", id); - await userManager.SetLockoutEnabledAsync(user, true); - await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); + user.LockoutEnabled = true; + user.LockoutEnd = DateTimeOffset.UtcNow.AddYears(100); + await userManager.UpdateAsync(user); } public async Task UnlockAccountAsync(UserId id) @@ -155,8 +205,9 @@ public async Task UnlockAccountAsync(UserId id) var user = await userManager.FindByIdAsync(id.Value) ?? throw new NotFoundException("User", id); - await userManager.SetLockoutEndDateAsync(user, null); - await userManager.ResetAccessFailedCountAsync(user); + user.LockoutEnd = null; + user.AccessFailedCount = 0; + await userManager.UpdateAsync(user); } public async Task DeactivateAsync(UserId id) @@ -165,9 +216,9 @@ public async Task DeactivateAsync(UserId id) ?? throw new NotFoundException("User", id); user.DeactivatedAt = DateTimeOffset.UtcNow; + user.LockoutEnabled = true; + user.LockoutEnd = DateTimeOffset.UtcNow.AddYears(100); await userManager.UpdateAsync(user); - await userManager.SetLockoutEnabledAsync(user, true); - await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)); } public async Task ReactivateAsync(UserId id) @@ -176,9 +227,9 @@ public async Task ReactivateAsync(UserId id) ?? throw new NotFoundException("User", id); user.DeactivatedAt = null; + user.LockoutEnd = null; + user.AccessFailedCount = 0; await userManager.UpdateAsync(user); - await userManager.SetLockoutEndDateAsync(user, null); - await userManager.ResetAccessFailedCountAsync(user); } public async Task ForceEmailReverificationAsync(UserId id) @@ -206,9 +257,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"), }; } 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);