314 lines
13 KiB
C#
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
|
|
};
|
|
}
|