edd7389d7d
- 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>
1075 lines
44 KiB
C#
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));
|
|
}
|
|
}
|