using System.Text; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.User; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Core.Interfaces; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; /// /// Controller for SuperAdmin platform user management /// Only accessible by SuperAdmin role /// [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class PlatformUsersController : Controller { // Root account — cannot be disabled, deleted, role-revoked, or password-reset via the UI. // Protected at the code level regardless of who is logged in. private const string RootUserEmail = "artemis@powdercoatinglogix.com"; private readonly UserManager _userManager; private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEmailService _emailService; public PlatformUsersController( UserManager userManager, IUnitOfWork unitOfWork, ILogger logger, IEmailService emailService) { _userManager = userManager; _unitOfWork = unitOfWork; _logger = logger; _emailService = emailService; } /// /// Lists SuperAdmins and CompanyAdmins (users with CompanyRole == null or /// CompanyRole == CompanyAdmin) with search, sort, and pagination. Regular workers and /// managers are excluded because this view is intended for platform-level access management, /// not day-to-day company HR. The "superadmins" filter requires a post-query role check /// (via IsInRoleAsync) because ASP.NET Identity roles cannot be directly joined in /// a LINQ-to-SQL expression; the total count is recalculated accordingly. Company names are /// resolved via a single batch lookup rather than N separate queries. /// // GET: PlatformUsers public async Task Index( string? filter = null, string? searchTerm = null, string? sortColumn = null, string sortDirection = "asc", int pageNumber = 1, int pageSize = 25) { try { // Create and validate grid request var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? "Name", SortDirection = sortDirection, SearchTerm = searchTerm }; gridRequest.Validate(); var query = _userManager.Users.AsQueryable(); // Only show CompanyAdmins and SuperAdmins (CompanyRole == null) — never regular company workers query = query.Where(u => u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin || u.CompanyRole == null); // Apply search filter if (!string.IsNullOrWhiteSpace(searchTerm)) { var search = searchTerm.ToLower(); query = query.Where(u => (u.FirstName != null && u.FirstName.ToLower().Contains(search)) || (u.LastName != null && u.LastName.ToLower().Contains(search)) || (u.Email != null && u.Email.ToLower().Contains(search))); } // Apply role/status filter if (!string.IsNullOrWhiteSpace(filter)) { if (filter == "active") { query = query.Where(u => u.IsActive); } else if (filter == "inactive") { query = query.Where(u => !u.IsActive); } else if (filter == "companyadmins") { query = query.Where(u => u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin); } // SuperAdmins filter will be handled after role check } // Get total count before pagination var totalCount = await query.CountAsync(); // Apply sorting query = gridRequest.SortColumn switch { "Name" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.LastName).ThenBy(u => u.FirstName) : query.OrderByDescending(u => u.LastName).ThenByDescending(u => u.FirstName), "Email" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.Email) : query.OrderByDescending(u => u.Email), "IsActive" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.IsActive) : query.OrderByDescending(u => u.IsActive), "LastLoginDate" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.LastLoginDate) : query.OrderByDescending(u => u.LastLoginDate), "CreatedAt" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.CreatedAt) : query.OrderByDescending(u => u.CreatedAt), _ => query.OrderBy(u => u.LastName).ThenBy(u => u.FirstName) }; // Apply pagination var users = await query .Skip((gridRequest.PageNumber - 1) * gridRequest.PageSize) .Take(gridRequest.PageSize) .ToListAsync(); // Load company names for display var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true); var companyMap = allCompanies.ToDictionary(c => c.Id, c => c.CompanyName); // Build DTOs with role information var userDtos = new List(); foreach (var user in users) { var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); userDtos.Add(new PlatformUserListDto { Id = user.Id, Email = user.Email ?? "", FirstName = user.FirstName, LastName = user.LastName, EmployeeNumber = user.EmployeeNumber, Department = user.Department, Position = user.Position, IsSuperAdmin = isSuperAdmin, IsActive = user.IsActive, CompanyId = user.CompanyId, CompanyName = user.CompanyId > 0 && companyMap.TryGetValue(user.CompanyId, out var cName) ? cName : null, CompanyRole = user.CompanyRole, LastLoginDate = user.LastLoginDate, CreatedAt = user.CreatedAt }); } // Filter SuperAdmins if needed (post-query since it requires role check) if (filter == "superadmins") { userDtos = userDtos.Where(u => u.IsSuperAdmin).ToList(); totalCount = userDtos.Count; // Recalculate total for SuperAdmins } var pagedResult = PagedResult.From(gridRequest, userDtos, totalCount); // Set ViewBag for sorting and filters ViewBag.CurrentFilter = filter; ViewBag.SearchTerm = searchTerm; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving platform users"); TempData["Error"] = "An error occurred while loading users."; return View(new PagedResult()); } } /// /// Renders the form for creating a new SuperAdmin account. Pre-fills the department to /// "Platform" and position to "Super Administrator" as sensible defaults for platform /// staff, though these are editable. /// // GET: PlatformUsers/CreateSuperAdmin public IActionResult CreateSuperAdmin() { var model = new CreateSuperAdminDto { HireDate = DateTime.UtcNow, Department = "Platform", Position = "Super Administrator" }; return View(model); } /// /// Creates a new SuperAdmin Identity user. SuperAdmins have CompanyRole = null /// (distinguishing them from company-scoped users) and are assigned to the first available /// company record as a nominal home company — this company assignment is only used to satisfy /// the non-null CompanyId FK; it does not limit what data the SuperAdmin can see. /// All per-feature permission flags are set to true by default since SuperAdmins /// need unrestricted access. The ASP.NET Identity SuperAdmin role is applied after /// account creation. /// // POST: PlatformUsers/CreateSuperAdmin [HttpPost] [ValidateAntiForgeryToken] public async Task CreateSuperAdmin(CreateSuperAdminDto model) { if (!ModelState.IsValid) { return View(model); } if (model.Password != model.ConfirmPassword) { ModelState.AddModelError("ConfirmPassword", "Passwords do not match."); return View(model); } try { // Check if user already exists var existingUser = await _userManager.FindByEmailAsync(model.Email); if (existingUser != null) { ModelState.AddModelError("Email", "A user with this email already exists."); return View(model); } // Get default company for SuperAdmin assignment // SuperAdmins are assigned to a company but have no CompanyRole (can see all companies) var defaultCompany = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true)).FirstOrDefault(); if (defaultCompany == null) { ModelState.AddModelError("", "No companies exist. Please create a company first."); return View(model); } // Create SuperAdmin user var user = new ApplicationUser { UserName = model.Email, Email = model.Email, EmailConfirmed = true, FirstName = model.FirstName, LastName = model.LastName, EmployeeNumber = model.EmployeeNumber, CompanyId = defaultCompany.Id, // SuperAdmins are assigned to default company CompanyRole = null, // SuperAdmin doesn't have a company role Department = model.Department, Position = model.Position, PhoneNumber = model.Phone, HireDate = model.HireDate, IsActive = true, // SuperAdmins have all permissions by default CanManageJobs = true, CanManageInventory = true, CanManageCustomers = true, CanCreateQuotes = true, CanApproveQuotes = true }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { // Assign SuperAdmin role await _userManager.AddToRoleAsync(user, AppConstants.Roles.SuperAdmin); _logger.LogInformation("SuperAdmin user {Email} created by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"SuperAdmin '{user.FullName}' created successfully."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } else { foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); } return View(model); } } catch (Exception ex) { _logger.LogError(ex, "Error creating SuperAdmin user"); ModelState.AddModelError("", "An error occurred while creating the SuperAdmin user."); return View(model); } } /// /// Loads the edit form for a platform-level user. If the target user turns out to be a /// company user (not a SuperAdmin), the request is transparently forwarded to /// CompanyUsers/Edit with a returnUrl pointing back here, so a single entry /// point in the UI works for both user types. This approach means the platform users list /// can include CompanyAdmins without needing a separate edit link per user type. /// // GET: PlatformUsers/Edit/id public async Task Edit(string id) { if (string.IsNullOrEmpty(id)) { return NotFound(); } try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); // Company users are edited via CompanyUsers controller (which SuperAdmins can access) if (!isSuperAdmin) { var returnUrl = Url.Action(nameof(Index), "PlatformUsers"); return RedirectToAction("Edit", "CompanyUsers", new { id, returnUrl }); } var model = new UpdateSuperAdminDto { Id = user.Id, Email = user.Email ?? "", FirstName = user.FirstName, LastName = user.LastName, EmployeeNumber = user.EmployeeNumber, Department = user.Department, Position = user.Position, Phone = user.PhoneNumber, IsActive = user.IsActive, HireDate = user.HireDate, TerminationDate = user.TerminationDate }; return View(model); } catch (Exception ex) { _logger.LogError(ex, "Error loading SuperAdmin for edit: {UserId}", id); TempData["Error"] = "An error occurred while loading the user."; return RedirectToAction(nameof(Index)); } } /// /// Saves changes to a SuperAdmin's profile fields (name, contact, department, active flag). /// The root account () can only be edited by itself — any other /// SuperAdmin attempting to modify it is blocked with an error. This guard is intentional /// to preserve the break-glass account's integrity. /// // POST: PlatformUsers/Edit/id [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(string id, UpdateSuperAdminDto model) { if (id != model.Id) { return NotFound(); } if (!ModelState.IsValid) { return View(model); } try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); if (!isSuperAdmin) { TempData["Error"] = "This user is not a SuperAdmin."; return RedirectToAction(nameof(Index)); } // Root account can only be edited by itself if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true && !User.Identity!.Name!.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase)) { TempData["Error"] = "The root account cannot be modified."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } // Update user properties user.FirstName = model.FirstName; user.LastName = model.LastName; user.EmployeeNumber = model.EmployeeNumber; user.Department = model.Department; user.Position = model.Position; user.PhoneNumber = model.Phone; user.IsActive = model.IsActive; user.HireDate = model.HireDate; user.TerminationDate = model.TerminationDate; user.UpdatedAt = DateTime.UtcNow; var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { _logger.LogInformation("SuperAdmin {Email} updated by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = "SuperAdmin updated successfully."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } else { foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); } return View(model); } } catch (Exception ex) { _logger.LogError(ex, "Error updating SuperAdmin {UserId}", id); ModelState.AddModelError("", "An error occurred while updating the SuperAdmin."); return View(model); } } /// /// Shows a read-only details view for any platform-level user (SuperAdmin or CompanyAdmin). /// Exposes all per-feature permission flags through ViewBag so the view can render them /// without the flags needing to be part of the DTO — this avoids polluting /// with company-level /// concepts. For CompanyAdmins, the company name is resolved from a full cross-tenant lookup. /// // GET: PlatformUsers/Details/id public async Task Details(string id) { if (string.IsNullOrEmpty(id)) { return NotFound(); } try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); var model = new SuperAdminDetailsDto { Id = user.Id, Email = user.Email ?? "", FirstName = user.FirstName, LastName = user.LastName, EmployeeNumber = user.EmployeeNumber, Department = user.Department, Position = user.Position, Phone = user.PhoneNumber, IsActive = user.IsActive, IsBanned = user.IsBanned, BannedAt = user.BannedAt, BanReason = user.BanReason, EmailConfirmed = user.EmailConfirmed, HireDate = user.HireDate, TerminationDate = user.TerminationDate, LastLoginDate = user.LastLoginDate, CreatedAt = user.CreatedAt, UpdatedAt = user.UpdatedAt }; ViewBag.IsSuperAdmin = isSuperAdmin; ViewBag.CompanyRole = user.CompanyRole; // Load company name for company users if (!isSuperAdmin && user.CompanyId > 0) { var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true); var company = allCompanies.FirstOrDefault(c => c.Id == user.CompanyId); ViewBag.CompanyName = company?.CompanyName; } // Pass permission flags for company users so Details view can display them read-only ViewBag.CanManageJobs = user.CanManageJobs; ViewBag.CanManageInventory = user.CanManageInventory; ViewBag.CanManageCustomers = user.CanManageCustomers; ViewBag.CanCreateQuotes = user.CanCreateQuotes; ViewBag.CanApproveQuotes = user.CanApproveQuotes; ViewBag.CanManageCalendar = user.CanManageCalendar; ViewBag.CanViewCalendar = user.CanViewCalendar; ViewBag.CanManageProducts = user.CanManageProducts; ViewBag.CanViewProducts = user.CanViewProducts; ViewBag.CanManageEquipment = user.CanManageEquipment; ViewBag.CanManageVendors = user.CanManageVendors; ViewBag.CanManageMaintenance = user.CanManageMaintenance; return View(model); } catch (Exception ex) { _logger.LogError(ex, "Error loading user details: {UserId}", id); TempData["Error"] = "An error occurred while loading user details."; return RedirectToAction(nameof(Index)); } } /// /// Toggles the IsActive flag for a SuperAdmin or CompanyAdmin. Three hard guards /// apply: (1) the root account () can never be deactivated by /// anyone — including itself — because it is the break-glass recovery account; (2) a user /// cannot deactivate their own account; (3) these guards are code-level, not UI-level, to /// ensure they cannot be bypassed by crafting a direct POST. /// // POST: PlatformUsers/ToggleActive/id [HttpPost] [ValidateAntiForgeryToken] public async Task ToggleActive(string id) { try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } // Root account can never be deactivated — by anyone, including itself if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true) { TempData["Error"] = "The root account cannot be deactivated."; return RedirectToAction(nameof(Index)); } // Prevent disabling yourself var currentUser = await _userManager.GetUserAsync(User); if (currentUser?.Id == id && user.IsActive) { TempData["Error"] = "You cannot deactivate your own account."; return RedirectToAction(nameof(Index)); } user.IsActive = !user.IsActive; user.UpdatedAt = DateTime.UtcNow; var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { var status = user.IsActive ? "activated" : "deactivated"; _logger.LogInformation("User {Email} {Status} by {Admin}", user.Email, status, User.Identity?.Name); TempData["Success"] = $"User {status} successfully."; } else { TempData["Error"] = "Failed to update user status."; } } catch (Exception ex) { _logger.LogError(ex, "Error toggling user active status for {UserId}", id); TempData["Error"] = "An error occurred while updating the user status."; } return RedirectToAction(nameof(Index)); } /// /// Bans a user, preventing all future logins. The root account cannot be banned. /// A reason is required and is shown to the user on their next login attempt. /// [HttpPost] [ValidateAntiForgeryToken] public async Task BanUser(string id, string reason, string? returnUrl = null) { try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true) { TempData["Error"] = "The root account cannot be banned."; return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } var currentUser = await _userManager.GetUserAsync(User); if (currentUser?.Id == id) { TempData["Error"] = "You cannot ban your own account."; return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } user.IsBanned = true; user.BannedAt = DateTime.UtcNow; user.BanReason = reason?.Trim(); user.BannedByUserId = currentUser?.Id; user.UpdatedAt = DateTime.UtcNow; var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { _logger.LogWarning("User {Email} banned by {Admin}. Reason: {Reason}", user.Email, User.Identity?.Name, reason); TempData["Success"] = $"{user.Email} has been banned."; } else { TempData["Error"] = "Failed to ban user."; } } catch (Exception ex) { _logger.LogError(ex, "Error banning user {UserId}", id); TempData["Error"] = "An error occurred while banning the user."; } return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } /// /// Lifts a ban, restoring the user's ability to log in. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UnbanUser(string id, string? returnUrl = null) { try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } user.IsBanned = false; user.BannedAt = null; user.BanReason = null; user.BannedByUserId = null; user.UpdatedAt = DateTime.UtcNow; var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { _logger.LogInformation("User {Email} unbanned by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"{user.Email} has been unbanned."; } else { TempData["Error"] = "Failed to unban user."; } } catch (Exception ex) { _logger.LogError(ex, "Error unbanning user {UserId}", id); TempData["Error"] = "An error occurred while unbanning the user."; } return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } /// /// Renders the "Grant SuperAdmin" form page, which contains a live-search widget backed /// by to find existing company users to promote. /// // GET: PlatformUsers/GrantSuperAdmin public IActionResult GrantSuperAdmin() { return View(); } /// /// AJAX live-search endpoint for the Grant SuperAdmin form. Returns up to 50 company users /// whose name or email matches . Already-SuperAdmin users are filtered /// out after the initial LINQ query because IsInRoleAsync requires an async Identity /// call that cannot be translated to SQL. The response is intentionally limited to 50 results /// to keep the dropdown usable. /// // GET: PlatformUsers/SearchUsersForGrant public async Task SearchUsersForGrant(string? term = null) { try { // Query users who have a CompanyRole (i.e. not already SuperAdmins by field) var query = _userManager.Users.Where(u => u.CompanyRole != null); if (!string.IsNullOrWhiteSpace(term)) { var search = term.ToLower(); query = query.Where(u => (u.FirstName != null && u.FirstName.ToLower().Contains(search)) || (u.LastName != null && u.LastName.ToLower().Contains(search)) || (u.Email != null && u.Email.ToLower().Contains(search))); } var users = await query.Take(50).ToListAsync(); // Load company names var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true); var companyMap = allCompanies.ToDictionary(c => c.Id, c => c.CompanyName); var results = new List(); foreach (var user in users) { // Secondary check: skip any who are already SuperAdmin by role var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); if (isSuperAdmin) continue; companyMap.TryGetValue(user.CompanyId, out var companyName); results.Add(new { id = user.Id, fullName = user.FullName, email = user.Email, companyName = companyName ?? "", companyRole = user.CompanyRole ?? "", isActive = user.IsActive }); } return Json(results); } catch (Exception ex) { _logger.LogError(ex, "Error searching users for grant"); return Json(new List()); } } /// /// Promotes an existing company user to SuperAdmin. Adds the user to the /// SuperAdmin Identity role, clears their CompanyRole (marking them as a /// platform user), and grants all per-feature permission flags. If the Identity role /// assignment succeeds but the subsequent user record update fails, the role is rolled /// back to keep the system in a consistent state — preventing a partial-grant scenario /// where a user has the role claim but not the matching user-record flags. /// // POST: PlatformUsers/GrantSuperAdmin [HttpPost] [ValidateAntiForgeryToken] public async Task GrantSuperAdmin(string userId) { if (string.IsNullOrEmpty(userId)) { TempData["Error"] = "Invalid user ID."; return RedirectToAction(nameof(Index)); } try { var user = await _userManager.FindByIdAsync(userId); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(GrantSuperAdmin)); } // Guard: already a SuperAdmin? var isAlreadySuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); if (isAlreadySuperAdmin) { TempData["Error"] = $"{user.FullName} is already a SuperAdmin."; return RedirectToAction(nameof(GrantSuperAdmin)); } // Add to SuperAdmin role var addRoleResult = await _userManager.AddToRoleAsync(user, AppConstants.Roles.SuperAdmin); if (!addRoleResult.Succeeded) { TempData["Error"] = $"Failed to grant SuperAdmin role: {string.Join(", ", addRoleResult.Errors.Select(e => e.Description))}"; return RedirectToAction(nameof(GrantSuperAdmin)); } // Update user flags — CompanyRole = null marks as platform-level; all permissions = true user.CompanyRole = null; user.CanViewShopFloor = true; user.CanManageJobs = true; user.CanManageInventory = true; user.CanManageCustomers = true; user.CanCreateQuotes = true; user.CanApproveQuotes = true; user.CanManageCalendar = true; user.CanViewCalendar = true; user.CanManageProducts = true; user.CanViewProducts = true; user.CanManageEquipment = true; user.CanManageVendors = true; user.CanManageMaintenance = true; user.UpdatedAt = DateTime.UtcNow; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { // Roll back role assignment to keep consistency await _userManager.RemoveFromRoleAsync(user, AppConstants.Roles.SuperAdmin); TempData["Error"] = $"Failed to update user record: {string.Join(", ", updateResult.Errors.Select(e => e.Description))}. Role grant has been reversed."; return RedirectToAction(nameof(GrantSuperAdmin)); } _logger.LogInformation("SuperAdmin role granted to {Email} by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"SuperAdmin access granted to {user.FullName} successfully."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } catch (Exception ex) { _logger.LogError(ex, "Error granting SuperAdmin to user {UserId}", userId); TempData["Error"] = "An error occurred while granting SuperAdmin access."; return RedirectToAction(nameof(GrantSuperAdmin)); } } /// /// Revokes SuperAdmin access from a platform user and demotes them back to CompanyAdmin. /// Four safety checks run before any change is made: (1) a SuperAdmin cannot revoke /// themselves; (2) the root account () can never be revoked — /// by anyone, including itself; (3) the target must actually be a SuperAdmin; (4) at least /// one SuperAdmin must remain on the platform after revocation. If the role removal succeeds /// but the user record update fails, the role is restored to maintain consistency. The /// demoted user retains their company assignment so they regain access to their company's /// data as a CompanyAdmin. /// // POST: PlatformUsers/RevokeSuperAdmin [HttpPost] [ValidateAntiForgeryToken] public async Task RevokeSuperAdmin(string id) { if (string.IsNullOrEmpty(id)) { TempData["Error"] = "Invalid user ID."; return RedirectToAction(nameof(Index)); } try { // Safety 1: Can't revoke yourself var currentUser = await _userManager.GetUserAsync(User); if (currentUser?.Id == id) { TempData["Error"] = "You cannot revoke SuperAdmin access from your own account."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } // Root account SuperAdmin role can never be revoked — by anyone, including itself if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true) { TempData["Error"] = "The root account SuperAdmin role cannot be revoked."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } // Safety 2: Confirm they are actually a SuperAdmin var isSuperAdmin = await _userManager.IsInRoleAsync(user, AppConstants.Roles.SuperAdmin); if (!isSuperAdmin) { TempData["Error"] = $"{user.FullName} is not a SuperAdmin."; return RedirectToAction(nameof(Index)); } // Safety 3: Ensure at least one SuperAdmin remains after revocation var superAdmins = await _userManager.GetUsersInRoleAsync(AppConstants.Roles.SuperAdmin); if (superAdmins.Count <= 1) { TempData["Error"] = "Cannot revoke the last SuperAdmin. There must always be at least one SuperAdmin on the platform."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } // Remove from SuperAdmin role var removeResult = await _userManager.RemoveFromRoleAsync(user, AppConstants.Roles.SuperAdmin); if (!removeResult.Succeeded) { TempData["Error"] = $"Failed to revoke SuperAdmin role: {string.Join(", ", removeResult.Errors.Select(e => e.Description))}"; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } // Restore to CompanyAdmin so the user keeps access to their company user.CompanyRole = AppConstants.CompanyRoles.CompanyAdmin; user.UpdatedAt = DateTime.UtcNow; var updateResult = await _userManager.UpdateAsync(user); if (!updateResult.Succeeded) { // Attempt to restore role for consistency await _userManager.AddToRoleAsync(user, AppConstants.Roles.SuperAdmin); TempData["Error"] = $"Failed to update user record: {string.Join(", ", updateResult.Errors.Select(e => e.Description))}. Role revocation has been reversed."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } _logger.LogWarning("SuperAdmin role revoked from {Email} by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"SuperAdmin access revoked from {user.FullName}. They have been set to Company Admin."; return RedirectToAction(nameof(Index)); } catch (Exception ex) { _logger.LogError(ex, "Error revoking SuperAdmin from user {UserId}", id); TempData["Error"] = "An error occurred while revoking SuperAdmin access."; return RedirectToAction(nameof(Index), new { filter = "superadmins" }); } } /// /// Resets the password for a SuperAdmin or CompanyAdmin. Blocked for the root account /// () because that account's password must be managed through /// a separate secure channel. Uses remove-then-add rather than a token-based reset because /// this is an admin-initiated action. Respects an optional for /// callers that need to navigate back to a specific page (e.g., Companies/Details). /// // POST: PlatformUsers/ResetPassword/id [HttpPost] [ValidateAntiForgeryToken] public async Task ResetPassword(string id, string newPassword, string? returnUrl = null) { try { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } // Root account password cannot be reset via the UI if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true) { TempData["Error"] = "The root account cannot be modified."; return RedirectToAction(nameof(Index)); } // Remove old password var removeResult = await _userManager.RemovePasswordAsync(user); if (!removeResult.Succeeded) { TempData["Error"] = "Failed to reset password."; return RedirectToAction(nameof(Index)); } // Add new password var addResult = await _userManager.AddPasswordAsync(user, newPassword); if (addResult.Succeeded) { _logger.LogInformation("Password reset for user {Email} by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = "Password reset successfully."; } else { TempData["Error"] = $"Failed to reset password: {string.Join(", ", addResult.Errors.Select(e => e.Description))}"; } } catch (Exception ex) { _logger.LogError(ex, "Error resetting password for user {UserId}", id); TempData["Error"] = "An error occurred while resetting the password."; } if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return RedirectToAction(nameof(Index)); } /// /// Generates a password reset link and emails it to the SuperAdmin user. Mirrors the company /// user version but applies the root-account guard so artemis@ can never be triggered via UI. /// // POST: PlatformUsers/SendPasswordResetEmail/id [HttpPost] [ValidateAntiForgeryToken] public async Task SendPasswordResetEmail(string id, string? returnUrl = null) { var user = await _userManager.FindByIdAsync(id); if (user == null) { TempData["Error"] = "User not found."; return RedirectToAction(nameof(Index)); } if (user.Email?.Equals(RootUserEmail, StringComparison.OrdinalIgnoreCase) == true) { TempData["Error"] = "The root account cannot be modified."; return RedirectToAction(nameof(Index)); } try { var token = await _userManager.GeneratePasswordResetTokenAsync(user); var encodedToken = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token)); var resetUrl = $"{Request.Scheme}://{Request.Host}/Identity/Account/ResetPassword" + $"?code={Uri.EscapeDataString(encodedToken)}"; var firstName = user.FirstName ?? user.Email ?? "there"; var subject = "Password Reset — Powder Coating Logix"; var plain = $"Hi {firstName},\r\n\r\n" + $"A password reset was requested for your Powder Coating Logix account.\r\n\r\n" + $"Click the link below to set a new password:\r\n{resetUrl}\r\n\r\n" + $"This link expires in 24 hours. If you did not request this, you can safely ignore it.\r\n\r\n" + $"— The Powder Coating Logix Team"; var html = $"

Hi {System.Net.WebUtility.HtmlEncode(firstName)},

" + $"

A password reset was requested for your Powder Coating Logix account.

" + $"

Set New Password

" + $"

Or copy this link: {System.Net.WebUtility.HtmlEncode(resetUrl)}

" + $"

This link expires in 24 hours.

"; var (success, error) = await _emailService.SendEmailAsync( user.Email!, user.FullName, subject, plain, html); if (success) { _logger.LogInformation("Password reset email sent to {Email} by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"Password reset email sent to {user.Email}."; } else { _logger.LogWarning("Failed to send password reset email to {Email}: {Error}", user.Email, error); TempData["Error"] = "Failed to send email. Please try again or reset the password manually."; } } catch (Exception ex) { _logger.LogError(ex, "Error sending password reset email for user {UserId}", id); TempData["Error"] = "An error occurred while sending the reset email."; } if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) return Redirect(returnUrl); return RedirectToAction(nameof(Index)); } }