diff --git a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs index 4234d33..648de91 100644 --- a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs +++ b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs @@ -1,6 +1,7 @@ namespace Shogi.UI.Identity; using Microsoft.AspNetCore.Components.Authorization; +using System.Net.Http; using System.Net.Http.Json; using System.Security.Claims; using System.Text; @@ -235,6 +236,38 @@ public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IA return _authenticated; } + /// + /// Ask for an email to be sent which contains a reset code. This reset code is used during + /// + /// Do not surface errors from this to users which may tell bad actors if emails do or do not exist in the system. + public async Task RequestPasswordReset(string email) + { + return await _httpClient.PostAsJsonAsync("forgotPassword", new { email }); + } + + public async Task ChangePassword(string email, string resetCode, string newPassword) + { + var body = new + { + email, + resetCode, + newPassword + }; + var response = await _httpClient.PostAsJsonAsync("resetPassword", body); + if (response.IsSuccessStatusCode) + { + return new FormResult { Succeeded = true }; + } + else + { + return new FormResult + { + Succeeded = false, + ErrorList = [await response.Content.ReadAsStringAsync()] + }; + } + } + public class RoleClaim { public string? Issuer { get; set; } diff --git a/Shogi.UI/Identity/IAccountManagement.cs b/Shogi.UI/Identity/IAccountManagement.cs index 6417a8e..3f92acc 100644 --- a/Shogi.UI/Identity/IAccountManagement.cs +++ b/Shogi.UI/Identity/IAccountManagement.cs @@ -28,4 +28,6 @@ public interface IAccountManagement public Task RegisterAsync(string email, string password); public Task CheckAuthenticatedAsync(); + Task RequestPasswordReset(string email); + Task ChangePassword(string email, string resetCode, string newPassword); } diff --git a/Shogi.UI/Pages/Identity/ForgotPassword.razor b/Shogi.UI/Pages/Identity/ForgotPassword.razor new file mode 100644 index 0000000..1fafd95 --- /dev/null +++ b/Shogi.UI/Pages/Identity/ForgotPassword.razor @@ -0,0 +1,125 @@ +@page "/forgot" +@inject IAccountManagement Acct + +
+

Forgot Password

+ + + @if (isReset) + { +

Your password has been reset. Log in with your new password any time.

+ } else if (isCodeSent) + { +

Look for an email from shogi@lucaserver.space with a reset code and fill out the form.

+ } + +
+ @if (errorList.Length > 0) + { +
    + @foreach (var error in errorList) + { +
  • @error
  • + } +
+ } + + + + @if (!isCodeSent) + { + + } + else + { + + + + + + + } +
+
+ +@code { + + private bool isCodeSent = false; + private bool isReset = false; + private string email = string.Empty; + private string code = string.Empty; + private string newPassword = string.Empty; + private string confirmPassword = string.Empty; + private string[] errorList = []; + + async Task SendResetCode() + { + if (string.IsNullOrWhiteSpace(email)) + { + errorList = ["Email is required"]; + return; + } + + var response = await Acct.RequestPasswordReset(email); + isCodeSent = response.IsSuccessStatusCode; + if (!response.IsSuccessStatusCode) + { + errorList = [await response.Content.ReadAsStringAsync()]; + } + } + + async Task ChangePassword() + { + var errors = new List(5); + if (string.IsNullOrWhiteSpace(email)) + { + errors.Add("Email is required"); + } + if (string.IsNullOrWhiteSpace(code)) + { + errors.Add("Reset code is required"); + } + if (string.IsNullOrWhiteSpace(newPassword)) + { + errors.Add("New password is required"); + } + if (!newPassword.Equals(confirmPassword)) + { + errors.Add("Your new password and confirm password do not match"); + } + + var result = await Acct.ChangePassword(email, code, newPassword); + if (result.Succeeded) + { + isReset = true; + ClearFormFields(); + } + else + { + errorList = result.ErrorList; + } + } + + void ClearFormFields() { + email = code = newPassword = confirmPassword = string.Empty; + } +} diff --git a/Shogi.UI/Pages/Identity/ForgotPassword.razor.css b/Shogi.UI/Pages/Identity/ForgotPassword.razor.css new file mode 100644 index 0000000..0bac370 --- /dev/null +++ b/Shogi.UI/Pages/Identity/ForgotPassword.razor.css @@ -0,0 +1,25 @@ +main { + padding: 1rem; +} + +.ForgotForm { + display: inline-flex; + flex-direction: column; + align-items: stretch; + gap: 1rem; +} + + .ForgotForm label { + display: flex; + justify-content: space-between; + gap: 1rem; + } + + .ForgotForm button { + align-self: end; + } + + .ForgotForm .Errors { + color: darkred; + background-color: var(--foregroundColor) + } diff --git a/Shogi.UI/Pages/Identity/LoginPage.razor b/Shogi.UI/Pages/Identity/LoginPage.razor index bb728eb..24b1bbe 100644 --- a/Shogi.UI/Pages/Identity/LoginPage.razor +++ b/Shogi.UI/Pages/Identity/LoginPage.razor @@ -27,6 +27,8 @@ + Reset password + diff --git a/Shogi.UI/Pages/Identity/LoginPage.razor.css b/Shogi.UI/Pages/Identity/LoginPage.razor.css index 6ee0527..42ff5c7 100644 --- a/Shogi.UI/Pages/Identity/LoginPage.razor.css +++ b/Shogi.UI/Pages/Identity/LoginPage.razor.css @@ -1,28 +1,20 @@ main { - /*display: grid; - grid-template-areas: - "header header header" - ". form ." - ". . ."; - grid-template-rows: auto 1fr 1fr; - - place-items: center; -*/ + padding: 1rem; } .LoginForm { grid-area: form; - display: inline-grid; grid-template-areas: "errors errors" "emailLabel emailControl" "passLabel passControl" + ". resetLink" "button button"; gap: 0.5rem 3rem; } -.LoginForm .Errors { - color: darkred; -} - + .LoginForm .Errors { + color: darkred; + background-color: var(--foregroundColor); + }