Phase 4C AuthService

This commit is contained in:
Nick Beckley 2026-06-06 19:33:09 +01:00
parent a294df4e14
commit 44416ec445
13 changed files with 613 additions and 12 deletions

View File

@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
@ -7,12 +9,14 @@ namespace PlotLine.Controllers;
public sealed class AccountController(IAuthService authService) : Controller
{
[HttpGet]
[AllowAnonymous]
public IActionResult Register(string? returnUrl = null)
{
return View(new RegisterViewModel());
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(RegisterViewModel model)
{
@ -21,24 +25,35 @@ public sealed class AccountController(IAuthService authService) : Controller
return View(model);
}
var result = await authService.RegisterAsync(model);
RegistrationResult result;
try
{
result = await authService.RegisterAsync(model);
}
catch
{
ModelState.AddModelError(string.Empty, "Your account could not be created because the verification email could not be sent. Please try again later.");
return View(model);
}
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Registration could not be completed.");
return View(model);
}
TempData["AuthMessage"] = "Your account has been created. You can now log in.";
TempData["AuthMessage"] = "Your account has been created. Check your email to verify your address before signing in. If it does not arrive, use the resend verification email link below.";
return RedirectToAction(nameof(Login));
}
[HttpGet]
[AllowAnonymous]
public IActionResult Login(string? returnUrl = null)
{
return View(new LoginViewModel { ReturnUrl = returnUrl });
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginViewModel model)
{
@ -71,8 +86,141 @@ public sealed class AccountController(IAuthService authService) : Controller
}
[HttpGet]
[AllowAnonymous]
public IActionResult AccessDenied()
{
return View();
}
[HttpGet]
[AllowAnonymous]
public async Task<IActionResult> VerifyEmail(Guid token)
{
if (token == Guid.Empty)
{
return View(new AccountStatusViewModel
{
Success = false,
Title = "Verification link problem",
Message = "This verification link is not valid."
});
}
var result = await authService.VerifyEmailAsync(token);
if (result.Success)
{
return RedirectToAction(nameof(VerifyEmailConfirmation));
}
return View(new AccountStatusViewModel
{
Success = false,
Title = "Verification link problem",
Message = result.ErrorMessage ?? "This verification link could not be used."
});
}
[HttpGet]
[AllowAnonymous]
public IActionResult VerifyEmailConfirmation()
{
return View();
}
[HttpGet]
[AllowAnonymous]
public IActionResult ForgotPassword()
{
return View(new ForgotPasswordViewModel());
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
try
{
var result = await authService.ForgotPasswordAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later.");
return View(model);
}
}
catch
{
ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later.");
return View(model);
}
TempData["AuthMessage"] = "If an account exists for that email address, a password reset link has been sent.";
return RedirectToAction(nameof(Login));
}
[HttpGet]
[AllowAnonymous]
public IActionResult ResetPassword(Guid token)
{
if (token == Guid.Empty)
{
return View(new ResetPasswordViewModel());
}
return View(new ResetPasswordViewModel { Token = token });
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.ResetPasswordAsync(model);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Your password could not be reset.");
return View(model);
}
TempData["AuthMessage"] = "Your password has been reset. You can now log in.";
return RedirectToAction(nameof(Login));
}
[HttpGet]
[AllowAnonymous]
public IActionResult ResendVerificationEmail()
{
return View(new ResendVerificationEmailViewModel());
}
[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResendVerificationEmail(ResendVerificationEmailViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
var result = await authService.ResendVerificationEmailAsync(model.Email);
if (!result.Success)
{
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "We could not send the verification email right now. Please try again in a few minutes.");
return View(model);
}
TempData["AuthMessage"] = "If an unverified account exists for that email address, a new verification email has been sent.";
return View(new ResendVerificationEmailViewModel());
}
}

View File

@ -0,0 +1,12 @@
namespace PlotLine.Models;
public sealed class EmailSettings
{
public string SmtpServer { get; set; } = string.Empty;
public int Port { get; set; } = 465;
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public bool EnableSsl { get; set; } = true;
public string FromAddress { get; set; } = string.Empty;
public string FromName { get; set; } = "PlotWeaver Studio";
}

View File

