1a44133a63
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>
996 lines
43 KiB
C#
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));
|
|
}
|
|
}
|