feat: add UserManagementService with temp-password creation and deactivation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-27 14:08:50 -07:00
parent 0986233d9b
commit 3ab0998793
3 changed files with 428 additions and 0 deletions
@@ -0,0 +1,232 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class UserManagementService : IUserManagementService
{
private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db;
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db)
{
_userManager = userManager;
_db = db;
}
// ── GetPaged ─────────────────────────────────────────────────────────────
public async Task<PagedResult<UserListItemDto>> GetPagedAsync(
int page, int pageSize, string? search)
{
var query = from u in _userManager.Users
join m in _db.Members.IgnoreQueryFilters()
on u.MemberId equals (int?)m.Id into mg
from m in mg.DefaultIfEmpty()
select new { u, m };
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(x =>
x.u.Email!.ToLower().Contains(s) ||
(x.m != null && (
x.m.FirstName_en.ToLower().Contains(s) ||
x.m.LastName_en.ToLower().Contains(s) ||
(x.m.NickName != null && x.m.NickName.ToLower().Contains(s)))));
}
var total = await query.CountAsync();
var rows = await query
.OrderBy(x => x.u.Email)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(x => new
{
x.u.Id, x.u.Email, x.u.MemberId, x.u.IsActive,
x.u.LanguagePreference, x.u.LastLoginAt, x.u.CreatedAt,
MemberDisplayName = x.m != null
? (x.m.NickName ?? x.m.FirstName_en) + " " + x.m.LastName_en
: (string?)null,
})
.ToListAsync();
// Batch-load roles
var userIds = rows.Select(r => r.Id).ToList();
var roleMap = await (
from ur in _db.UserRoles
join r in _db.Roles on ur.RoleId equals r.Id
where userIds.Contains(ur.UserId)
select new { ur.UserId, r.Name }
).ToListAsync();
var rolesByUser = roleMap
.GroupBy(x => x.UserId)
.ToDictionary(g => g.Key, g => g.Select(x => x.Name!).ToList());
var items = rows.Select(r => new UserListItemDto
{
Id = r.Id,
Email = r.Email ?? "",
MemberId = r.MemberId,
MemberDisplayName = r.MemberDisplayName,
IsActive = r.IsActive,
LanguagePreference = r.LanguagePreference,
LastLoginAt = r.LastLoginAt,
CreatedAt = r.CreatedAt,
Roles = rolesByUser.GetValueOrDefault(r.Id, []),
}).ToList();
return new PagedResult<UserListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// ── GetById ──────────────────────────────────────────────────────────────
public async Task<UserDto?> GetByIdAsync(string id)
{
var user = await _userManager.FindByIdAsync(id);
if (user is null) return null;
var roles = await _userManager.GetRolesAsync(user);
var memberName = user.MemberId.HasValue
? await _db.Members.IgnoreQueryFilters()
.Where(m => m.Id == user.MemberId)
.Select(m => (m.NickName ?? m.FirstName_en) + " " + m.LastName_en)
.FirstOrDefaultAsync()
: null;
return new UserDto
{
Id = user.Id,
Email = user.Email ?? "",
MemberId = user.MemberId,
MemberDisplayName = memberName,
Roles = roles.ToList(),
IsActive = user.IsActive,
LanguagePreference = user.LanguagePreference,
LastLoginAt = user.LastLoginAt,
CreatedAt = user.CreatedAt,
};
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<CreateUserResult> CreateAsync(CreateUserRequest request)
{
// Validate Member exists (FindAsync bypasses query filter — intentional)
var member = await _db.Members.FindAsync(request.MemberId)
?? throw new InvalidOperationException(
$"Member {request.MemberId} does not exist.");
// One user per member
if (_userManager.Users.Any(u => u.MemberId == request.MemberId))
throw new InvalidOperationException(
"This member already has a user account.");
// Unique email
if (await _userManager.FindByEmailAsync(request.Email) is not null)
throw new InvalidOperationException(
$"Email '{request.Email}' is already in use.");
var tempPassword = GenerateTempPassword();
var user = new AppUser
{
UserName = request.Email,
Email = request.Email,
EmailConfirmed = true,
MemberId = request.MemberId,
LanguagePreference = request.LanguagePreference,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
var result = await _userManager.CreateAsync(user, tempPassword);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(e => e.Description)));
await _userManager.AddToRolesAsync(user, request.Roles);
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
}
// ── Update ───────────────────────────────────────────────────────────────
public async Task UpdateAsync(string id, UpdateUserRequest request)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
user.Email = request.Email;
user.UserName = request.Email;
user.NormalizedEmail = request.Email.ToUpperInvariant();
user.NormalizedUserName = request.Email.ToUpperInvariant();
user.LanguagePreference = request.LanguagePreference;
user.IsActive = request.IsActive;
user.LockoutEnd = request.IsActive ? null : DateTimeOffset.MaxValue;
var updateResult = await _userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
throw new InvalidOperationException(
string.Join("; ", updateResult.Errors.Select(e => e.Description)));
var currentRoles = await _userManager.GetRolesAsync(user);
var toRemove = currentRoles.Except(request.Roles).ToList();
var toAdd = request.Roles.Except(currentRoles).ToList();
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
}
// ── Deactivate ───────────────────────────────────────────────────────────
public async Task DeactivateAsync(string id)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
user.IsActive = false;
user.LockoutEnd = DateTimeOffset.MaxValue;
await _userManager.UpdateAsync(user);
}
// ── ResetPassword ────────────────────────────────────────────────────────
public async Task<string> ResetPasswordAsync(string id)
{
var user = await _userManager.FindByIdAsync(id)
?? throw new KeyNotFoundException($"User {id} not found.");
var tempPassword = GenerateTempPassword();
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, tempPassword);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(e => e.Description)));
return tempPassword;
}
// ── Helpers ──────────────────────────────────────────────────────────────
private static string GenerateTempPassword()
{
const string upper = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const string lower = "abcdefghjkmnpqrstuvwxyz";
const string digits = "23456789";
const string special = "!@#$%^";
const string all = upper + lower + digits + special;
var rng = new Random();
var pw = new char[12];
pw[0] = upper[rng.Next(upper.Length)];
pw[1] = lower[rng.Next(lower.Length)];
pw[2] = digits[rng.Next(digits.Length)];
pw[3] = special[rng.Next(special.Length)];
for (var i = 4; i < 12; i++) pw[i] = all[rng.Next(all.Length)];
return new string(pw.OrderBy(_ => rng.Next()).ToArray());
}
}