Initial commit
This commit is contained in:
@@ -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 & Quote Management</li>
|
||||
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
|
||||
<li><i class="bi bi-box-seam-fill"></i> Inventory & Equipment</li>
|
||||
<li><i class="bi bi-graph-up-arrow"></i> Analytics & 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 & Quote Management</li>
|
||||
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
|
||||
<li><i class="bi bi-box-seam-fill"></i> Inventory & Equipment</li>
|
||||
<li><i class="bi bi-graph-up-arrow"></i> Analytics & 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 →</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="/Registration">Create an account →</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 & Quote Management</li>
|
||||
<li><i class="bi bi-people-fill"></i> Customer CRM</li>
|
||||
<li><i class="bi bi-box-seam-fill"></i> Inventory & Equipment</li>
|
||||
<li><i class="bi bi-graph-up-arrow"></i> Analytics & 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";
|
||||
}
|
||||
Reference in New Issue
Block a user