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>
303 lines
9.9 KiB
C#
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();
|
|
}
|
|
}
|