Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CompanyUsersController.cs
T
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00

996 lines
43 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 managing users within a company (CompanyAdmin only)
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class CompanyUsersController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CompanyUsersController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ISubscriptionService _subscriptionService;
private readonly IEmailService _emailService;
public CompanyUsersController(
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
ILogger<CompanyUsersController> logger,
IUnitOfWork unitOfWork,
ISubscriptionService subscriptionService,
IEmailService emailService)
{
_userManager = userManager;
_tenantContext = tenantContext;
_logger = logger;
_unitOfWork = unitOfWork;
_subscriptionService = subscriptionService;
_emailService = emailService;
}
/// <summary>
/// Lists users belonging to the current tenant company with search, sort, and pagination.
/// The query filters by <c>CompanyId</c> and requires a non-null <c>CompanyRole</c> to
/// exclude SuperAdmin platform users, who have <c>CompanyRole == null</c> 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.
/// </summary>
// GET: CompanyUsers
public async Task<IActionResult> 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<CompanyUserListDto>.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<CompanyUserListDto>());
}
}
/// <summary>
/// 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.
/// </summary>
// GET: CompanyUsers/Create
public async Task<IActionResult> 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);
}
/// <summary>
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
/// of valid <c>CompanyRole</c> 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.
/// </summary>
// POST: CompanyUsers/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Loads the user edit form. Blocks access to platform users (SuperAdmins, identified by
/// <c>CompanyRole == null</c>) 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 <c>PlatformUsersController</c>. The optional <paramref name="returnUrl"/> allows
/// callers such as the Companies/Details view to receive the user back after the edit.
/// </summary>
// GET: CompanyUsers/Edit/id
public async Task<IActionResult> 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));
}
}
/// <summary>
/// Saves changes to an existing company user. Validates company isolation and role whitelist
/// (same checks as <see cref="Edit(string, string)"/>). 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
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
/// normalisation logic runs correctly.
/// </summary>
// POST: CompanyUsers/Edit/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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);
}
}
/// <summary>
/// Toggles a company user's <c>IsActive</c> 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.
/// </summary>
// POST: CompanyUsers/ToggleActive/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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))!);
}
/// <summary>
/// Lifts a ban on a company user, restoring their ability to log in.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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))!);
}
/// <summary>
/// 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 <c>AddPasswordAsync</c> 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.
/// </summary>
// POST: CompanyUsers/ResetPassword/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}
/// <summary>
/// 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.
/// </summary>
// POST: CompanyUsers/SendPasswordResetEmail/id
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 =
$"<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.";
}
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));
}
}