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 _userManager; private readonly SignInManager _signInManager; private readonly IProfilePhotoService _profilePhotoService; private readonly ILogger _logger; public ProfileController( UserManager userManager, SignInManager signInManager, IProfilePhotoService profilePhotoService, ILogger logger) { _userManager = userManager; _signInManager = signInManager; _profilePhotoService = profilePhotoService; _logger = logger; } /// /// 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. /// public async Task Index() { var user = await _userManager.GetUserAsync(User); if (user == null) return NotFound(); var dto = MapToDto(user); return View(dto); } /// /// 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). /// [HttpGet] [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Client)] public async Task 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(); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// [HttpPost] [ValidateAntiForgeryToken] public async Task 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." }); } /// /// 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. /// 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 }; }