Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/PlatformUsersController.cs
T
spouliot edd7389d7d Refactor: extract shared helpers, fix field drift, add assembly services
- IJobItemAssemblyService / IQuotePricingAssemblyService: centralize job item
  and quote pricing construction that was duplicated across create, rework copy,
  and quote-to-job conversion paths
- BlobFileHelper: single ValidateUpload/GetContentType/SanitizeFileName used by
  6 blob services (JobPhoto, QuotePhoto, ProfilePhoto, CompanyLogo, Equipment,
  Catalog) and BillsController + ExpensesController, removing 8 private copies
- PagedResult<T>.From(): static factory eliminates 6-line boilerplate in 11
  controllers (Appointments, Customers, Equipment, Inventory, Invoices, Jobs,
  Maintenance, CompanyUsers, PlatformUsers, Quotes, Vendors)
- AccountingDropdownHelper: single LoadAsync() call replaces duplicate
  vendor/account/job queries in BillsController and ExpensesController
- JobTemplateItem: add IsSalesItem + Sku fields with migration; propagate
  through JobTemplatesController snapshot copy and GetTemplatesJson projection,
  and JobsController template-application path
- Test assertions updated for standardized BlobFileHelper error messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:12:33 -04:00

1075 lines
44 KiB
C#

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;
/// <summary>
/// Controller for SuperAdmin platform user management
/// Only accessible by SuperAdmin role
/// </summary>
[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<ApplicationUser> _userManager;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<PlatformUsersController> _logger;
private readonly IEmailService _emailService;
public PlatformUsersController(
UserManager<ApplicationUser> userManager,
IUnitOfWork unitOfWork,
ILogger<PlatformUsersController> logger,
IEmailService emailService)
{
_userManager = userManager;
_unitOfWork = unitOfWork;
_logger = logger;
_emailService = emailService;
}
/// <summary>
/// Lists SuperAdmins and CompanyAdmins (users with <c>CompanyRole == null</c> or
/// <c>CompanyRole == CompanyAdmin</c>) 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 <c>IsInRoleAsync</c>) 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.
/// </summary>
// GET: PlatformUsers
public async Task<IActionResult> 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<PlatformUserListDto>();
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<PlatformUserListDto>.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<PlatformUserListDto>());
}
}
/// <summary>
/// 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.
/// </summary>
// GET: PlatformUsers/CreateSuperAdmin
public IActionResult CreateSuperAdmin()
{
var model = new CreateSuperAdminDto
{
HireDate = DateTime.UtcNow,
Department = "Platform",
Position = "Super Administrator"
};
return View(model);
}
/// <summary>
/// Creates a new SuperAdmin Identity user. SuperAdmins have <c>CompanyRole = null</c>
/// (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 <c>CompanyId</c> FK; it does not limit what data the SuperAdmin can see.
/// All per-feature permission flags are set to <c>true</c> by default since SuperAdmins
/// need unrestricted access. The ASP.NET Identity <c>SuperAdmin</c> role is applied after
/// account creation.
/// </summary>
// POST: PlatformUsers/CreateSuperAdmin
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// 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
/// <c>CompanyUsers/Edit</c> with a <c>returnUrl</c> 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.
/// </summary>
// GET: PlatformUsers/Edit/id
public async Task<IActionResult> 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));
}
}
/// <summary>
/// Saves changes to a SuperAdmin's profile fields (name, contact, department, active flag).
/// The root account (<see cref="RootUserEmail"/>) 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.
/// </summary>
// POST: PlatformUsers/Edit/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// 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
/// <see cref="PowderCoating.Application.DTOs.User.SuperAdminDetailsDto"/> with company-level
/// concepts. For CompanyAdmins, the company name is resolved from a full cross-tenant lookup.
/// </summary>
// GET: PlatformUsers/Details/id
public async Task<IActionResult> 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));
}
}
/// <summary>
/// Toggles the <c>IsActive</c> flag for a SuperAdmin or CompanyAdmin. Three hard guards
/// apply: (1) the root account (<see cref="RootUserEmail"/>) 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.
/// </summary>
// POST: PlatformUsers/ToggleActive/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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))!);
}
/// <summary>
/// Lifts a ban, restoring the user's ability to log in.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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))!);
}
/// <summary>
/// Renders the "Grant SuperAdmin" form page, which contains a live-search widget backed
/// by <see cref="SearchUsersForGrant"/> to find existing company users to promote.
/// </summary>
// GET: PlatformUsers/GrantSuperAdmin
public IActionResult GrantSuperAdmin()
{
return View();
}
/// <summary>
/// AJAX live-search endpoint for the Grant SuperAdmin form. Returns up to 50 company users
/// whose name or email matches <paramref name="term"/>. Already-SuperAdmin users are filtered
/// out after the initial LINQ query because <c>IsInRoleAsync</c> requires an async Identity
/// call that cannot be translated to SQL. The response is intentionally limited to 50 results
/// to keep the dropdown usable.
/// </summary>
// GET: PlatformUsers/SearchUsersForGrant
public async Task<IActionResult> 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<object>();
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<object>());
}
}
/// <summary>
/// Promotes an existing company user to SuperAdmin. Adds the user to the
/// <c>SuperAdmin</c> Identity role, clears their <c>CompanyRole</c> (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.
/// </summary>
// POST: PlatformUsers/GrantSuperAdmin
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
}
/// <summary>
/// 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 (<see cref="RootUserEmail"/>) 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.
/// </summary>
// POST: PlatformUsers/RevokeSuperAdmin
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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" });
}
}
/// <summary>
/// Resets the password for a SuperAdmin or CompanyAdmin. Blocked for the root account
/// (<see cref="RootUserEmail"/>) 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 <paramref name="returnUrl"/> for
/// callers that need to navigate back to a specific page (e.g., Companies/Details).
/// </summary>
// POST: PlatformUsers/ResetPassword/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
// POST: PlatformUsers/SendPasswordResetEmail/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 =
$"<p>Hi {System.Net.WebUtility.HtmlEncode(firstName)},</p>" +
$"<p>A password reset was requested for your Powder Coating Logix account.</p>" +
$"<p><a href=\"{System.Net.WebUtility.HtmlEncode(resetUrl)}\" " +
$"style=\"background:#0d6efd;color:#fff;padding:10px 20px;border-radius:4px;" +
$"text-decoration:none;display:inline-block\">Set New Password</a></p>" +
$"<p style=\"color:#6c757d;font-size:0.875em\">Or copy this link: {System.Net.WebUtility.HtmlEncode(resetUrl)}</p>" +
$"<p style=\"color:#6c757d;font-size:0.875em\">This link expires in 24 hours.</p>";
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));
}
}