Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/ProfileController.cs
T
2026-04-23 21:38:24 -04:00

314 lines
13 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.User;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Controllers;
[Authorize]
public class ProfileController : Controller
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IProfilePhotoService _profilePhotoService;
private readonly ILogger<ProfileController> _logger;
public ProfileController(
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
IProfilePhotoService profilePhotoService,
ILogger<ProfileController> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_profilePhotoService = profilePhotoService;
_logger = logger;
}
/// <summary>
/// Displays the current user's profile page. All profile sections (personal info, email, password, appearance, photo) are rendered from a single DTO to keep the view self-contained.
/// </summary>
public async Task<IActionResult> Index()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return NotFound();
var dto = MapToDto(user);
return View(dto);
}
/// <summary>
/// Serves a user's profile photo binary directly from the filesystem. Cached client-side for 1 hour to reduce disk I/O.
/// When no ID is supplied the current user's photo is returned. Cross-user access is restricted: only the same user, a SuperAdmin, or a same-company user may view another user's photo (prevents enumeration of photos across tenants).
/// </summary>
[HttpGet]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Client)]
public async Task<IActionResult> Photo(string? id = null)
{
ApplicationUser? user;
var currentUser = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(id))
{
// No ID provided - use current user's photo
user = currentUser;
}
else
{
// SECURITY: Only allow access if same user, SuperAdmin, or same company
if (currentUser?.Id != id && !User.IsInRole("SuperAdmin"))
{
var requestedUser = await _userManager.FindByIdAsync(id);
// Deny access if user not found or different company
if (requestedUser == null || requestedUser.CompanyId != currentUser?.CompanyId)
{
_logger.LogWarning("SECURITY: Unauthorized photo access attempt. User {CurrentUserId} tried to access photo for {RequestedUserId}",
currentUser?.Id, id);
return Forbid();
}
}
// ID provided - load that user's photo (for quote PreparedBy thumbnail, etc.)
user = await _userManager.FindByIdAsync(id);
}
if (user == null)
return NotFound();
// Try filesystem first (new method)
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
var (success, fileContent, contentType, errorMessage) = await _profilePhotoService.GetProfilePhotoAsync(user.ProfilePictureFilePath);
if (success)
{
return File(fileContent, contentType);
}
_logger.LogWarning("Failed to load profile photo from filesystem for user {UserId}: {Error}", user.Id, errorMessage);
}
return NotFound();
}
/// <summary>
/// AJAX endpoint to update the current user's name and phone number. Calls RefreshSignInAsync so any claims that embed display name are updated immediately without requiring re-login.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateProfile([FromBody] UpdateProfileDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
user.FirstName = dto.FirstName.Trim();
user.LastName = dto.LastName.Trim();
user.PhoneNumber = dto.Phone?.Trim();
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Profile updated successfully." });
}
/// <summary>
/// AJAX endpoint to change the current user's email address, which also serves as the username in ASP.NET Identity.
/// Requires the current password as confirmation to prevent account takeover if a session is left unattended. Checks for duplicate addresses system-wide before applying the change.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateEmail([FromBody] UpdateEmailDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
if (!await _userManager.CheckPasswordAsync(user, dto.CurrentPassword))
return Json(new { success = false, message = "Current password is incorrect." });
if (string.Equals(dto.NewEmail, user.Email, StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "New email is the same as the current email." });
var existing = await _userManager.FindByEmailAsync(dto.NewEmail);
if (existing != null)
return Json(new { success = false, message = "That email address is already in use." });
var emailResult = await _userManager.SetEmailAsync(user, dto.NewEmail);
if (!emailResult.Succeeded)
{
var errors = string.Join(", ", emailResult.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _userManager.SetUserNameAsync(user, dto.NewEmail);
await _signInManager.RefreshSignInAsync(user);
_logger.LogInformation("User {UserId} changed email to {NewEmail}", user.Id, dto.NewEmail);
return Json(new { success = true, message = "Email updated successfully." });
}
/// <summary>
/// AJAX endpoint to change the current user's password via the Identity ChangePasswordAsync flow. RefreshSignInAsync is called after success so the existing cookie remains valid; the user is not forced to log in again.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = "Please correct the validation errors." });
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
var result = await _userManager.ChangePasswordAsync(user, dto.CurrentPassword, dto.NewPassword);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Password changed successfully." });
}
/// <summary>
/// Replaces the current user's profile photo with a new upload. The old file is deleted from the filesystem first; if the Identity update subsequently fails the newly uploaded file is rolled back (deleted) to avoid orphaned files.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadPhoto(IFormFile photo)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
// Delete old filesystem photo if exists
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
await _profilePhotoService.DeleteProfilePhotoAsync(user.ProfilePictureFilePath);
}
// Save new photo to filesystem
var (success, filePath, errorMessage) = await _profilePhotoService.SaveProfilePhotoAsync(photo, user.Id, user.CompanyId);
if (!success)
return Json(new { success = false, message = errorMessage });
// Update user record
user.ProfilePictureFilePath = filePath;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
// Rollback: delete the uploaded file
await _profilePhotoService.DeleteProfilePhotoAsync(filePath);
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Photo uploaded successfully." });
}
/// <summary>
/// Removes the current user's profile photo from both the filesystem and the user record. Setting ProfilePictureFilePath to null causes the UI to fall back to the default avatar initials.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeletePhoto()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
// Delete from filesystem if exists
if (!string.IsNullOrEmpty(user.ProfilePictureFilePath))
{
await _profilePhotoService.DeleteProfilePhotoAsync(user.ProfilePictureFilePath);
}
// Update user record
user.ProfilePictureFilePath = null;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Photo removed." });
}
/// <summary>
/// Saves the user's UI preferences (theme, sidebar color, date format, time zone). Theme is validated against the allowed values "light"/"dark" and defaults to "light" if an unexpected value is submitted.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateAppearance([FromBody] UpdateAppearanceDto dto)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
return Json(new { success = false, message = "User not found." });
user.Theme = dto.Theme is "light" or "dark" ? dto.Theme : "light";
user.SidebarColor = dto.SidebarColor;
user.DateFormat = dto.DateFormat;
user.TimeZone = dto.TimeZone;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
return Json(new { success = false, message = errors });
}
await _signInManager.RefreshSignInAsync(user);
return Json(new { success = true, message = "Appearance saved." });
}
/// <summary>
/// Maps an ApplicationUser entity to the view DTO used by the profile page. Kept static and side-effect-free so it can be called without async overhead; defaults are applied here rather than in the view to centralise fallback logic.
/// </summary>
private static UserProfileDto MapToDto(ApplicationUser user) => new()
{
Id = user.Id,
Email = user.Email ?? string.Empty,
FirstName = user.FirstName,
LastName = user.LastName,
FullName = user.FullName,
Phone = user.PhoneNumber,
Department = user.Department,
Position = user.Position,
EmployeeNumber = user.EmployeeNumber,
CompanyRole = user.CompanyRole,
Theme = user.Theme ?? "light",
SidebarColor = user.SidebarColor ?? "ocean",
DateFormat = user.DateFormat ?? "MM/dd/yyyy",
TimeZone = user.TimeZone,
HasProfilePicture = !string.IsNullOrEmpty(user.ProfilePictureFilePath),
HireDate = user.HireDate,
CreatedAt = user.CreatedAt,
LastLoginDate = user.LastLoginDate
};
}