Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,170 @@
@page
@model ForgotPasswordModel
@{
ViewData["Title"] = "Forgot Password";
Layout = "/Views/Shared/_AuthLayout.cshtml";
}
@section Styles {
<style>
.auth-brand-panel {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem 2.5rem;
color: white;
}
.auth-brand-panel .brand-icon {
font-size: 4rem;
color: #4fc3f7;
margin-bottom: 1.5rem;
}
.auth-brand-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
}
.auth-brand-panel .tagline {
font-size: 1rem;
color: rgba(255,255,255,0.65);
margin-bottom: 2.5rem;
text-align: center;
}
.feature-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 280px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
font-size: 0.95rem;
color: rgba(255,255,255,0.82);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.feature-list li:last-child { border-bottom: none; }
.feature-list li i {
color: #4fc3f7;
font-size: 1.1rem;
flex-shrink: 0;
}
.auth-form-panel {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
background-color: #ffffff;
min-height: 100vh;
}
.auth-form-container {
width: 100%;
max-width: 420px;
}
.auth-form-container h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.375rem;
}
.auth-form-container .subtext {
color: #64748b;
margin-bottom: 2rem;
font-size: 0.95rem;
}
</style>
}
<div class="d-flex" style="min-height:100vh;">
<!-- Left Brand Panel -->
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:220px; margin-bottom:1.5rem;" />
<h1>Powder Coating Logix</h1>
<p class="tagline">The complete management platform for powder coating businesses</p>
<ul class="feature-list">
<li><i class="bi bi-briefcase-fill"></i> Job &amp; Quote Management</li>
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
<li><i class="bi bi-box-seam-fill"></i> Inventory &amp; Equipment</li>
<li><i class="bi bi-graph-up-arrow"></i> Analytics &amp; Reports</li>
</ul>
</div>
<!-- Right Form Panel -->
<div class="auth-form-panel col-lg-7">
<div class="auth-form-container">
@if (Model.EmailSent)
{
<div class="text-center py-3">
<div class="mb-4" style="font-size:3.5rem; color:#4fc3f7;">
<i class="bi bi-envelope-check"></i>
</div>
<h2 class="mb-2">Check your email</h2>
<p class="subtext mb-4">
If an account exists for <strong>@Model.Input.Email</strong>, we've sent a password reset link.
Check your inbox (and spam folder) and follow the link to reset your password.
</p>
<p class="text-muted small mb-4">The link expires in 24 hours.</p>
<a asp-page="./Login" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back to Sign In
</a>
</div>
}
else
{
<div class="mb-4" style="font-size:2.5rem; color:#4fc3f7;">
<i class="bi bi-key"></i>
</div>
<h2>Forgot your password?</h2>
<p class="subtext">Enter your email address and we'll send you a link to reset it.</p>
<form method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger py-2 mb-3" role="alert"></div>
<div class="mb-4">
<label asp-for="Input.Email" class="form-label fw-medium">Email address</label>
<input asp-for="Input.Email" class="form-control form-control-lg"
autocomplete="username" placeholder="you@company.com" />
<span asp-validation-for="Input.Email" class="text-danger small"></span>
</div>
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-send me-1"></i> Send Reset Link
</button>
</div>
</form>
<div class="text-center mt-3">
<a asp-page="./Login" class="text-decoration-none small" style="color:var(--primary-color);">
<i class="bi bi-arrow-left me-1"></i> Back to Sign In
</a>
</div>
}
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
@@ -0,0 +1,119 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Areas.Identity.Pages.Account;
public class ForgotPasswordModel : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly IEmailService _emailService;
private readonly ILogger<ForgotPasswordModel> _logger;
public ForgotPasswordModel(
UserManager<ApplicationUser> userManager,
IEmailService emailService,
ILogger<ForgotPasswordModel> logger)
{
_userManager = userManager;
_emailService = emailService;
_logger = logger;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public bool EmailSent { get; set; }
public class InputModel
{
[Required(ErrorMessage = "Email address is required.")]
[EmailAddress(ErrorMessage = "Please enter a valid email address.")]
public string Email { get; set; } = string.Empty;
}
public void OnGet() { }
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
// Always show the confirmation view regardless of whether the email exists —
// this prevents user enumeration attacks.
EmailSent = true;
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null || !user.IsActive)
{
_logger.LogInformation("Password reset requested for unknown or inactive email: {Email}", Input.Email);
return Page();
}
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var encodedToken = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(token));
var resetLink = Url.Page(
"/Account/ResetPassword",
pageHandler: null,
values: new { area = "Identity", token = encodedToken, email = Input.Email },
protocol: Request.Scheme)!;
var htmlBody = $"""
<div style="font-family: Arial, sans-serif; max-width: 560px; margin: 0 auto;">
<div style="background: linear-gradient(135deg,#1a1a2e,#0f3460); padding: 2rem; text-align: center; border-radius: 8px 8px 0 0;">
<img src="https://powdercoatinglogix.com/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:180px; display:block; margin:0 auto 0.5rem;" onerror="this.style.display='none'" />
<h1 style="color:#4fc3f7; margin:0; font-size:1.5rem;">Powder Coating Logix</h1>
</div>
<div style="background:#fff; padding: 2rem; border: 1px solid #e2e8f0; border-top:none; border-radius: 0 0 8px 8px;">
<h2 style="color:#0f172a; margin-top:0;">Reset your password</h2>
<p style="color:#475569;">
We received a request to reset the password for your account (<strong>{HtmlEncoder.Default.Encode(Input.Email)}</strong>).
Click the button below to choose a new password.
</p>
<div style="text-align:center; margin: 2rem 0;">
<a href="{HtmlEncoder.Default.Encode(resetLink)}"
style="background:#0d6efd; color:#fff; padding: 0.85rem 2rem; border-radius: 6px;
text-decoration:none; font-weight:600; font-size:1rem; display:inline-block;">
Reset Password
</a>
</div>
<p style="color:#64748b; font-size:0.875rem;">
This link expires in <strong>24 hours</strong>. If you didn't request a password reset,
you can safely ignore this email your password will not change.
</p>
<hr style="border:none; border-top:1px solid #e2e8f0; margin:1.5rem 0;">
<p style="color:#94a3b8; font-size:0.8rem;">
If the button above doesn't work, copy and paste this URL into your browser:<br>
<a href="{HtmlEncoder.Default.Encode(resetLink)}" style="color:#0d6efd; word-break:break-all;">{HtmlEncoder.Default.Encode(resetLink)}</a>
</p>
</div>
</div>
""";
var plainBody = $"Reset your Powder Coating Logix password\n\n" +
$"We received a request to reset the password for {Input.Email}.\n\n" +
$"Click the link below to reset your password (expires in 24 hours):\n{resetLink}\n\n" +
$"If you didn't request this, you can safely ignore this email.";
var result = await _emailService.SendEmailAsync(
toEmail: user.Email!,
toName: user.FullName,
subject: "Reset your Powder Coating Logix password",
plainTextBody: plainBody,
htmlBody: htmlBody);
if (!result.Success)
_logger.LogWarning("Failed to send password reset email to {Email}: {Error}", Input.Email, result.ErrorMessage);
else
_logger.LogInformation("Password reset email sent to {Email}", Input.Email);
return Page();
}
}
@@ -0,0 +1,285 @@
@page
@model LoginModel
@{
ViewData["Title"] = "Sign In";
Layout = "/Views/Shared/_AuthLayout.cshtml";
}
@section Styles {
<style>
.auth-brand-panel {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem 2.5rem;
color: white;
}
.auth-brand-panel .brand-icon {
font-size: 4rem;
color: #4fc3f7;
margin-bottom: 1.5rem;
}
.auth-brand-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
}
.auth-brand-panel .tagline {
font-size: 1rem;
color: rgba(255,255,255,0.65);
margin-bottom: 2.5rem;
text-align: center;
}
.feature-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 280px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
font-size: 0.95rem;
color: rgba(255,255,255,0.82);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.feature-list li:last-child {
border-bottom: none;
}
.feature-list li i {
color: #4fc3f7;
font-size: 1.1rem;
flex-shrink: 0;
}
.auth-form-panel {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
background-color: #ffffff;
min-height: 100vh;
}
.auth-form-container {
width: 100%;
max-width: 420px;
}
.auth-form-container h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.375rem;
}
.auth-form-container .subtext {
color: #64748b;
margin-bottom: 2rem;
font-size: 0.95rem;
}
.auth-divider {
position: relative;
text-align: center;
margin: 1.5rem 0;
}
.auth-divider::before,
.auth-divider::after {
content: '';
position: absolute;
top: 50%;
width: 42%;
height: 1px;
background: #e2e8f0;
}
.auth-divider::before { left: 0; }
.auth-divider::after { right: 0; }
.auth-divider span {
background: white;
padding: 0 0.75rem;
color: #94a3b8;
font-size: 0.875rem;
}
.trial-cta {
text-align: center;
padding: 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.625rem;
margin-top: 0.5rem;
}
.trial-cta .cta-text {
color: #475569;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.trial-cta a {
color: var(--primary-color);
font-weight: 600;
text-decoration: none;
font-size: 0.95rem;
}
.trial-cta a:hover {
text-decoration: underline;
}
.password-wrapper {
position: relative;
}
.password-wrapper .toggle-pw {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 0;
font-size: 1rem;
}
.password-wrapper .toggle-pw:hover {
color: #475569;
}
.password-wrapper input {
padding-right: 2.75rem;
}
</style>
}
<div class="d-flex" style="min-height:100vh;">
<!-- Left Brand Panel — hidden on mobile -->
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:220px; margin-bottom:1.5rem;" />
<h1>Powder Coating Logix</h1>
<p class="tagline">The complete management platform for powder coating businesses</p>
<ul class="feature-list">
<li><i class="bi bi-briefcase-fill"></i> Job &amp; Quote Management</li>
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
<li><i class="bi bi-box-seam-fill"></i> Inventory &amp; Equipment</li>
<li><i class="bi bi-graph-up-arrow"></i> Analytics &amp; Reports</li>
</ul>
</div>
<!-- Right Form Panel -->
<div class="auth-form-panel col-lg-7">
<div class="auth-form-container">
<h2>Welcome back</h2>
<p class="subtext">Sign in to your account</p>
@if (TempData["AccountDeleted"] as string == "true")
{
var deletedName = TempData["DeletedCompanyName"] as string;
<div class="alert alert-success alert-permanent mb-3" role="alert">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>Account deleted.</strong>
@if (!string.IsNullOrEmpty(deletedName))
{
<span>The account for <strong>@deletedName</strong> has been deactivated. Your data is retained for 30 days before being permanently purged. If this was a mistake, contact support immediately.</span>
}
else
{
<span>Your account has been deactivated. Your data is retained for 30 days before being permanently purged. If this was a mistake, contact support immediately.</span>
}
</div>
}
<form id="account" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger py-2 mb-3" role="alert" style="display:@(ViewContext.ViewData.ModelState.IsValid ? "none" : "block")"></div>
<div class="mb-3">
<label asp-for="Input.Email" class="form-label fw-medium">Email address</label>
<input asp-for="Input.Email" class="form-control form-control-lg" autocomplete="username" aria-required="true" placeholder="you@company.com" />
<span asp-validation-for="Input.Email" class="text-danger small"></span>
</div>
<div class="mb-3">
<label asp-for="Input.Password" class="form-label fw-medium">Password</label>
<div class="password-wrapper">
<input asp-for="Input.Password" id="passwordInput" class="form-control form-control-lg" autocomplete="current-password" aria-required="true" placeholder="••••••••" />
<button type="button" class="toggle-pw" id="togglePw" tabindex="-1" aria-label="Show/hide password">
<i class="bi bi-eye" id="togglePwIcon"></i>
</button>
</div>
<span asp-validation-for="Input.Password" class="text-danger small"></span>
</div>
<div class="mb-4 d-flex align-items-center justify-content-between">
<div class="form-check mb-0">
<input class="form-check-input" asp-for="Input.RememberMe" />
<label class="form-check-label text-secondary" asp-for="Input.RememberMe">
Remember me
</label>
</div>
<a asp-page="./ForgotPassword" class="text-decoration-none small" style="color:var(--primary-color);">Forgot password?</a>
</div>
<div class="d-grid mb-3">
<button id="login-submit" type="submit" class="btn btn-primary btn-lg">
Sign In
</button>
</div>
</form>
@if (Model.SignupOpen)
{
<div class="auth-divider"><span>or</span></div>
<div class="trial-cta">
<p class="cta-text mb-2">Don't have an account?</p>
@if (Model.TrialsEnabled)
{
<a href="/Registration">Start your @Model.TrialDays-day free trial &rarr;</a>
}
else
{
<a href="/Registration">Create an account &rarr;</a>
}
</div>
}
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
}
@@ -0,0 +1,259 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Areas.Identity.Pages.Account
{
[EnableRateLimiting(AppConstants.RateLimitPolicies.Auth)]
public class LoginModel : PageModel
{
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<LoginModel> _logger;
private readonly IHostEnvironment _env;
private readonly IConfiguration _config;
private readonly ApplicationDbContext _db;
private readonly IPlatformSettingsService _platformSettings;
public LoginModel(
SignInManager<ApplicationUser> signInManager,
UserManager<ApplicationUser> userManager,
ILogger<LoginModel> logger,
IHostEnvironment env,
IConfiguration config,
ApplicationDbContext db,
IPlatformSettingsService platformSettings)
{
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
_env = env;
_config = config;
_db = db;
_platformSettings = platformSettings;
}
[BindProperty]
public InputModel Input { get; set; } = default!;
public IList<AuthenticationScheme> ExternalLogins { get; set; } = new List<AuthenticationScheme>();
public string? ReturnUrl { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public bool SignupOpen { get; set; } = true;
public bool TrialsEnabled { get; set; } = true;
public int TrialDays { get; set; } = 7;
public class InputModel
{
[Required]
[EmailAddress]
public string Email { get; set; } = default!;
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = default!;
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
public async Task OnGetAsync(string? returnUrl = null)
{
if (!string.IsNullOrEmpty(ErrorMessage))
{
ModelState.AddModelError(string.Empty, ErrorMessage);
}
returnUrl ??= Url.Content("~/");
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
ReturnUrl = returnUrl;
// Determine whether self-signup is available
SignupOpen = await IsRegistrationOpenAsync();
// Trial period days from platform settings (falls back to 7 if not configured)
var rawTrialDays = await _platformSettings.GetAsync(PlatformSettingKeys.TrialPeriodDays);
if (int.TryParse(rawTrialDays, out var td) && td > 0)
TrialDays = td;
var rawTrialsEnabled = await _platformSettings.GetAsync(PlatformSettingKeys.TrialsEnabled);
TrialsEnabled = rawTrialsEnabled == null || !string.Equals(rawTrialsEnabled, "false", StringComparison.OrdinalIgnoreCase);
}
private async Task<bool> IsRegistrationOpenAsync()
{
try
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.MaxTenants);
if (!int.TryParse(raw, out var max) || max <= 0)
return true;
var count = await _db.Companies.IgnoreQueryFilters().CountAsync(c => !c.IsDeleted);
return count < max;
}
catch
{
return true; // Fail open — don't block login page on a DB error
}
}
public async Task<IActionResult> OnPostAsync(string? returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
// Check IP ban before hitting Identity at all
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
var now = DateTime.UtcNow;
var ipBan = await _db.BannedIps.FirstOrDefaultAsync(b =>
b.IpAddress == clientIp && b.IsActive &&
(b.ExpiresAt == null || b.ExpiresAt > now));
if (ipBan != null)
{
_logger.LogWarning("Login blocked — IP {IP} is banned. Reason: {Reason}", clientIp, ipBan.Reason);
ModelState.AddModelError(string.Empty, "Access from your IP address has been restricted. Please contact support.");
return Page();
}
}
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
// Check if the user's company is active (skip check for SuperAdmins)
var user = await _userManager.Users
.Include(u => u.Company)
.FirstOrDefaultAsync(u => u.Email == Input.Email);
if (user != null)
{
// Banned users are denied regardless of role or company
if (user.IsBanned)
{
await _signInManager.SignOutAsync();
_logger.LogWarning("Banned user {Email} attempted login", Input.Email);
await WriteLoginAuditAsync("LoginDenied", user, $"Account banned: {user.BanReason}");
ErrorMessage = "This account has been suspended. Please contact support.";
return RedirectToPage("./Login", new { returnUrl });
}
// Check if user is SuperAdmin
var isSuperAdmin = await _userManager.IsInRoleAsync(user, "SuperAdmin");
// Only check company status for non-SuperAdmin users with a company
if (!isSuperAdmin && user.Company != null && !user.Company.IsActive)
{
// Sign out the user immediately
await _signInManager.SignOutAsync();
_logger.LogWarning("Login denied for {Email} — company {CompanyName} is deactivated",
Input.Email, user.Company.CompanyName);
await WriteLoginAuditAsync("LoginDenied", user, $"Company '{user.Company.CompanyName}' is deactivated");
ErrorMessage = "Your company account has been deactivated. Please contact support for assistance.";
return RedirectToPage("./Login", new { returnUrl });
}
}
if (user != null)
{
user.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(user);
await WriteLoginAuditAsync("Login", user);
}
_logger.LogInformation("User {Email} logged in", Input.Email);
return LocalRedirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
// Bypass 2FA in non-production environments
var bypass2FA = !_env.IsProduction() || _config.GetValue<bool>("AppSettings:Disable2FAEnforcement");
if (bypass2FA)
{
var user2fa = await _userManager.FindByEmailAsync(Input.Email);
if (user2fa != null)
{
user2fa.LastLoginDate = DateTime.UtcNow;
await _userManager.UpdateAsync(user2fa);
await _signInManager.SignInAsync(user2fa, Input.RememberMe);
_logger.LogInformation("2FA bypassed in non-production for {Email}", Input.Email);
await WriteLoginAuditAsync("Login2FABypassed", user2fa);
return LocalRedirect(returnUrl);
}
}
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning("Account locked out: {Email}", Input.Email);
var lockedUser = await _userManager.FindByEmailAsync(Input.Email);
if (lockedUser != null)
await WriteLoginAuditAsync("AccountLockedOut", lockedUser, "Too many failed attempts");
return RedirectToPage("./Lockout");
}
else
{
_logger.LogWarning("Failed login attempt for {Email}", Input.Email);
// Write failed login — find user if they exist (don't leak whether account exists via timing)
var failedUser = await _userManager.FindByEmailAsync(Input.Email);
if (failedUser != null)
await WriteLoginAuditAsync("FailedLogin", failedUser);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
return Page();
}
private async Task WriteLoginAuditAsync(string action, ApplicationUser user, string? note = null)
{
try
{
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
_db.AuditLogs.Add(new AuditLog
{
UserId = user.Id,
UserName = user.Email ?? user.UserName ?? "Unknown",
CompanyId = user.CompanyId,
Action = action,
EntityType = "ApplicationUser",
EntityId = user.Id,
EntityDescription = user.Email,
NewValues = note != null ? System.Text.Json.JsonSerializer.Serialize(new { note }) : null,
IpAddress = ip,
Timestamp = DateTime.UtcNow
});
await _db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write login audit for {Email}", user.Email);
}
}
}
}
@@ -0,0 +1,2 @@
@page
@model LogoutModel
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Areas.Identity.Pages.Account;
public class LogoutModel : PageModel
{
private readonly SignInManager<ApplicationUser> _signInManager;
public LogoutModel(SignInManager<ApplicationUser> signInManager)
{
_signInManager = signInManager;
}
public async Task<IActionResult> OnPostAsync()
{
await _signInManager.SignOutAsync();
return RedirectToPage("/Account/Login", new { area = "Identity" });
}
public IActionResult OnGet()
{
return RedirectToPage("/Account/Login", new { area = "Identity" });
}
}
@@ -0,0 +1,276 @@
@page
@model ResetPasswordModel
@{
ViewData["Title"] = "Reset Password";
Layout = "/Views/Shared/_AuthLayout.cshtml";
}
@section Styles {
<style>
.auth-brand-panel {
background: linear-gradient(135deg, #1a1a2e, #16213e, #0f3460);
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 3rem 2.5rem;
color: white;
}
.auth-brand-panel .brand-icon {
font-size: 4rem;
color: #4fc3f7;
margin-bottom: 1.5rem;
}
.auth-brand-panel h1 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.75rem;
text-align: center;
}
.auth-brand-panel .tagline {
font-size: 1rem;
color: rgba(255,255,255,0.65);
margin-bottom: 2.5rem;
text-align: center;
}
.feature-list {
list-style: none;
padding: 0;
width: 100%;
max-width: 280px;
}
.feature-list li {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 0;
font-size: 0.95rem;
color: rgba(255,255,255,0.82);
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.feature-list li:last-child { border-bottom: none; }
.feature-list li i {
color: #4fc3f7;
font-size: 1.1rem;
flex-shrink: 0;
}
.auth-form-panel {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
background-color: #ffffff;
min-height: 100vh;
}
.auth-form-container {
width: 100%;
max-width: 420px;
}
.auth-form-container h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
margin-bottom: 0.375rem;
}
.auth-form-container .subtext {
color: #64748b;
margin-bottom: 2rem;
font-size: 0.95rem;
}
.password-wrapper {
position: relative;
}
.password-wrapper .toggle-pw {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
padding: 0;
font-size: 1rem;
}
.password-wrapper .toggle-pw:hover { color: #475569; }
.password-wrapper input { padding-right: 2.75rem; }
.password-strength {
height: 4px;
border-radius: 2px;
margin-top: 0.4rem;
transition: all 0.3s;
}
</style>
}
<div class="d-flex" style="min-height:100vh;">
<!-- Left Brand Panel -->
<div class="col-lg-5 d-none d-lg-flex auth-brand-panel">
<img src="/images/pcl-logo.png" alt="Powder Coating Logix" style="max-width:220px; margin-bottom:1.5rem;" />
<h1>Powder Coating Logix</h1>
<p class="tagline">The complete management platform for powder coating businesses</p>
<ul class="feature-list">
<li><i class="bi bi-briefcase-fill"></i> Job &amp; Quote Management</li>
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
<li><i class="bi bi-box-seam-fill"></i> Inventory &amp; Equipment</li>
<li><i class="bi bi-graph-up-arrow"></i> Analytics &amp; Reports</li>
</ul>
</div>
<!-- Right Form Panel -->
<div class="auth-form-panel col-lg-7">
<div class="auth-form-container">
@if (Model.InvalidToken)
{
<div class="text-center py-3">
<div class="mb-4" style="font-size:3.5rem; color:#ef4444;">
<i class="bi bi-x-circle"></i>
</div>
<h2 class="mb-2">Link expired or invalid</h2>
<p class="subtext mb-4">
This password reset link has expired or has already been used.
Reset links are only valid for 24 hours.
</p>
<a asp-page="./ForgotPassword" class="btn btn-primary me-2">
Request a new link
</a>
<a asp-page="./Login" class="btn btn-outline-secondary">
Back to Sign In
</a>
</div>
}
else if (Model.ResetSucceeded)
{
<div class="text-center py-3">
<div class="mb-4" style="font-size:3.5rem; color:#22c55e;">
<i class="bi bi-check-circle"></i>
</div>
<h2 class="mb-2">Password reset!</h2>
<p class="subtext mb-4">
Your password has been updated successfully. You can now sign in with your new password.
</p>
<a asp-page="./Login" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-1"></i> Sign In
</a>
</div>
}
else
{
<div class="mb-4" style="font-size:2.5rem; color:#4fc3f7;">
<i class="bi bi-shield-lock"></i>
</div>
<h2>Choose a new password</h2>
<p class="subtext">Must be at least 12 characters with uppercase, lowercase, a number, and a special character.</p>
<form method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger py-2 mb-3" role="alert"></div>
<input type="hidden" asp-for="Input.Token" />
<input type="hidden" asp-for="Input.Email" />
<div class="mb-3">
<label asp-for="Input.NewPassword" class="form-label fw-medium">New password</label>
<div class="password-wrapper">
<input asp-for="Input.NewPassword" id="newPasswordInput"
class="form-control form-control-lg"
autocomplete="new-password" placeholder="••••••••" />
<button type="button" class="toggle-pw" tabindex="-1"
onclick="togglePassword('newPasswordInput', 'newPwIcon')" aria-label="Show/hide password">
<i class="bi bi-eye" id="newPwIcon"></i>
</button>
</div>
<div class="password-strength bg-secondary" id="strengthBar" style="width:0%;"></div>
<span asp-validation-for="Input.NewPassword" class="text-danger small"></span>
</div>
<div class="mb-4">
<label asp-for="Input.ConfirmPassword" class="form-label fw-medium">Confirm password</label>
<div class="password-wrapper">
<input asp-for="Input.ConfirmPassword" id="confirmPasswordInput"
class="form-control form-control-lg"
autocomplete="new-password" placeholder="••••••••" />
<button type="button" class="toggle-pw" tabindex="-1"
onclick="togglePassword('confirmPasswordInput', 'confirmPwIcon')" aria-label="Show/hide password">
<i class="bi bi-eye" id="confirmPwIcon"></i>
</button>
</div>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger small"></span>
</div>
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-lg me-1"></i> Reset Password
</button>
</div>
</form>
<div class="text-center mt-2">
<a asp-page="./Login" class="text-decoration-none small" style="color:var(--primary-color);">
<i class="bi bi-arrow-left me-1"></i> Back to Sign In
</a>
</div>
}
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
function togglePassword(inputId, iconId) {
var input = document.getElementById(inputId);
var icon = document.getElementById(iconId);
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
}
// Simple password strength indicator
var pwInput = document.getElementById('newPasswordInput');
if (pwInput) {
pwInput.addEventListener('input', function () {
var val = this.value;
var score = 0;
if (val.length >= 12) score++;
if (/[A-Z]/.test(val)) score++;
if (/[a-z]/.test(val)) score++;
if (/[0-9]/.test(val)) score++;
if (/[^A-Za-z0-9]/.test(val)) score++;
var bar = document.getElementById('strengthBar');
var colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#16a34a'];
var widths = ['20%', '40%', '60%', '80%', '100%'];
if (val.length === 0) {
bar.style.width = '0%';
} else {
bar.style.width = widths[score - 1] || '20%';
bar.style.backgroundColor = colors[score - 1] || '#ef4444';
}
});
}
</script>
}
@@ -0,0 +1,108 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PowderCoating.Core.Entities;
namespace PowderCoating.Web.Areas.Identity.Pages.Account;
public class ResetPasswordModel : PageModel
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<ResetPasswordModel> _logger;
public ResetPasswordModel(UserManager<ApplicationUser> userManager, ILogger<ResetPasswordModel> logger)
{
_userManager = userManager;
_logger = logger;
}
[BindProperty]
public InputModel Input { get; set; } = new();
public bool InvalidToken { get; set; }
public bool ResetSucceeded { get; set; }
public class InputModel
{
[Required]
public string Token { get; set; } = string.Empty;
[Required]
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Please enter a new password.")]
[StringLength(100, MinimumLength = 12, ErrorMessage = "Password must be at least 12 characters.")]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; } = string.Empty;
[Required(ErrorMessage = "Please confirm your new password.")]
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare(nameof(NewPassword), ErrorMessage = "Passwords do not match.")]
public string ConfirmPassword { get; set; } = string.Empty;
}
public IActionResult OnGet(string? token, string? email)
{
if (string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(email))
{
InvalidToken = true;
return Page();
}
// Decode the token from the URL
try
{
Input.Token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token));
}
catch
{
InvalidToken = true;
return Page();
}
Input.Email = email;
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null)
{
// Don't reveal that the user doesn't exist — just show success
ResetSucceeded = true;
return Page();
}
var result = await _userManager.ResetPasswordAsync(user, Input.Token, Input.NewPassword);
if (result.Succeeded)
{
_logger.LogInformation("Password reset successfully for {Email}", Input.Email);
ResetSucceeded = true;
return Page();
}
// If the token is invalid or expired, ResetPasswordAsync returns an error with code "InvalidToken"
if (result.Errors.Any(e => e.Code == "InvalidToken"))
{
_logger.LogWarning("Invalid or expired password reset token for {Email}", Input.Email);
InvalidToken = true;
return Page();
}
foreach (var error in result.Errors)
ModelState.AddModelError(string.Empty, error.Description);
return Page();
}
}
@@ -0,0 +1,6 @@
@using Microsoft.AspNetCore.Identity
@using PowderCoating.Web.Areas.Identity
@using PowderCoating.Web.Areas.Identity.Pages
@using PowderCoating.Web.Areas.Identity.Pages.Account
@using PowderCoating.Core.Entities
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,3 @@
@{
Layout = "/Views/Shared/_Layout.cshtml";
}