From 44416ec445bad73dc4d722718c31e21abc418286 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 6 Jun 2026 19:33:09 +0100 Subject: [PATCH] Phase 4C AuthService --- PlotLine/Controllers/AccountController.cs | 152 +++++++++- PlotLine/Models/EmailSettings.cs | 12 + PlotLine/Program.cs | 4 +- PlotLine/Services/AuthServiceInterfaces.cs | 1 + PlotLine/Services/AuthServices.cs | 276 +++++++++++++++++- PlotLine/ViewModels/AuthViewModels.cs | 12 +- PlotLine/Views/Account/ForgotPassword.cshtml | 35 +++ PlotLine/Views/Account/Login.cshtml | 4 + .../Account/ResendVerificationEmail.cshtml | 41 +++ PlotLine/Views/Account/ResetPassword.cshtml | 41 +++ PlotLine/Views/Account/VerifyEmail.cshtml | 21 ++ .../Account/VerifyEmailConfirmation.cshtml | 17 ++ PlotLine/appsettings.json | 9 + 13 files changed, 613 insertions(+), 12 deletions(-) create mode 100644 PlotLine/Models/EmailSettings.cs create mode 100644 PlotLine/Views/Account/ForgotPassword.cshtml create mode 100644 PlotLine/Views/Account/ResendVerificationEmail.cshtml create mode 100644 PlotLine/Views/Account/ResetPassword.cshtml create mode 100644 PlotLine/Views/Account/VerifyEmail.cshtml create mode 100644 PlotLine/Views/Account/VerifyEmailConfirmation.cshtml diff --git a/PlotLine/Controllers/AccountController.cs b/PlotLine/Controllers/AccountController.cs index 71aadc9..7b6215e 100644 --- a/PlotLine/Controllers/AccountController.cs +++ b/PlotLine/Controllers/AccountController.cs @@ -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 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 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 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 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 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 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()); + } } diff --git a/PlotLine/Models/EmailSettings.cs b/PlotLine/Models/EmailSettings.cs new file mode 100644 index 0000000..1e72162 --- /dev/null +++ b/PlotLine/Models/EmailSettings.cs @@ -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"; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index b5f47ab..810aa70 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -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(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(); builder.Services.AddScoped(); builder.Services.AddScoped(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase.")); - builder.Services.AddScoped(_ => throw new NotImplementedException("Authentication email sending will be implemented in a later phase.")); + builder.Services.AddScoped(); builder.Services.AddScoped(); var app = builder.Build(); diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs index 1c8b5f8..f7c18b7 100644 --- a/PlotLine/Services/AuthServiceInterfaces.cs +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -10,6 +10,7 @@ public interface IAuthService Task CompleteTwoFactorLoginAsync(TwoFactorViewModel model); Task LogoutAsync(); Task VerifyEmailAsync(Guid token); + Task ResendVerificationEmailAsync(string email); Task ForgotPasswordAsync(ForgotPasswordViewModel model); Task ResetPasswordAsync(ResetPasswordViewModel model); Task ChangePasswordAsync(ChangePasswordViewModel model); diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs index a84ae0c..1f22064 100644 --- a/PlotLine/Services/AuthServices.cs +++ b/PlotLine/Services/AuthServices.cs @@ -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 emailOptions, + IHttpContextAccessor httpContextAccessor, + ILogger 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 $""" + + + +
+

PlotWeaver Studio

+

{safeTitle}

+

Hello {safeName},

+

{safeIntro}

+

+ {safeButton} +

+

If the button does not work, copy and paste this link into your browser:

+

{safeLink}

+

{safeFootnote}

+
+ + + """; + } + + 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 emailOptions, IHttpContextAccessor httpContextAccessor, ILogger logger) : IAuthService { + private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); + private readonly EmailSettings _emailSettings = emailOptions.Value; + public async Task 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 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 CompleteTwoFactorLoginAsync(TwoFactorViewModel model) => throw new NotImplementedException("Two-factor login will be implemented in a later phase."); - public Task VerifyEmailAsync(Guid token) - => throw new NotImplementedException("Email verification will be implemented in a later phase."); + public async Task 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 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 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 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 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 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 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); + } } diff --git a/PlotLine/ViewModels/AuthViewModels.cs b/PlotLine/ViewModels/AuthViewModels.cs index 458254d..8650af0 100644 --- a/PlotLine/ViewModels/AuthViewModels.cs +++ b/PlotLine/ViewModels/AuthViewModels.cs @@ -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.")] diff --git a/PlotLine/Views/Account/ForgotPassword.cshtml b/PlotLine/Views/Account/ForgotPassword.cshtml new file mode 100644 index 0000000..6c6991b --- /dev/null +++ b/PlotLine/Views/Account/ForgotPassword.cshtml @@ -0,0 +1,35 @@ +@model ForgotPasswordViewModel +@{ + ViewData["Title"] = "Forgot password"; +} + +
+
+

