Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/CompaniesController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

895 lines
37 KiB
C#

using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Controller for managing companies (SuperAdmin only)
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class CompaniesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISeedDataService _seedDataService;
private readonly ICompanyListService _companyList;
private readonly ICompanyDataPurgeService _companyPurge;
private readonly IAuditLogService _auditLog;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<CompaniesController> _logger;
public CompaniesController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ICompanyListService companyList,
ICompanyDataPurgeService companyPurge,
IAuditLogService auditLog,
IInAppNotificationService inApp,
ILogger<CompaniesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_seedDataService = seedDataService;
_companyList = companyList;
_companyPurge = companyPurge;
_auditLog = auditLog;
_inApp = inApp;
_logger = logger;
}
/// <summary>
/// Lists all non-deleted tenant companies with search, sort, and pagination. Bypasses the
/// global multi-tenancy query filter with <c>IgnoreQueryFilters()</c> because SuperAdmins
/// need a cross-company view. Job, quote, and customer counts are fetched in three separate
/// <c>GROUP BY</c> queries rather than loading related collections, avoiding N+1 behaviour
/// on large tenants. The current impersonation context is surfaced to the view so the UI
/// can highlight the company being impersonated.
/// </summary>
// GET: Companies
public async Task<IActionResult> Index(
string? searchTerm,
string sortColumn = "CompanyName",
string sortDirection = "asc",
int pageNumber = 1,
int pageSize = 25)
{
try
{
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var (companies, totalCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
if (companyDtos.Count > 0)
{
var ids = companyDtos.Select(c => c.Id).ToList();
var summary = await _companyList.GetCountSummaryAsync(ids);
foreach (var dto in companyDtos)
{
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
{
dto.WizardCompleted = true;
dto.WizardCompletedAt = w.CompletedAt;
dto.WizardCompletedByName = w.CompletedByName;
}
}
}
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
ViewBag.SearchTerm = searchTerm;
ViewBag.SortColumn = sortColumn;
ViewBag.SortDirection = sortDirection;
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
return View(companyDtos);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving companies list");
TempData["Error"] = "An error occurred while loading companies.";
return View(new List<CompanyListDto>());
}
}
/// <summary>
/// Begins a SuperAdmin impersonation session for the specified company. Stores the target
/// company ID and name in the server-side session so that <see cref="ITenantContext"/> resolves
/// to that company for the duration of the session. The action is intentionally logged at
/// Warning level because impersonation is a privileged escalation that should appear in
/// audit trails without requiring a full audit-log query.
/// </summary>
// POST: Companies/StartImpersonating
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> StartImpersonating(int companyId)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null || company.IsDeleted)
return NotFound();
HttpContext.Session.SetInt32("ImpersonatingCompanyId", companyId);
HttpContext.Session.SetString("ImpersonatingCompanyName", company.CompanyName);
_logger.LogWarning("SuperAdmin {User} started impersonating company {Id} ({Name})",
User.Identity?.Name, companyId, company.CompanyName);
TempData["SuccessMessage"] = $"Now impersonating {company.CompanyName}. All data is now scoped to this company.";
return RedirectToAction("Index", "Dashboard");
}
/// <summary>
/// Ends the current impersonation session by removing the company ID and name keys from the
/// server-side session, restoring the SuperAdmin to the full platform view. The company name
/// is captured before removal so it can appear in the success message even after the session
/// key is gone.
/// </summary>
// POST: Companies/StopImpersonating
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult StopImpersonating()
{
var name = HttpContext.Session.GetString("ImpersonatingCompanyName") ?? "company";
HttpContext.Session.Remove("ImpersonatingCompanyId");
HttpContext.Session.Remove("ImpersonatingCompanyName");
_logger.LogInformation("SuperAdmin {User} stopped impersonating {Name}",
User.Identity?.Name, name);
TempData["SuccessMessage"] = "Impersonation ended. You are back to full platform view.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Shows the full details page for a single tenant company, including its user list,
/// customers, and jobs. Uses <c>ignoreQueryFilters: true</c> so deleted companies can
/// still be inspected by a SuperAdmin without needing to manually bypass the soft-delete
/// filter.
/// </summary>
// GET: Companies/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var company = await _unitOfWork.Companies
.GetByIdAsync(id, ignoreQueryFilters: true,
c => c.Users,
c => c.Customers,
c => c.Jobs);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var companyDto = _mapper.Map<CompanyDto>(company);
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(companyDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving company details for ID {CompanyId}", id);
TempData["Error"] = "An error occurred while loading company details.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Renders the new-company form pre-filled with today's subscription start date and
/// <c>IsActive = true</c>. Available subscription plans are loaded from
/// <c>SubscriptionPlanConfig</c> so the dropdown stays in sync with whatever plans are
/// currently active in the database.
/// </summary>
// GET: Companies/Create
public async Task<IActionResult> Create()
{
var model = new CreateCompanyDto
{
SubscriptionStartDate = DateTime.UtcNow,
IsActive = true
};
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
/// <summary>
/// Creates a new tenant company together with its first CompanyAdmin user account. The company
/// record is saved first so its generated ID can be assigned back to <c>Company.CompanyId</c>
/// (a self-referencing FK used internally). Company-specific lookup tables (job statuses,
/// priorities, etc.) are seeded immediately via <see cref="ISeedDataService.SeedCompanyLookupsAsync"/>
/// so the new tenant can start using the app without a separate seeding step. If user creation
/// fails after the company is saved, the company record is kept and a warning is shown — a
/// full rollback is not attempted to avoid leaving orphaned ASP.NET Identity records.
/// </summary>
// POST: Companies/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreateCompanyDto model)
{
if (!ModelState.IsValid)
{
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
try
{
// Check if company code is unique
if (!string.IsNullOrWhiteSpace(model.CompanyCode))
{
var existingCompany = await _unitOfWork.Companies
.FindAsync(c => c.CompanyCode == model.CompanyCode, ignoreQueryFilters: true);
if (existingCompany.Any())
{
ModelState.AddModelError("CompanyCode", "A company with this code already exists.");
return View(model);
}
}
// Create company
var company = _mapper.Map<Company>(model);
company.CompanyId = 0; // Will be set after creation
await _unitOfWork.Companies.AddAsync(company);
await _unitOfWork.CompleteAsync();
// Update self-reference
company.CompanyId = company.Id;
await _unitOfWork.CompleteAsync();
// Seed lookup tables for the new company
_logger.LogInformation("Seeding lookup tables for new company {CompanyName}", company.CompanyName);
var seedResult = await _seedDataService.SeedCompanyLookupsAsync(company.Id);
if (!seedResult.Success)
{
_logger.LogWarning("Failed to seed lookup tables for company {CompanyName}: {Message}",
company.CompanyName, seedResult.Message);
}
else
{
_logger.LogInformation("Successfully seeded {Count} lookup items for company {CompanyName}",
seedResult.ItemsSeeded, company.CompanyName);
}
// Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.AdminEmail,
Email = model.AdminEmail,
EmailConfirmed = true,
FirstName = model.AdminFirstName,
LastName = model.AdminLastName,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.CompanyAdmin,
IsActive = true,
HireDate = DateTime.UtcNow,
Department = "Management",
Position = "Company Administrator",
};
adminUser.GrantAllPermissions();
var result = await _userManager.CreateAsync(adminUser, model.AdminPassword);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(adminUser, AppConstants.Roles.Administrator);
_logger.LogInformation("Company {CompanyName} created successfully by {User}",
company.CompanyName, User.Identity?.Name);
_ = _inApp.CreateForSuperAdminsAsync(
"Company Created",
$"{company.CompanyName} was created manually by {User.Identity?.Name}.",
"NewCompany",
$"/Companies/Details/{company.Id}");
TempData["Success"] = $"Company '{company.CompanyName}' and admin user created successfully.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
else
{
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
TempData["Warning"] = $"Company created but admin user creation failed: {string.Join(", ", result.Errors.Select(e => e.Description))}";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating company");
ModelState.AddModelError("", "An error occurred while creating the company.");
return View(model);
}
}
/// <summary>
/// Loads the company edit form. Uses <c>ignoreQueryFilters: true</c> so SuperAdmins can
/// edit a company that has been soft-deleted (e.g., to reactivate it).
/// </summary>
// GET: Companies/Edit/5
public async Task<IActionResult> Edit(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var model = _mapper.Map<UpdateCompanyDto>(company);
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading company for edit: {CompanyId}", id);
TempData["Error"] = "An error occurred while loading the company.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Saves changes to an existing company. Validates that any changed company code remains
/// unique across all tenants (excluding the record being edited). AutoMapper merges only
/// the DTO fields onto the tracked entity so fields not present in the form (e.g. internal
/// flags) are not accidentally overwritten.
/// </summary>
// POST: Companies/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
{
if (id != model.Id)
return NotFound();
if (!ModelState.IsValid)
return View(model);
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
// Check if company code is unique (if changed)
if (!string.IsNullOrWhiteSpace(model.CompanyCode) && model.CompanyCode != company.CompanyCode)
{
var existingCompany = await _unitOfWork.Companies
.FindAsync(c => c.CompanyCode == model.CompanyCode && c.Id != id, ignoreQueryFilters: true);
if (existingCompany.Any())
{
ModelState.AddModelError("CompanyCode", "A company with this code already exists.");
return View(model);
}
}
_mapper.Map(model, company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
company.CompanyName, User.Identity?.Name);
TempData["Success"] = "Company updated successfully.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating company {CompanyId}", id);
ModelState.AddModelError("", "An error occurred while updating the company.");
return View(model);
}
}
/// <summary>
/// Flips the <c>IsActive</c> flag on a company. Deactivating a company prevents its users
/// from logging in (the app checks <c>IsActive</c> during sign-in) but keeps all data intact
/// so the company can be reactivated later without data loss. For a more permanent action
/// see <see cref="SoftDelete"/>.
/// </summary>
// POST: Companies/ToggleActive/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
company.IsActive = !company.IsActive;
await _unitOfWork.CompleteAsync();
var status = company.IsActive ? "activated" : "deactivated";
_logger.LogInformation("Company {CompanyName} {Status} by {User}",
company.CompanyName, status, User.Identity?.Name);
TempData["Success"] = $"Company {status} successfully.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error toggling company active status for {CompanyId}", id);
TempData["Error"] = "An error occurred while updating the company status.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Soft-deletes a company by setting <c>IsDeleted = true</c> and <c>IsActive = false</c>,
/// and deactivates all of its users so they can no longer log in. All business data is
/// preserved for potential recovery. An audit log entry is written to <c>AuditLog</c>
/// recording who performed the action and how many users were affected. This is less
/// destructive than <see cref="HardDelete"/> and should be the default deactivation path.
/// </summary>
// POST: Companies/SoftDelete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SoftDelete(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id,
ignoreQueryFilters: true,
c => c.Users);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
var userCount = company.Users.Count;
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
foreach (var user in company.Users)
{
user.IsActive = false;
await _userManager.UpdateAsync(user);
}
await _unitOfWork.CompleteAsync();
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = id,
CompanyName = companyName,
Action = "SoftDelete",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
NewValues = $"IsDeleted=true, IsActive=false. {userCount} user(s) deactivated.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
companyName, id, adminName);
TempData["Success"] = $"Company '{companyName}' deactivated. {userCount} user(s) disabled.";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error soft-deleting company {CompanyId}", id);
TempData["Error"] = "An error occurred while deactivating the company.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Permanently deletes a company and every piece of data associated with it across all
/// tables. Requires the caller to type "DELETE" as a confirmation string to prevent
/// accidental invocation. Deletion is ordered leaf-to-root across six tiers to respect
/// foreign key constraints without disabling constraint checks. ASP.NET Identity users are
/// deleted via <c>UserManager.DeleteAsync</c> so the Identity framework cascades to its
/// own AspNetUser* tables correctly. An audit log entry is written before the method returns
/// so there is a permanent record even though the company record itself is gone.
/// This is irreversible — prefer <see cref="SoftDelete"/> for normal deactivations.
/// </summary>
// POST: Companies/HardDelete/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> HardDelete(int id, string confirmation)
{
if (confirmation != "DELETE")
{
TempData["Error"] = "Hard delete requires typing DELETE to confirm.";
return RedirectToAction(nameof(Index));
}
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
try
{
// Load user IDs first — needed for announcement-dismissal cleanup in the purge service
var userIds = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
// Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
// Tier 5: delete Identity users so AspNetUser* tables cascade correctly
var users = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).ToListAsync();
var userCount = users.Count;
foreach (var user in users)
await _userManager.DeleteAsync(user);
// Tier 6: delete company record
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = null,
CompanyName = companyName,
Action = "HardDelete",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
OldValues = $"Company '{companyName}' (ID:{id}) permanently deleted. {userCount} user(s) removed.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
companyName, id, adminName, userCount);
TempData["Success"] = $"Company '{companyName}' and all associated data permanently deleted ({userCount} user(s) removed).";
return RedirectToAction(nameof(Index));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error hard-deleting company {CompanyId}", id);
TempData["Error"] = $"An error occurred during deletion: {ex.Message}. Some data may have been partially removed.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Permanently deletes all business data for a company (jobs, quotes, customers, invoices,
/// inventory, etc.) while preserving the company record, its users, operating costs,
/// preferences, and lookup tables. Useful for resetting a demo or trial tenant back to a
/// clean state. Requires the caller to type "DELETE" as confirmation. Deletion is ordered
/// across three tiers (grandchildren → children → top-level entities) to avoid FK violations.
/// The QuickBooks migration wizard progress is also cleared. An audit log entry is written
/// to record the action. This operation is irreversible.
/// </summary>
// POST: Companies/ResetData/5
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetData(int id, string confirmation)
{
if (confirmation != "DELETE")
{
TempData["Error"] = "Data reset requires typing DELETE to confirm.";
return RedirectToAction(nameof(Details), new { id });
}
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var adminUserId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var adminName = User.Identity?.Name ?? "Unknown";
var companyName = company.CompanyName;
try
{
await _companyPurge.ResetBusinessDataAsync(id);
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
CompanyId = id,
CompanyName = companyName,
Action = "ResetData",
EntityType = "Company",
EntityId = id.ToString(),
EntityDescription = companyName,
OldValues = $"All business data for company '{companyName}' (ID:{id}) permanently deleted by {adminName}. Company record, users, and settings preserved.",
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
_logger.LogWarning(
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
companyName, id, adminName);
TempData["SuccessPermanent"] = $"All business data for '{companyName}' has been permanently deleted. The company record, users, and configuration have been preserved.";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resetting data for company {CompanyId}", id);
TempData["ErrorPermanent"] = $"An error occurred during data reset: {ex.Message}. Some data may have been partially removed.";
return RedirectToAction(nameof(Details), new { id });
}
}
/// <summary>
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
/// Used when a company needs more than one admin or when the original admin's account must
/// be replaced (e.g., employee departure). This is a SuperAdmin action; company admins
/// create regular users via <c>CompanyUsersController</c>.
/// </summary>
// GET: Companies/CreateCompanyAdmin/5
public async Task<IActionResult> CreateCompanyAdmin(int id)
{
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var model = new CreateCompanyAdminDto
{
CompanyId = company.Id,
CompanyName = company.CompanyName
};
return View(model);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading create company admin page for company {CompanyId}", id);
TempData["Error"] = "An error occurred while loading the page.";
return RedirectToAction(nameof(Index));
}
}
/// <summary>
/// Creates a new CompanyAdmin Identity user for the specified company. Grants all
/// per-company permissions by default (same set as the initial admin created in
/// <see cref="Create(CreateCompanyDto)"/>) and assigns the ASP.NET Identity
/// <c>Administrator</c> role. Email uniqueness is checked before creation; if the email
/// already exists the form is returned with a model error rather than throwing an exception.
/// </summary>
// POST: Companies/CreateCompanyAdmin
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateCompanyAdmin(CreateCompanyAdminDto model)
{
if (!ModelState.IsValid)
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company != null)
model.CompanyName = company.CompanyName;
return View(model);
}
try
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company == null)
{
TempData["Error"] = "Company not found.";
return RedirectToAction(nameof(Index));
}
var existingUser = await _userManager.FindByEmailAsync(model.Email);
if (existingUser != null)
{
ModelState.AddModelError("Email", "A user with this email already exists.");
model.CompanyName = company.CompanyName;
return View(model);
}
var adminUser = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
EmailConfirmed = true,
FirstName = model.FirstName,
LastName = model.LastName,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.CompanyAdmin,
IsActive = true,
HireDate = DateTime.UtcNow,
Department = model.Department ?? "Management",
Position = model.Position ?? "Company Administrator",
PhoneNumber = model.Phone,
};
adminUser.GrantAllPermissions();
var result = await _userManager.CreateAsync(adminUser, model.Password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(adminUser, AppConstants.Roles.Administrator);
_logger.LogInformation("Company admin {Email} created for company {CompanyName} by SuperAdmin {User}",
adminUser.Email, company.CompanyName, User.Identity?.Name);
TempData["Success"] = $"Company admin user '{adminUser.FullName}' created successfully for {company.CompanyName}.";
return RedirectToAction(nameof(Details), new { id = company.Id });
}
else
{
foreach (var error in result.Errors)
ModelState.AddModelError("", error.Description);
model.CompanyName = company.CompanyName;
return View(model);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating company admin for company {CompanyId}", model.CompanyId);
ModelState.AddModelError("", "An error occurred while creating the admin user.");
return View(model);
}
}
// ─── User Login History ──────────────────────────────────────────────────
/// <summary>
/// Returns profile info and the last 50 login-related audit entries for a single user,
/// used by the user-details offcanvas on the Company Details page. Verifies that the
/// requested user actually belongs to the specified company so that a SuperAdmin cannot
/// use this endpoint to pull audit data for users in other companies by guessing IDs.
/// </summary>
[HttpGet]
public async Task<IActionResult> UserLoginHistory(int companyId, string userId)
{
try
{
var user = await _userManager.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Id == userId && u.CompanyId == companyId);
if (user == null)
return NotFound(new { error = "User not found." });
// Use the viewed company's timezone so timestamps match the tenant's local time
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
var tz = company?.TimeZone;
var logs = new List<dynamic>();
try
{
var rawLogs = await _auditLog.GetUserActivityAsync(userId);
logs = rawLogs.Select(l => (dynamic)new
{
action = l.Action,
ipAddress = l.IpAddress ?? "—",
timestamp = l.Timestamp.Tz(tz).ToString("MMM d, yyyy h:mm tt"),
note = TryParseNote(l.NewValues)
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load audit log history for user {UserId}", userId);
// Login history is non-critical — return user data with empty history
}
return Json(new
{
user = new
{
id = user.Id,
fullName = user.FullName,
email = user.Email,
companyRole = user.CompanyRole,
department = user.Department,
position = user.Position,
phone = user.PhoneNumber,
isActive = user.IsActive,
emailConfirmed = user.EmailConfirmed,
hireDate = user.HireDate != default ? user.HireDate.ToString("MMM d, yyyy") : null,
lastLoginDate = user.LastLoginDate.HasValue
? user.LastLoginDate.Value.Tz(tz).ToString("MMM d, yyyy h:mm tt")
: null,
createdAt = user.CreatedAt.Tz(tz).ToString("MMM d, yyyy")
},
loginHistory = logs
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading user login history for user {UserId} in company {CompanyId}", userId, companyId);
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Safely extracts the "note" string from the JSON stored in <c>AuditLog.NewValues</c>
/// (e.g., <c>{"note":"Company 'Acme' is deactivated"}</c>). Returns null if the field is
/// absent or the JSON cannot be parsed, so the UI can omit the column cleanly.
/// </summary>
private static string? TryParseNote(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
return doc.RootElement.TryGetProperty("note", out var prop) ? prop.GetString() : null;
}
catch { return null; }
}
}