using MemberCenter.Application.Abstractions; using MemberCenter.Application.Models.Admin; using MemberCenter.Infrastructure.Identity; using MemberCenter.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; using OpenIddict.EntityFrameworkCore.Models; namespace MemberCenter.Infrastructure.Services; public sealed class AccountGovernanceService : IAccountGovernanceService { private const string AdminRole = "admin"; private const string SuperuserRole = "superuser"; private readonly MemberCenterDbContext _dbContext; private readonly UserManager _userManager; private readonly RoleManager _roleManager; private readonly IAuditLogWriter _auditLogWriter; public AccountGovernanceService( MemberCenterDbContext dbContext, UserManager userManager, RoleManager roleManager, IAuditLogWriter auditLogWriter) { _dbContext = dbContext; _userManager = userManager; _roleManager = roleManager; _auditLogWriter = auditLogWriter; } public async Task> ListUsersAsync(string? search = null, int take = 200) { var query = _dbContext.Users .AsNoTracking() .GroupJoin( _dbContext.UserProfiles.AsNoTracking(), user => user.Id, profile => profile.UserId, (user, profiles) => new { User = user, Profile = profiles.FirstOrDefault() }); if (!string.IsNullOrWhiteSpace(search)) { var term = search.Trim().ToLower(); query = query.Where(x => (x.User.Email ?? string.Empty).ToLower().Contains(term) || (x.Profile != null && ( x.Profile.LastName.ToLower().Contains(term) || x.Profile.FirstName.ToLower().Contains(term) || (x.Profile.NickName != null && x.Profile.NickName.ToLower().Contains(term))))); } var users = await query .OrderByDescending(x => x.User.CreatedAt) .Take(Math.Clamp(take, 1, 500)) .ToListAsync(); var result = new List(users.Count); foreach (var item in users) { var roles = await _userManager.GetRolesAsync(item.User); result.Add(new UserGovernanceSummaryDto( item.User.Id, item.User.Email ?? string.Empty, item.Profile?.LastName, item.Profile?.FirstName, item.Profile?.NickName, item.User.EmailConfirmed, roles.Contains(AdminRole, StringComparer.OrdinalIgnoreCase), roles.Contains(SuperuserRole, StringComparer.OrdinalIgnoreCase), item.User.DisabledAt.HasValue, item.User.IsBlacklisted, item.User.CreatedAt, item.User.LastLoginAt, item.User.LastSeenAt, item.User.DisabledAt, item.User.DisabledBy)); } return result; } public async Task SetAdminAsync(Guid actorUserId, Guid targetUserId, bool enabled) { await EnsureSuperuserAsync(actorUserId); await EnsureRoleExistsAsync(AdminRole); var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); if (enabled && targetUser.DisabledAt.HasValue) { throw new InvalidOperationException("Disabled accounts cannot be granted admin permissions."); } var inRole = await _userManager.IsInRoleAsync(targetUser, AdminRole); if (enabled && !inRole) { EnsureSucceeded(await _userManager.AddToRoleAsync(targetUser, AdminRole)); } else if (!enabled && inRole) { EnsureSucceeded(await _userManager.RemoveFromRoleAsync(targetUser, AdminRole)); } await _auditLogWriter.WriteAsync("user", actorUserId, "account.role_changed", new { target_user_id = targetUser.Id, email = targetUser.Email, role = AdminRole, enabled }); } public async Task SetDisabledAsync(Guid actorUserId, Guid targetUserId, bool disabled) { await EnsureAdminAsync(actorUserId); if (actorUserId == targetUserId) { throw new InvalidOperationException("You cannot disable your own account."); } var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); await EnsureTargetIsMemberAsync(targetUser); targetUser.DisabledAt = disabled ? DateTimeOffset.UtcNow : null; targetUser.DisabledBy = disabled ? actorUserId.ToString() : null; EnsureSucceeded(await _userManager.UpdateAsync(targetUser)); await _auditLogWriter.WriteAsync("user", actorUserId, disabled ? "account.disabled" : "account.enabled", new { target_user_id = targetUser.Id, email = targetUser.Email }); } public async Task ResetPasswordAsync(Guid actorUserId, Guid targetUserId, string newPassword) { var actorIsSuperuser = await EnsureAdminAsync(actorUserId); var targetUser = await _userManager.FindByIdAsync(targetUserId.ToString()) ?? throw new InvalidOperationException("Target user not found."); await EnsureTargetIsMutableAsync(targetUser); var targetIsAdmin = await _userManager.IsInRoleAsync(targetUser, AdminRole); if (targetIsAdmin && !actorIsSuperuser) { throw new InvalidOperationException("Only superuser can reset admin passwords."); } if (string.IsNullOrWhiteSpace(newPassword)) { throw new InvalidOperationException("New password is required."); } var resetToken = await _userManager.GeneratePasswordResetTokenAsync(targetUser); EnsureSucceeded(await _userManager.ResetPasswordAsync(targetUser, resetToken, newPassword)); EnsureSucceeded(await _userManager.UpdateSecurityStampAsync(targetUser)); await RevokeUserAuthorizationsAsync(targetUser.Id); await _auditLogWriter.WriteAsync("user", actorUserId, "account.password_reset_by_admin", new { target_user_id = targetUser.Id, email = targetUser.Email, actor_is_superuser = actorIsSuperuser, revoke_existing_sessions = true }); } private async Task EnsureSuperuserAsync(Guid actorUserId) { await EnsureRoleExistsAsync(SuperuserRole); var actor = await _userManager.FindByIdAsync(actorUserId.ToString()) ?? throw new InvalidOperationException("Actor user not found."); if (!await _userManager.IsInRoleAsync(actor, SuperuserRole)) { throw new InvalidOperationException("Only superuser can modify account governance."); } } private async Task EnsureAdminAsync(Guid actorUserId) { await EnsureRoleExistsAsync(AdminRole); await EnsureRoleExistsAsync(SuperuserRole); var actor = await _userManager.FindByIdAsync(actorUserId.ToString()) ?? throw new InvalidOperationException("Actor user not found."); var isSuperuser = await _userManager.IsInRoleAsync(actor, SuperuserRole); var isAdmin = await _userManager.IsInRoleAsync(actor, AdminRole); if (!isAdmin && !isSuperuser) { throw new InvalidOperationException("Only admin can modify account governance."); } return isSuperuser; } private async Task EnsureTargetIsMutableAsync(ApplicationUser targetUser) { if (await _userManager.IsInRoleAsync(targetUser, SuperuserRole)) { throw new InvalidOperationException("Superuser accounts cannot be modified from the management UI."); } } private async Task EnsureTargetIsMemberAsync(ApplicationUser targetUser) { if (await _userManager.IsInRoleAsync(targetUser, AdminRole)) { throw new InvalidOperationException("Admin accounts must have admin permissions removed before they can be disabled."); } } private async Task EnsureRoleExistsAsync(string roleName) { if (await _roleManager.RoleExistsAsync(roleName)) { return; } EnsureSucceeded(await _roleManager.CreateAsync(new ApplicationRole { Id = Guid.NewGuid(), Name = roleName, NormalizedName = roleName.ToUpperInvariant() })); } private static void EnsureSucceeded(IdentityResult result) { if (result.Succeeded) { return; } throw new InvalidOperationException(string.Join("; ", result.Errors.Select(x => x.Description))); } private async Task RevokeUserAuthorizationsAsync(Guid userId) { var subject = userId.ToString(); var tokens = await _dbContext.Set() .Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked) .ToListAsync(); foreach (var token in tokens) { token.Status = OpenIddictConstants.Statuses.Revoked; token.RedemptionDate = DateTime.UtcNow; } var authorizations = await _dbContext.Set() .Where(x => x.Subject == subject && x.Status != OpenIddictConstants.Statuses.Revoked) .ToListAsync(); foreach (var authorization in authorizations) { authorization.Status = OpenIddictConstants.Statuses.Revoked; } await _dbContext.SaveChangesAsync(); } }