Account

+

Forgot password

+

Enter your email address and, if an account exists, we will send a password reset link.

+
+
+ +
+
+
+ +
+
+ + + +
+
+ +
+ + Back to login +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Account/Login.cshtml b/PlotLine/Views/Account/Login.cshtml index 058ad63..72ac9c5 100644 --- a/PlotLine/Views/Account/Login.cshtml +++ b/PlotLine/Views/Account/Login.cshtml @@ -43,7 +43,11 @@ +

+ Need a new verification email? +

diff --git a/PlotLine/Views/Account/ResendVerificationEmail.cshtml b/PlotLine/Views/Account/ResendVerificationEmail.cshtml new file mode 100644 index 0000000..7149988 --- /dev/null +++ b/PlotLine/Views/Account/ResendVerificationEmail.cshtml @@ -0,0 +1,41 @@ +@model ResendVerificationEmailViewModel +@{ + ViewData["Title"] = "Resend verification email"; +} + +
+
+

Account

+

Resend verification email

+

If your verification email did not arrive or the link has expired, request a new one here.

+
+
+ +@if (TempData["AuthMessage"] is string authMessage) +{ +
@authMessage
+} + +
+
+
+ +
+
+ + + +
+
+ +
+ + Back to Login + Back to Register +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Account/ResetPassword.cshtml b/PlotLine/Views/Account/ResetPassword.cshtml new file mode 100644 index 0000000..8935063 --- /dev/null +++ b/PlotLine/Views/Account/ResetPassword.cshtml @@ -0,0 +1,41 @@ +@model ResetPasswordViewModel +@{ + ViewData["Title"] = "Reset password"; +} + +
+
+

Account

+

Reset password

+

Choose a new password for your PlotWeaver Studio account.

+
+
+ +
+
+ +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ + Back to login +
+
+
+ +@section Scripts { + +} diff --git a/PlotLine/Views/Account/VerifyEmail.cshtml b/PlotLine/Views/Account/VerifyEmail.cshtml new file mode 100644 index 0000000..49b08db --- /dev/null +++ b/PlotLine/Views/Account/VerifyEmail.cshtml @@ -0,0 +1,21 @@ +@model AccountStatusViewModel +@{ + ViewData["Title"] = Model.Title; +} + +
+
+

Account

+

@Model.Title

+
+
+ +
+
+ @Model.Message +
+ +
diff --git a/PlotLine/Views/Account/VerifyEmailConfirmation.cshtml b/PlotLine/Views/Account/VerifyEmailConfirmation.cshtml new file mode 100644 index 0000000..a6817c3 --- /dev/null +++ b/PlotLine/Views/Account/VerifyEmailConfirmation.cshtml @@ -0,0 +1,17 @@ +@{ + ViewData["Title"] = "Email verified"; +} + +
+
+

Account

+

Email verified

+
+
+ +
+
+ Your email address has been verified. You can now log in to PlotWeaver Studio. +
+ Login +
diff --git a/PlotLine/appsettings.json b/PlotLine/appsettings.json index bf5e6da..2e91861 100644 --- a/PlotLine/appsettings.json +++ b/PlotLine/appsettings.json @@ -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",