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 managing users within a company (CompanyAdmin only) /// [Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)] public class CompanyUsersController : Controller { private readonly UserManager _userManager; private readonly ITenantContext _tenantContext; private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly ISubscriptionService _subscriptionService; private readonly IEmailService _emailService; public CompanyUsersController( UserManager userManager, ITenantContext tenantContext, ILogger logger, IUnitOfWork unitOfWork, ISubscriptionService subscriptionService, IEmailService emailService) { _userManager = userManager; _tenantContext = tenantContext; _logger = logger; _unitOfWork = unitOfWork; _subscriptionService = subscriptionService; _emailService = emailService; } /// /// Lists users belonging to the current tenant company with search, sort, and pagination. /// The query filters by CompanyId and requires a non-null CompanyRole to /// exclude SuperAdmin platform users, who have CompanyRole == null and should never /// appear in a company's own user list. Non-SuperAdmin users without a valid CompanyId are /// rejected early with a redirect, preventing a data-access error from propagating to the view. /// // GET: CompanyUsers public async Task Index( string? searchTerm, string? roleFilter, string? sortColumn, string sortDirection = "asc", int pageNumber = 1, int pageSize = 25) { try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); _logger.LogInformation("CompanyUsers.Index - User: {User}, CompanyId: {CompanyId}, IsSuperAdmin: {IsSuperAdmin}", User.Identity?.Name, companyId, isSuperAdmin); // SECURITY: Non-SuperAdmin users MUST have a valid CompanyId if (!companyId.HasValue && !isSuperAdmin) { _logger.LogWarning("Non-SuperAdmin user {User} attempted to access CompanyUsers without a valid CompanyId", User.Identity?.Name); TempData["Error"] = "Unable to determine your company. Please contact support."; return RedirectToAction("Index", "Home"); } // If SuperAdmin and no company selected, show all users or redirect to company selection if (isSuperAdmin && !companyId.HasValue) { TempData["Info"] = "As SuperAdmin, please select a company to manage its users, or use the Users admin page for platform-wide user management."; return RedirectToAction("Index", "Home"); } // SECURITY: Ensure CompanyId has a value before querying if (!companyId.HasValue) { _logger.LogError("CompanyId is null after validation checks - this should never happen. User: {User}", User.Identity?.Name); TempData["Error"] = "An error occurred determining your company access."; return RedirectToAction("Index", "Home"); } // Create and validate grid request var gridRequest = new GridRequest { PageNumber = pageNumber, PageSize = pageSize, SortColumn = sortColumn ?? "Name", SortDirection = sortDirection, SearchTerm = searchTerm }; gridRequest.Validate(); // Build query for company users (excluding SuperAdmins) var query = _userManager.Users .Where(u => u.CompanyId == companyId.Value && 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)) || (u.Department != null && u.Department.ToLower().Contains(search))); } // Apply role filter if (!string.IsNullOrWhiteSpace(roleFilter)) { query = query.Where(u => u.CompanyRole == roleFilter); } // 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), "CompanyRole" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.CompanyRole) : query.OrderByDescending(u => u.CompanyRole), "Department" => gridRequest.SortDirection == "asc" ? query.OrderBy(u => u.Department) : query.OrderByDescending(u => u.Department), "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), _ => 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(); _logger.LogInformation("Retrieved {Count} company users (excluding platform users) for company {CompanyId}", users.Count, companyId.Value); // Map to DTOs var userDtos = users.Select(u => new CompanyUserListDto { Id = u.Id, Email = u.Email ?? "", FirstName = u.FirstName, LastName = u.LastName, CompanyRole = u.CompanyRole, Department = u.Department, IsActive = u.IsActive, IsBanned = u.IsBanned, HireDate = u.HireDate, LastLoginDate = u.LastLoginDate }).ToList(); var pagedResult = PagedResult.From(gridRequest, userDtos, totalCount); // Set ViewBag for sorting and filters ViewBag.SearchTerm = searchTerm; ViewBag.RoleFilter = roleFilter; ViewBag.SortColumn = gridRequest.SortColumn; ViewBag.SortDirection = gridRequest.SortDirection; return View(pagedResult); } catch (Exception ex) { _logger.LogError(ex, "Error retrieving company users"); TempData["Error"] = "An error occurred while loading users."; return View(new PagedResult()); } } /// /// Renders the new-user form after checking the subscription user-count limit. If the /// plan limit is already reached, the user is redirected to the index with an explanatory /// message rather than allowing the form to be submitted and fail later. SuperAdmins bypass /// the limit check so platform operators are never locked out. /// // GET: CompanyUsers/Create public async Task Create() { var isSuperAdmin = _tenantContext.IsSuperAdmin(); var companyId = _tenantContext.GetCurrentCompanyId(); if (!isSuperAdmin && companyId.HasValue && !await _subscriptionService.CanAddUserAsync(companyId.Value)) { var (used, max) = await _subscriptionService.GetUserCountAsync(companyId.Value); TempData["Error"] = $"You have reached your plan limit of {max} users. " + "Please upgrade your plan to add more users."; return RedirectToAction(nameof(Index)); } var model = new CreateCompanyUserDto { HireDate = DateTime.UtcNow, CompanyRole = AppConstants.CompanyRoles.Viewer }; return View(model); } /// /// Creates a new company user, enforcing the subscription user-count limit and a whitelist /// of valid CompanyRole values (preventing callers from submitting a null role to /// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all /// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy /// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned /// to satisfy policy checks that still reference the role system. /// // POST: CompanyUsers/Create [HttpPost] [ValidateAntiForgeryToken] public async Task Create(CreateCompanyUserDto model) { if (!ModelState.IsValid) { return View(model); } try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); if (!companyId.HasValue && !isSuperAdmin) { TempData["Error"] = "Unable to determine your company."; return RedirectToAction(nameof(Index)); } if (isSuperAdmin && !companyId.HasValue) { TempData["Error"] = "Please select a company context to create users."; return RedirectToAction(nameof(Index)); } // Subscription limit check (skip for SuperAdmin) if (!isSuperAdmin && companyId.HasValue) { if (!await _subscriptionService.CanAddUserAsync(companyId.Value)) { var (used, max) = await _subscriptionService.GetUserCountAsync(companyId.Value); ModelState.AddModelError(string.Empty, $"You have reached your plan limit of {max} users. " + "Please upgrade your plan to add more users."); return View(model); } } // SECURITY: Ensure CompanyRole is a valid company role (not null/empty, which would create a platform user) var validCompanyRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Accountant, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer }; if (string.IsNullOrWhiteSpace(model.CompanyRole) || !validCompanyRoles.Contains(model.CompanyRole)) { ModelState.AddModelError("CompanyRole", "Invalid company role selected."); return View(model); } // 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); } // Company Admins always get all permissions (unless a SuperAdmin is explicitly customizing them) var isCompanyAdmin = model.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin; var forceAllPermissions = isCompanyAdmin && !isSuperAdmin; // Create user var user = new ApplicationUser { UserName = model.Email, Email = model.Email, EmailConfirmed = true, FirstName = model.FirstName, LastName = model.LastName, EmployeeNumber = model.EmployeeNumber, CompanyId = companyId!.Value, CompanyRole = model.CompanyRole, Department = model.Department, Position = model.Position, PhoneNumber = model.Phone, HireDate = model.HireDate, IsActive = true, // SuperAdmins can set individual permissions even for CompanyAdmin users CanManageJobs = forceAllPermissions || model.CanManageJobs, CanManageInventory = forceAllPermissions || model.CanManageInventory, CanManageCustomers = forceAllPermissions || model.CanManageCustomers, CanCreateQuotes = forceAllPermissions || model.CanCreateQuotes, CanApproveQuotes = forceAllPermissions || model.CanApproveQuotes, CanManageCalendar = forceAllPermissions || model.CanManageCalendar, CanViewCalendar = forceAllPermissions || model.CanViewCalendar, CanManageProducts = forceAllPermissions || model.CanManageProducts, CanViewProducts = forceAllPermissions || model.CanViewProducts, CanManageEquipment = forceAllPermissions || model.CanManageEquipment, CanManageVendors = forceAllPermissions || model.CanManageVendors, CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance, CanManageInvoices = forceAllPermissions || model.CanManageInvoices, CanViewReports = forceAllPermissions || model.CanViewReports, CanManageBills = forceAllPermissions || model.CanManageBills, CanManageAccounting = forceAllPermissions || model.CanManageAccounting }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { // Assign legacy role based on company role var legacyRole = model.CompanyRole switch { AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator, AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager, AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee, AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee, _ => AppConstants.Roles.ReadOnly }; await _userManager.AddToRoleAsync(user, legacyRole); _logger.LogInformation("User {Email} created successfully by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = $"User '{user.FullName}' created successfully."; return RedirectToAction(nameof(Index)); } else { foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); } return View(model); } } catch (Exception ex) { _logger.LogError(ex, "Error creating user"); ModelState.AddModelError("", "An error occurred while creating the user."); return View(model); } } /// /// Loads the user edit form. Blocks access to platform users (SuperAdmins, identified by /// CompanyRole == null) and to users in other companies, even for SuperAdmins who /// happen to be visiting via a company context, because cross-company user edits are done /// through PlatformUsersController. The optional allows /// callers such as the Companies/Details view to receive the user back after the edit. /// // GET: CompanyUsers/Edit/id public async Task Edit(string id, string? returnUrl = null) { if (string.IsNullOrEmpty(id)) { return NotFound(); } try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); if (!companyId.HasValue && !isSuperAdmin) { TempData["Error"] = "Unable to determine your company."; return RedirectToAction(nameof(Index)); } var user = await _userManager.FindByIdAsync(id); // SECURITY: Prevent access to platform users (SuperAdmins) and users from other companies if (user == null || (!isSuperAdmin && user.CompanyId != companyId!.Value) || user.CompanyRole == null) // CompanyRole == null indicates platform user (SuperAdmin) { TempData["Error"] = "User not found or access denied."; return RedirectToAction(nameof(Index)); } var model = new UpdateCompanyUserDto { Id = user.Id, Email = user.Email ?? "", FirstName = user.FirstName, LastName = user.LastName, EmployeeNumber = user.EmployeeNumber, CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer, Department = user.Department, Position = user.Position, LaborCostPerHour = user.LaborCostPerHour, Phone = user.PhoneNumber, IsActive = user.IsActive, HireDate = user.HireDate, TerminationDate = user.TerminationDate, CanManageJobs = user.CanManageJobs, CanManageInventory = user.CanManageInventory, CanManageCustomers = user.CanManageCustomers, CanCreateQuotes = user.CanCreateQuotes, CanApproveQuotes = user.CanApproveQuotes, CanManageCalendar = user.CanManageCalendar, CanViewCalendar = user.CanViewCalendar, CanManageProducts = user.CanManageProducts, CanViewProducts = user.CanViewProducts, CanManageEquipment = user.CanManageEquipment, CanManageVendors = user.CanManageVendors, CanManageMaintenance = user.CanManageMaintenance, CanManageInvoices = user.CanManageInvoices, CanViewReports = user.CanViewReports, CanManageBills = user.CanManageBills, CanManageAccounting = user.CanManageAccounting }; ViewBag.ReturnUrl = returnUrl; ViewBag.IsSuperAdmin = isSuperAdmin; return View(model); } catch (Exception ex) { _logger.LogError(ex, "Error loading user for edit: {UserId}", id); TempData["Error"] = "An error occurred while loading the user."; return RedirectToAction(nameof(Index)); } } /// /// Saves changes to an existing company user. Validates company isolation and role whitelist /// (same checks as ). Prevents two dangerous deactivation /// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin /// for a company (which would lock out the tenant). Email changes are applied via /// SetEmailAsync / SetUserNameAsync after the main update so Identity's own /// normalisation logic runs correctly. /// // POST: CompanyUsers/Edit/id [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(string id, UpdateCompanyUserDto model, string? returnUrl = null) { if (id != model.Id) { return NotFound(); } if (!ModelState.IsValid) { ViewBag.ReturnUrl = returnUrl; return View(model); } try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); if (!companyId.HasValue && !isSuperAdmin) { TempData["Error"] = "Unable to determine your company."; return RedirectToAction(nameof(Index)); } var user = await _userManager.FindByIdAsync(id); // SECURITY: Prevent access to platform users (SuperAdmins) and users from other companies if (user == null || (!isSuperAdmin && user.CompanyId != companyId!.Value) || user.CompanyRole == null) // CompanyRole == null indicates platform user (SuperAdmin) { TempData["Error"] = "User not found or access denied."; return RedirectToAction(nameof(Index)); } var oldEmail = user.Email ?? string.Empty; var emailChanged = !string.Equals(model.Email, oldEmail, StringComparison.OrdinalIgnoreCase); // If email is changing, verify the new address isn't already taken if (emailChanged) { var existing = await _userManager.FindByEmailAsync(model.Email); if (existing != null) { ModelState.AddModelError("Email", "A user with this email already exists."); ViewBag.ReturnUrl = returnUrl; ViewBag.IsSuperAdmin = isSuperAdmin; return View(model); } } // SECURITY: Ensure CompanyRole is a valid company role (prevent elevation to platform user) var validCompanyRoles = new[] { AppConstants.CompanyRoles.CompanyAdmin, AppConstants.CompanyRoles.Manager, AppConstants.CompanyRoles.Accountant, AppConstants.CompanyRoles.Worker, AppConstants.CompanyRoles.Viewer }; if (string.IsNullOrWhiteSpace(model.CompanyRole) || !validCompanyRoles.Contains(model.CompanyRole)) { ModelState.AddModelError("CompanyRole", "Invalid company role selected."); ViewBag.ReturnUrl = returnUrl; return View(model); } // Company Admins always get all permissions (unless a SuperAdmin is explicitly customizing them) var isCompanyAdmin = model.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin; var forceAllPermissions = isCompanyAdmin && !isSuperAdmin; // Guards when attempting to deactivate an active user if (!model.IsActive && user.IsActive) { // Guard 1: cannot deactivate your own account if (user.Id == _userManager.GetUserId(User)) { ModelState.AddModelError("IsActive", "You cannot deactivate your own account."); ViewBag.ReturnUrl = returnUrl; return View(model); } // Guard 2: at least one active CompanyAdmin must remain if (user.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin) { var activeAdminCount = _userManager.Users.Count(u => u.CompanyId == user.CompanyId && u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin && u.IsActive && u.Id != user.Id); if (activeAdminCount == 0) { ModelState.AddModelError("IsActive", "Cannot deactivate this user. At least one active Company Admin must remain."); ViewBag.ReturnUrl = returnUrl; return View(model); } } } // Update user properties user.FirstName = model.FirstName; user.LastName = model.LastName; user.EmployeeNumber = model.EmployeeNumber; user.CompanyRole = model.CompanyRole; user.Department = model.Department; user.Position = model.Position; user.LaborCostPerHour = model.LaborCostPerHour; user.PhoneNumber = model.Phone; user.IsActive = model.IsActive; user.HireDate = model.HireDate; user.TerminationDate = model.TerminationDate; // SuperAdmins can set individual permissions even for CompanyAdmin users user.CanManageJobs = forceAllPermissions || model.CanManageJobs; user.CanManageInventory = forceAllPermissions || model.CanManageInventory; user.CanManageCustomers = forceAllPermissions || model.CanManageCustomers; user.CanCreateQuotes = forceAllPermissions || model.CanCreateQuotes; user.CanApproveQuotes = forceAllPermissions || model.CanApproveQuotes; user.CanManageCalendar = forceAllPermissions || model.CanManageCalendar; user.CanViewCalendar = forceAllPermissions || model.CanViewCalendar; user.CanManageProducts = forceAllPermissions || model.CanManageProducts; user.CanViewProducts = forceAllPermissions || model.CanViewProducts; user.CanManageEquipment = forceAllPermissions || model.CanManageEquipment; user.CanManageVendors = forceAllPermissions || model.CanManageVendors; user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance; user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices; user.CanViewReports = forceAllPermissions || model.CanViewReports; user.CanManageBills = forceAllPermissions || model.CanManageBills; user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting; user.UpdatedAt = DateTime.UtcNow; var result = await _userManager.UpdateAsync(user); if (result.Succeeded) { // Apply email/username change if requested if (emailChanged) { await _userManager.SetEmailAsync(user, model.Email); await _userManager.SetUserNameAsync(user, model.Email); _logger.LogInformation("Email changed for user {UserId} from {OldEmail} to {NewEmail} by {Admin}", user.Id, oldEmail, model.Email, User.Identity?.Name); } _logger.LogInformation("User {Email} updated successfully by {Admin}", user.Email, User.Identity?.Name); TempData["Success"] = "User updated successfully."; // Redirect to returnUrl if provided, otherwise Index if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } return RedirectToAction(nameof(Index)); } else { foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); } ViewBag.ReturnUrl = returnUrl; ViewBag.IsSuperAdmin = isSuperAdmin; return View(model); } } catch (Exception ex) { _logger.LogError(ex, "Error updating user {UserId}", id); ModelState.AddModelError("", "An error occurred while updating the user."); ViewBag.ReturnUrl = returnUrl; return View(model); } } /// /// Toggles a company user's IsActive flag. Applies the same self-deactivation and /// last-admin guards as the Edit POST. After the toggle, the referer is inspected to redirect /// back to Companies/Details if that's where the request originated, providing a smoother /// SuperAdmin workflow. The referer host is validated against the current request host to /// prevent open-redirect attacks. /// // POST: CompanyUsers/ToggleActive/id [HttpPost] [ValidateAntiForgeryToken] public async Task ToggleActive(string id) { try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); if (!companyId.HasValue && !isSuperAdmin) { TempData["Error"] = "Unable to determine your company."; return RedirectToAction(nameof(Index)); } var user = await _userManager.FindByIdAsync(id); // SECURITY: Prevent access to platform users (SuperAdmins) and users from other companies if (user == null || (!isSuperAdmin && user.CompanyId != companyId!.Value) || user.CompanyRole == null) // CompanyRole == null indicates platform user (SuperAdmin) { TempData["Error"] = "User not found or access denied."; return RedirectToAction(nameof(Index)); } // Guards only apply when deactivating (not reactivating) if (user.IsActive) { // Guard 1: cannot deactivate your own account if (user.Id == _userManager.GetUserId(User)) { TempData["Error"] = "You cannot deactivate your own account."; return RedirectToAction(nameof(Index)); } // Guard 2: at least one active CompanyAdmin must remain if (user.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin) { var activeAdminCount = _userManager.Users.Count(u => u.CompanyId == user.CompanyId && u.CompanyRole == AppConstants.CompanyRoles.CompanyAdmin && u.IsActive && u.Id != user.Id); if (activeAdminCount == 0) { TempData["Error"] = "Cannot deactivate this user. At least one active Company Admin must remain."; 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."; } // Redirect back to Companies/Details if that's where the action came from. // Validate the referer is same-host to prevent open redirect. var referer = Request.Headers["Referer"].ToString(); if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refUri) && string.Equals(refUri.Host, Request.Host.Host, StringComparison.OrdinalIgnoreCase) && refUri.AbsolutePath.StartsWith("/Companies/Details/", StringComparison.OrdinalIgnoreCase)) { return LocalRedirect(refUri.PathAndQuery); } return RedirectToAction(nameof(Index)); } /// /// Bans a company user, preventing all future logins. SuperAdmins can ban any company user; /// company admins can ban users within their own company. A ban reason is required. /// [HttpPost] [ValidateAntiForgeryToken] public async Task BanUser(string id, string reason, string? returnUrl = null) { try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); var user = await _userManager.FindByIdAsync(id); if (user == null || user.CompanyRole == null || (!isSuperAdmin && user.CompanyId != companyId)) { TempData["Error"] = "User not found or access denied."; return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } if (user.Id == _userManager.GetUserId(User)) { 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 = _userManager.GetUserId(User); 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."; } return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } /// /// Lifts a ban on a company user, restoring their ability to log in. /// [HttpPost] [ValidateAntiForgeryToken] public async Task UnbanUser(string id, string? returnUrl = null) { try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); var user = await _userManager.FindByIdAsync(id); if (user == null || user.CompanyRole == null || (!isSuperAdmin && user.CompanyId != companyId)) { TempData["Error"] = "User not found or access denied."; return Redirect(returnUrl ?? Url.Action(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."; } return Redirect(returnUrl ?? Url.Action(nameof(Index))!); } /// /// Resets the password for a company user. Blocked for platform users (SuperAdmins) and /// users belonging to other companies. The old password hash is removed and the new one /// set via AddPasswordAsync rather than using a token-based reset flow, as this /// is an admin-initiated action rather than a user self-service one. After the operation, /// the response respects the referer to redirect back to Companies/Details if applicable. /// // POST: CompanyUsers/ResetPassword/id [HttpPost] [ValidateAntiForgeryToken] public async Task ResetPassword(string id, string newPassword) { try { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); if (!companyId.HasValue && !isSuperAdmin) { TempData["Error"] = "Unable to determine your company."; return RedirectToAction(nameof(Index)); } var user = await _userManager.FindByIdAsync(id); // SECURITY: Prevent access to platform users (SuperAdmins) and users from other companies if (user == null || (!isSuperAdmin && user.CompanyId != companyId!.Value) || user.CompanyRole == null) // CompanyRole == null indicates platform user (SuperAdmin) { TempData["Error"] = "User not found or access denied."; 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."; } // Redirect back to Companies/Details if that's where the action came from. // Validate the referer is same-host to prevent open redirect. var referer = Request.Headers["Referer"].ToString(); if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refUri) && string.Equals(refUri.Host, Request.Host.Host, StringComparison.OrdinalIgnoreCase) && refUri.AbsolutePath.StartsWith("/Companies/Details/", StringComparison.OrdinalIgnoreCase)) { return LocalRedirect(refUri.PathAndQuery); } return RedirectToAction(nameof(Index)); } /// /// Generates a password reset link and emails it to the user. Intended for admins who need /// to unblock a user who cannot log in (e.g., wrong email at signup, lost password). /// Uses ASP.NET Identity's standard token so the user sets their own password via the reset page. /// // POST: CompanyUsers/SendPasswordResetEmail/id [HttpPost] [ValidateAntiForgeryToken] public async Task SendPasswordResetEmail(string id) { var companyId = _tenantContext.GetCurrentCompanyId(); var isSuperAdmin = _tenantContext.IsSuperAdmin(); var user = await _userManager.FindByIdAsync(id); if (user == null || (!isSuperAdmin && user.CompanyId != companyId) || user.CompanyRole == null) { TempData["Error"] = "User not found or access denied."; 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."; } var referer = Request.Headers["Referer"].ToString(); if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refUri) && string.Equals(refUri.Host, Request.Host.Host, StringComparison.OrdinalIgnoreCase)) { return LocalRedirect(refUri.PathAndQuery); } return RedirectToAction(nameof(Index)); } }