Files
sports-division/src/SportsDivision.Web/Controllers/UserManagementController.cs
warringtond 810f721e48 Fix all 45 audited bugs from bug-fixes.md
Verified every finding against the code, then fixed correctness (redirects,
advancement reset, duplicate heats, high-jump attempt entry, placement
ranking with tie handling, delete-dependency 500s), security (secrets out of
config, config-driven admin seed, login lockout, role authorization with
school scoping, heat-time IDOR, forwarded headers, last-admin guards),
schema integrity (nullable+filtered ExistingStudentId, unique indexes for
rounds/heats/bar heights via SchemaIntegrityFixes migration), performance
(N+1 removal in high jump/reports/standings/dashboard, SQL-side student
paging), and hygiene (duplicate notifications, auto-dismiss scope, local
bootstrap-icons, orphaned files, test-data.sql tournament creation).

FluentValidation is now registered; AutoMapper bumped to 14.0.0 (advisory
fully patched only in licence-changed 15.1.1 — documented). 11 new tests;
63/63 passing. Credential rotation and deploy-time DB_CONNECTION_STRING are
required manual follow-ups, documented in bug-fixes.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:30:53 -04:00

303 lines
9.9 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Infrastructure.Identity;
namespace SportsDivision.Web.Controllers;
[Authorize(Roles = "Admin")]
public class UserManagementController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly ISchoolService _schoolService;
public UserManagementController(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
ISchoolService schoolService)
{
_userManager = userManager;
_roleManager = roleManager;
_schoolService = schoolService;
}
public async Task<IActionResult> Index(string? role, bool? isActive)
{
var users = await _userManager.Users.ToListAsync();
var schools = await _schoolService.GetAllAsync();
var schoolLookup = schools.ToDictionary(s => s.SchoolId, s => s.Name);
// One query per role instead of one per user.
var roleNames = await _roleManager.Roles.Select(r => r.Name!).ToListAsync();
var roleByUserId = new Dictionary<string, string>();
foreach (var roleName in roleNames)
{
foreach (var member in await _userManager.GetUsersInRoleAsync(roleName))
{
roleByUserId.TryAdd(member.Id, roleName);
}
}
var userDtos = new List<UserDto>();
foreach (var user in users)
{
var dto = new UserDto
{
Id = user.Id,
Email = user.Email ?? string.Empty,
FirstName = user.FirstName,
LastName = user.LastName,
FullName = user.FullName,
Role = roleByUserId.GetValueOrDefault(user.Id, "None"),
SchoolId = user.SchoolId,
SchoolName = user.SchoolId.HasValue && schoolLookup.TryGetValue(user.SchoolId.Value, out var name) ? name : null,
IsActive = user.IsActive
};
userDtos.Add(dto);
}
if (!string.IsNullOrEmpty(role))
{
userDtos = userDtos.Where(u => u.Role == role).ToList();
}
if (isActive.HasValue)
{
userDtos = userDtos.Where(u => u.IsActive == isActive.Value).ToList();
}
ViewBag.Roles = await _roleManager.Roles.Select(r => r.Name).ToListAsync();
ViewBag.SelectedRole = role;
ViewBag.SelectedIsActive = isActive;
return View(userDtos);
}
[HttpGet]
public async Task<IActionResult> Create()
{
await PopulateDropdowns();
return View(new UserCreateDto());
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(UserCreateDto dto)
{
if (!ModelState.IsValid)
{
await PopulateDropdowns();
return View(dto);
}
var user = new ApplicationUser
{
UserName = dto.Email,
Email = dto.Email,
FirstName = dto.FirstName,
LastName = dto.LastName,
SchoolId = dto.SchoolId,
IsActive = true,
EmailConfirmed = true
};
var result = await _userManager.CreateAsync(user, dto.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await PopulateDropdowns();
return View(dto);
}
if (!string.IsNullOrEmpty(dto.Role))
{
await _userManager.AddToRoleAsync(user, dto.Role);
}
TempData["SuccessMessage"] = "User created successfully.";
return RedirectToAction(nameof(Index));
}
[HttpGet]
public async Task<IActionResult> Edit(string id)
{
var user = await _userManager.FindByIdAsync(id);
if (user == null) return NotFound();
var roles = await _userManager.GetRolesAsync(user);
var dto = new UserUpdateDto
{
Id = user.Id,
FirstName = user.FirstName,
LastName = user.LastName,
Role = roles.FirstOrDefault() ?? string.Empty,
SchoolId = user.SchoolId,
IsActive = user.IsActive
};
ViewBag.Email = user.Email;
await PopulateDropdowns();
return View(dto);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(UserUpdateDto dto)
{
if (!ModelState.IsValid)
{
var u = await _userManager.FindByIdAsync(dto.Id);
ViewBag.Email = u?.Email;
await PopulateDropdowns();
return View(dto);
}
var user = await _userManager.FindByIdAsync(dto.Id);
if (user == null) return NotFound();
// Guard against locking the system out of administration: an admin may not
// deactivate or demote themselves, and the last active Admin must remain.
var currentRoles0 = await _userManager.GetRolesAsync(user);
var losesAdmin = currentRoles0.Contains("Admin") && dto.Role != "Admin";
if (user.Id == _userManager.GetUserId(User) && (!dto.IsActive || losesAdmin))
{
TempData["ErrorMessage"] = "You cannot deactivate or demote your own account.";
return RedirectToAction(nameof(Index));
}
if ((!dto.IsActive || losesAdmin) && await IsLastActiveAdminAsync(user))
{
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated or demoted.";
return RedirectToAction(nameof(Index));
}
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.SchoolId = dto.SchoolId;
user.IsActive = dto.IsActive;
var updateResult = await _userManager.UpdateAsync(user);
if (!updateResult.Succeeded)
{
foreach (var error in updateResult.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
ViewBag.Email = user.Email;
await PopulateDropdowns();
return View(dto);
}
// Update role if changed
var currentRoles = await _userManager.GetRolesAsync(user);
var currentRole = currentRoles.FirstOrDefault();
if (currentRole != dto.Role)
{
if (!string.IsNullOrEmpty(currentRole))
{
await _userManager.RemoveFromRoleAsync(user, currentRole);
}
if (!string.IsNullOrEmpty(dto.Role))
{
await _userManager.AddToRoleAsync(user, dto.Role);
}
}
TempData["SuccessMessage"] = "User updated successfully.";
return RedirectToAction(nameof(Index));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(string id)
{
var user = await _userManager.FindByIdAsync(id);
if (user == null) return NotFound();
if (user.IsActive)
{
if (user.Id == _userManager.GetUserId(User))
{
TempData["ErrorMessage"] = "You cannot deactivate your own account.";
return RedirectToAction(nameof(Index));
}
if (await IsLastActiveAdminAsync(user))
{
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated.";
return RedirectToAction(nameof(Index));
}
}
user.IsActive = !user.IsActive;
await _userManager.UpdateAsync(user);
TempData["SuccessMessage"] = $"User {(user.IsActive ? "activated" : "deactivated")} successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>True when <paramref name="user"/> is an active Admin and no other active Admin exists.</summary>
private async Task<bool> IsLastActiveAdminAsync(ApplicationUser user)
{
if (!user.IsActive || !await _userManager.IsInRoleAsync(user, "Admin")) return false;
var admins = await _userManager.GetUsersInRoleAsync("Admin");
return !admins.Any(a => a.IsActive && a.Id != user.Id);
}
[HttpGet]
public async Task<IActionResult> ResetPassword(string id)
{
var user = await _userManager.FindByIdAsync(id);
if (user == null) return NotFound();
return View(new ResetPasswordDto
{
Id = user.Id,
Email = user.Email ?? string.Empty,
FullName = user.FullName
});
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordDto dto)
{
var user = await _userManager.FindByIdAsync(dto.Id);
if (user == null) return NotFound();
// Keep display fields populated regardless of the posted values.
dto.Email = user.Email ?? string.Empty;
dto.FullName = user.FullName;
if (!ModelState.IsValid)
{
return View(dto);
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, token, dto.NewPassword);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return View(dto);
}
TempData["SuccessMessage"] = $"Password reset successfully for {user.Email}.";
return RedirectToAction(nameof(Index));
}
private async Task PopulateDropdowns()
{
ViewBag.Roles = await _roleManager.Roles.Select(r => r.Name).ToListAsync();
ViewBag.Schools = (await _schoolService.GetAllAsync()).Where(s => s.IsActive).ToList();
}
}