@ -1,4 +1,5 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.Services;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
@ -17,6 +18,7 @@ public class Program
builder.Services.AddControllersWithViews();
builder.Services.AddHttpContextAccessor();
builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "App_Data", "DataProtectionKeys")));
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
@ -91,7 +93,7 @@ public class Program
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
builder.Services.AddScoped<IEmailService>(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase."));
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
var app = builder.Build();

View File

@ -10,6 +10,7 @@ public interface IAuthService
Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model);
Task LogoutAsync();
Task<AuthenticationResult> VerifyEmailAsync(Guid token);
Task<AuthenticationResult> ResendVerificationEmailAsync(string email);
Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model);
Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model);
Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model);

View File

@ -1,7 +1,11 @@
using System.Security.Claims;
using System.Net;
using System.Net.Mail;
using System.Net.Mime;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
@ -44,18 +48,156 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
public bool IsAuthenticated => User?.Identity?.IsAuthenticated == true;
}
public sealed class EmailService(
IOptions<EmailSettings> emailOptions,
IHttpContextAccessor httpContextAccessor,
ILogger<EmailService> logger) : IEmailService
{
private readonly EmailSettings _settings = emailOptions.Value;
public Task SendVerificationEmailAsync(string email, string displayName, Guid token)
{
var link = BuildAccountUrl("VerifyEmail", token);
var html = BuildTemplate(
"Verify your PlotWeaver Studio account",
displayName,
"Please verify your email address to finish setting up your PlotWeaver Studio account.",
"Verify account",
link,
"This link expires in 24 hours.");
return SendAsync(email, displayName, "Verify your PlotWeaver Studio account", html);
}
public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token)
{
var link = BuildAccountUrl("ResetPassword", token);
var html = BuildTemplate(
"Reset your PlotWeaver Studio password",
displayName,
"Use the link below to choose a new password for your PlotWeaver Studio account.",
"Reset password",
link,
"This link expires in 24 hours. If you did not request a reset, you can ignore this email.");
return SendAsync(email, displayName, "Reset your PlotWeaver Studio password", html);
}
private async Task SendAsync(string toEmail, string displayName, string subject, string html)
{
if (string.IsNullOrWhiteSpace(_settings.SmtpServer)
|| string.IsNullOrWhiteSpace(_settings.Username)
|| string.IsNullOrWhiteSpace(_settings.Password)
|| string.IsNullOrWhiteSpace(_settings.FromAddress))
{
logger.LogWarning("Email '{Subject}' for {Email} could not be sent because SMTP settings are incomplete.", subject, toEmail);
throw new InvalidOperationException("Email settings are not configured.");
}
try
{
using var message = new MailMessage
{
From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)),
Subject = subject,
Body = html,
IsBodyHtml = true
};
message.To.Add(new MailAddress(toEmail, CleanDisplayName(displayName)));
message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(StripHtml(html), null, MediaTypeNames.Text.Plain));
using var client = new SmtpClient(_settings.SmtpServer, _settings.Port)
{
EnableSsl = _settings.EnableSsl,
Credentials = new NetworkCredential(_settings.Username, _settings.Password)
};
await client.SendMailAsync(message);
logger.LogInformation("Email '{Subject}' sent to {Email}.", subject, toEmail);
}
catch (Exception ex)
{
logger.LogError(ex, "Email '{Subject}' could not be sent to {Email}.", subject, toEmail);
throw;
}
}
private string BuildAccountUrl(string action, Guid token)
{
var request = httpContextAccessor.HttpContext?.Request;
var host = request?.Host.Value;
if (string.IsNullOrWhiteSpace(host))
{
throw new InvalidOperationException("Cannot build account email link without an active request host.");
}
return $"https://{host}/Account/{action}?token={Uri.EscapeDataString(token.ToString("D"))}";
}
private static string BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote)
{
var safeName = WebUtility.HtmlEncode(displayName);
var safeTitle = WebUtility.HtmlEncode(title);
var safeIntro = WebUtility.HtmlEncode(intro);
var safeButton = WebUtility.HtmlEncode(buttonText);
var safeLink = WebUtility.HtmlEncode(link);
var safeFootnote = WebUtility.HtmlEncode(footnote);
return $"""
<!doctype html>
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
<div style="max-width:620px;margin:0 auto;background:#fffaf2;border:1px solid #e4d4bd;border-radius:10px;padding:28px;">
<p style="margin:0 0 8px;text-transform:uppercase;letter-spacing:.08em;color:#7a5b3b;font-size:12px;font-weight:700;">PlotWeaver Studio</p>
<h1 style="margin:0 0 16px;font-size:26px;">{safeTitle}</h1>
<p>Hello {safeName},</p>
<p>{safeIntro}</p>
<p style="margin:28px 0;">
<a href="{safeLink}" style="display:inline-block;background:#2f6f63;color:#fff;text-decoration:none;padding:12px 18px;border-radius:6px;font-weight:700;">{safeButton}</a>
</p>
<p>If the button does not work, copy and paste this link into your browser:</p>
<p style="word-break:break-all;color:#4d625c;">{safeLink}</p>
<p style="color:#6f6256;font-size:14px;">{safeFootnote}</p>
</div>
</body>
</html>
""";
}
private static string StripHtml(string html)
{
return WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " "));
}
private static string CleanDisplayName(string value)
{
return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim();
}
}
public sealed class AuthService(
IUserRepository users,
IAuthenticationRepository authentication,
IPasswordService passwords,
IEmailService emails,
IOptions<EmailSettings> emailOptions,
IHttpContextAccessor httpContextAccessor,
ILogger<AuthService> logger) : IAuthService
{
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
private readonly EmailSettings _emailSettings = emailOptions.Value;
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
{
var email = Clean(model.Email);
var displayName = Clean(model.DisplayName);
if (!EmailIsConfigured())
{
logger.LogWarning("Registration could not continue because SMTP settings are incomplete.");
return RegistrationResult.Failed("Email sending is not configured yet. Please try again later.");
}
var existingUser = await users.GetByEmailAsync(email);
if (existingUser is not null)
{
@ -63,8 +205,11 @@ public sealed class AuthService(
}
var passwordHash = passwords.HashPassword(model.Password);
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: true);
logger.LogInformation("User {UserID} registered with email confirmation temporarily enabled for Phase 4B.", created.UserID);
var created = await users.CreateAsync(email, displayName, passwordHash, emailConfirmed: false);
var token = Guid.NewGuid();
await authentication.CreateEmailVerificationTokenAsync(created.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
await emails.SendVerificationEmailAsync(created.Email, created.DisplayName, token);
logger.LogInformation("User {UserID} registered and was sent an email verification token.", created.UserID);
return RegistrationResult.Succeeded(created.UserID);
}
@ -72,6 +217,12 @@ public sealed class AuthService(
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
{
var email = Clean(model.Email);
if (!EmailIsConfigured())
{
logger.LogWarning("Password reset email could not be sent because SMTP settings are incomplete.");
return AuthenticationResult.Failed("Password reset email could not be sent right now.");
}
var user = await users.GetByEmailAsync(email);
if (user is null)
{
@ -79,6 +230,12 @@ public sealed class AuthService(
return AuthenticationResult.Failed("The email address or password was not recognised.");
}
if (!user.EmailConfirmed)
{
await InsertLoginAuditAsync(user.UserID, email, false, "Email not verified");
return AuthenticationResult.Failed("Please verify your email address before signing in.");
}
if (!passwords.VerifyPassword(model.Password, user.PasswordHash))
{
await users.UpdateFailedLoginAsync(user.UserID, lockoutEndUtc: null);
@ -105,14 +262,109 @@ public sealed class AuthService(
public Task<AuthenticationResult> CompleteTwoFactorLoginAsync(TwoFactorViewModel model)
=> throw new NotImplementedException("Two-factor login will be implemented in a later phase.");
public Task<AuthenticationResult> VerifyEmailAsync(Guid token)
=> throw new NotImplementedException("Email verification will be implemented in a later phase.");
public async Task<AuthenticationResult> VerifyEmailAsync(Guid token)
{
var storedToken = await authentication.GetEmailVerificationTokenAsync(token);
if (storedToken is null)
{
logger.LogWarning("Email verification failed because token {Token} was not found.", token);
return AuthenticationResult.Failed("This verification link is not valid.");
}
public Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
=> throw new NotImplementedException("Forgotten password handling will be implemented in a later phase.");
if (storedToken.UsedUtc.HasValue)
{
return AuthenticationResult.Failed("This verification link has already been used.");
}
public Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
=> throw new NotImplementedException("Password reset handling will be implemented in a later phase.");
if (storedToken.ExpiryUtc < DateTime.UtcNow)
{
return AuthenticationResult.Failed("This verification link has expired.");
}
var confirmed = await users.ConfirmEmailAsync(storedToken.UserID, token);
return confirmed
? AuthenticationResult.Succeeded()
: AuthenticationResult.Failed("This verification link could not be used.");
}
public async Task<AuthenticationResult> ResendVerificationEmailAsync(string email)
{
var cleanedEmail = Clean(email);
var user = await users.GetByEmailAsync(cleanedEmail);
if (user is null)
{
logger.LogInformation("Verification resend requested for unknown email address {Email}.", cleanedEmail);
return AuthenticationResult.Succeeded();
}
if (user.EmailConfirmed)
{
logger.LogInformation("Verification resend requested for already verified user {UserID}.", user.UserID);
return AuthenticationResult.Succeeded();
}
if (!EmailIsConfigured())
{
logger.LogWarning("Verification resend for user {UserID} could not continue because SMTP settings are incomplete.", user.UserID);
return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes.");
}
try
{
var token = Guid.NewGuid();
await authentication.CreateEmailVerificationTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
await emails.SendVerificationEmailAsync(user.Email, user.DisplayName, token);
logger.LogInformation("Verification email resent to user {UserID}.", user.UserID);
return AuthenticationResult.Succeeded();
}
catch (Exception ex)
{
logger.LogError(ex, "Verification email resend failed for user {UserID}.", user.UserID);
return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes.");
}
}
public async Task<AuthenticationResult> ForgotPasswordAsync(ForgotPasswordViewModel model)
{
var email = Clean(model.Email);
var user = await users.GetByEmailAsync(email);
if (user is null)
{
logger.LogInformation("Password reset requested for unknown email address {Email}.", email);
return AuthenticationResult.Succeeded();
}
var token = Guid.NewGuid();
await authentication.CreatePasswordResetTokenAsync(user.UserID, token, DateTime.UtcNow.Add(TokenLifetime));
await emails.SendPasswordResetEmailAsync(user.Email, user.DisplayName, token);
return AuthenticationResult.Succeeded();
}
public async Task<AuthenticationResult> ResetPasswordAsync(ResetPasswordViewModel model)
{
var storedToken = await authentication.GetPasswordResetTokenAsync(model.Token);
if (storedToken is null)
{
logger.LogWarning("Password reset failed because token {Token} was not found.", model.Token);
return AuthenticationResult.Failed("This password reset link is not valid.");
}
if (storedToken.UsedUtc.HasValue)
{
return AuthenticationResult.Failed("This password reset link has already been used.");
}
if (storedToken.ExpiryUtc < DateTime.UtcNow)
{
return AuthenticationResult.Failed("This password reset link has expired.");
}
var passwordHash = passwords.HashPassword(model.Password);
var reset = await users.ResetPasswordAsync(storedToken.UserID, model.Token, passwordHash);
return reset
? AuthenticationResult.Succeeded()
: AuthenticationResult.Failed("Your password could not be reset with this link.");
}
public Task<AuthenticationResult> ChangePasswordAsync(ChangePasswordViewModel model)
=> throw new NotImplementedException("Password changes will be implemented in a later phase.");
@ -162,4 +414,12 @@ public sealed class AuthService(
}
private static string Clean(string value) => value.Trim();
private bool EmailIsConfigured()
{
return !string.IsNullOrWhiteSpace(_emailSettings.SmtpServer)
&& !string.IsNullOrWhiteSpace(_emailSettings.Username)
&& !string.IsNullOrWhiteSpace(_emailSettings.Password)
&& !string.IsNullOrWhiteSpace(_emailSettings.FromAddress);
}
}

View File

@ -48,13 +48,16 @@ public sealed class ForgotPasswordViewModel
public string Email { get; set; } = string.Empty;
}
public sealed class ResetPasswordViewModel
public sealed class ResendVerificationEmailViewModel
{
[Required(ErrorMessage = "Enter your email address.")]
[EmailAddress(ErrorMessage = "Enter a valid email address.")]
[StringLength(256, ErrorMessage = "Email must be 256 characters or fewer.")]
public string Email { get; set; } = string.Empty;
}
public sealed class ResetPasswordViewModel
{
[Required(ErrorMessage = "Password reset token is missing.")]
public Guid Token { get; set; }
@ -69,6 +72,13 @@ public sealed class ResetPasswordViewModel
public string ConfirmPassword { get; set; } = string.Empty;
}
public sealed class AccountStatusViewModel
{
public bool Success { get; set; }
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
public sealed class TwoFactorViewModel
{
[Required(ErrorMessage = "Enter your authentication code.")]

View File

@ -0,0 +1,35 @@
@model ForgotPasswordViewModel
@{
ViewData["Title"] = "Forgot password";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Forgot password</h1>
<p class="lead-text">Enter your email address and, if an account exists, we will send a password reset link.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="ForgotPassword" method="post" class="asset-create-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Email">Email address</label>
<input class="form-control" asp-for="Email" autocomplete="email" />
<span class="text-danger" asp-validation-for="Email"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Send reset link</button>
<a class="btn btn-outline-secondary" asp-action="Login">Back to login</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -43,7 +43,11 @@
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Login</button>
<a class="btn btn-outline-secondary" asp-action="Register">Create an account</a>
<a class="btn btn-outline-secondary" asp-action="ForgotPassword">Forgot password?</a>
</div>
<p class="mt-3 mb-0">
<a asp-action="ResendVerificationEmail">Need a new verification email?</a>
</p>
</form>
</section>

View File

@ -0,0 +1,41 @@
@model ResendVerificationEmailViewModel
@{
ViewData["Title"] = "Resend verification email";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Resend verification email</h1>
<p class="lead-text">If your verification email did not arrive or the link has expired, request a new one here.</p>
</div>
</div>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="alert alert-info">@authMessage</div>
}
<section class="edit-panel">
<form asp-action="ResendVerificationEmail" method="post" class="asset-create-form">
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Email">Email address</label>
<input class="form-control" asp-for="Email" autocomplete="email" />
<span class="text-danger" asp-validation-for="Email"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Send verification email</button>
<a class="btn btn-outline-secondary" asp-action="Login">Back to Login</a>
<a class="btn btn-outline-secondary" asp-action="Register">Back to Register</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,41 @@
@model ResetPasswordViewModel
@{
ViewData["Title"] = "Reset password";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Reset password</h1>
<p class="lead-text">Choose a new password for your PlotWeaver Studio account.</p>
</div>
</div>
<section class="edit-panel">
<form asp-action="ResetPassword" method="post" class="asset-create-form">
<input asp-for="Token" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" asp-for="Password">New password</label>
<input class="form-control" asp-for="Password" autocomplete="new-password" />
<span class="text-danger" asp-validation-for="Password"></span>
</div>
<div class="col-md-6">
<label class="form-label" asp-for="ConfirmPassword">Confirm password</label>
<input class="form-control" asp-for="ConfirmPassword" autocomplete="new-password" />
<span class="text-danger" asp-validation-for="ConfirmPassword"></span>
</div>
</div>
<div class="button-row mt-3">
<button class="btn btn-primary" type="submit">Reset password</button>
<a class="btn btn-outline-secondary" asp-action="Login">Back to login</a>
</div>
</form>
</section>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -0,0 +1,21 @@
@model AccountStatusViewModel
@{
ViewData["Title"] = Model.Title;
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>@Model.Title</h1>
</div>
</div>
<section class="edit-panel">
<div class="alert @(Model.Success ? "alert-info" : "alert-warning") mb-3">
@Model.Message
</div>
<div class="button-row">
<a class="btn btn-primary" asp-action="Login">Login</a>
<a class="btn btn-outline-secondary" asp-controller="Home" asp-action="Index">Back to Home</a>
</div>
</section>

View File

@ -0,0 +1,17 @@
@{
ViewData["Title"] = "Email verified";
}
<div class="page-heading compact">
<div>
<p class="eyebrow">Account</p>
<h1>Email verified</h1>
</div>
</div>
<section class="edit-panel">
<div class="alert alert-info mb-3">
Your email address has been verified. You can now log in to PlotWeaver Studio.
</div>
<a class="btn btn-primary" asp-action="Login">Login</a>
</section>

View File

@ -2,6 +2,15 @@
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=PlotLine;User Id=PlotLineApp;Password=t6eb1cvd9bNB27czde#h9r;MultipleActiveResultSets=true;Encrypt=false;"
},
"EmailSettings": {
"SmtpServer": "",
"Port": 465,
"Username": "",
"Password": "",
"EnableSsl": true,
"FromAddress": "",
"FromName": "PlotWeaver Studio"
},
"Logging": {
"LogLevel": {
"Default": "Information",