From da25aa43eaefdaddebc4bf0c87468a8022a9f7f1 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 17 Jun 2024 22:08:49 +0200 Subject: [PATCH] Add form validation to login/register pages (#23) --- .../Controllers/AuthController.cs | 11 +- src/AliasVault.Shared/Models/LoginModel.cs | 5 + src/AliasVault.Shared/Models/RegisterModel.cs | 10 + .../Models/Validation/MustBeTrueAttribute.cs | 30 +++ .../Models/WebApi/AliasEdit.cs | 47 ++++ .../WebApi/ServerValidationErrorResponse.cs | 93 ++++++++ .../Models/WebApi/Service.cs | 3 + src/AliasVault.WebApp/Auth/Pages/Login.razor | 31 ++- .../Auth/Pages/Register.razor | 97 +++----- .../Components/Alerts/AlertMessageError.razor | 3 + .../Alerts/AlertMessageSuccess.razor | 4 +- .../Alerts/GlobalNotificationDisplay.razor | 2 +- .../Components/ServerValidationErrors.razor | 41 ++++ src/AliasVault.WebApp/Layout/TopMenu.razor | 8 +- .../Pages/Aliases/AddEdit.razor | 217 ++++++++++-------- src/AliasVault.WebApp/Pages/Home.razor | 1 + 16 files changed, 434 insertions(+), 169 deletions(-) create mode 100644 src/AliasVault.Shared/Models/Validation/MustBeTrueAttribute.cs create mode 100644 src/AliasVault.Shared/Models/WebApi/AliasEdit.cs create mode 100644 src/AliasVault.Shared/Models/WebApi/ServerValidationErrorResponse.cs create mode 100644 src/AliasVault.WebApp/Components/ServerValidationErrors.razor diff --git a/src/AliasVault.Api/Controllers/AuthController.cs b/src/AliasVault.Api/Controllers/AuthController.cs index d3941e86a..338d81f01 100644 --- a/src/AliasVault.Api/Controllers/AuthController.cs +++ b/src/AliasVault.Api/Controllers/AuthController.cs @@ -5,6 +5,8 @@ // //----------------------------------------------------------------------- +using AliasVault.Shared.Models.WebApi; + namespace AliasVault.Api.Controllers; using System.IdentityModel.Tokens.Jwt; @@ -43,7 +45,7 @@ public class AuthController(AliasDbContext context, UserManager us return Ok(tokenModel); } - return Unauthorized(); + return BadRequest(ServerValidationErrorResponse.Create("Invalid username or password. Please try again.", 400)); } /// @@ -151,10 +153,9 @@ public class AuthController(AliasDbContext context, UserManager us var tokenModel = await GenerateNewTokenForUser(user); return Ok(tokenModel); } - else - { - return BadRequest(result.Errors); - } + + var errors = result.Errors.Select(e => e.Description).ToArray(); + return BadRequest(ServerValidationErrorResponse.Create(errors, 400)); } private string GenerateJwtToken(IdentityUser user) diff --git a/src/AliasVault.Shared/Models/LoginModel.cs b/src/AliasVault.Shared/Models/LoginModel.cs index a6c25e221..a499ce5aa 100644 --- a/src/AliasVault.Shared/Models/LoginModel.cs +++ b/src/AliasVault.Shared/Models/LoginModel.cs @@ -5,6 +5,8 @@ // //----------------------------------------------------------------------- +using System.ComponentModel.DataAnnotations; + namespace AliasVault.Shared.Models; /// @@ -15,10 +17,13 @@ public class LoginModel /// /// Gets or sets the email. /// + [Required] + [EmailAddress] public string Email { get; set; } = null!; /// /// Gets or sets the password. /// + [Required] public string Password { get; set; } = null!; } diff --git a/src/AliasVault.Shared/Models/RegisterModel.cs b/src/AliasVault.Shared/Models/RegisterModel.cs index 719288875..f8c0fae78 100644 --- a/src/AliasVault.Shared/Models/RegisterModel.cs +++ b/src/AliasVault.Shared/Models/RegisterModel.cs @@ -5,6 +5,9 @@ // //----------------------------------------------------------------------- +using System.ComponentModel.DataAnnotations; +using AliasVault.Shared.Models.Validation; + namespace AliasVault.Shared.Models; /// @@ -15,20 +18,27 @@ public class RegisterModel /// /// Gets or sets the email. /// + [Required] + [EmailAddress] public string Email { get; set; } = null!; /// /// Gets or sets the password. /// + [Required] + [MinLength(8, ErrorMessage = "Password must be at least 8 characters long.")] public string Password { get; set; } = null!; /// /// Gets or sets the password confirmation. /// + [Required] + [Compare("Password", ErrorMessage = "Passwords do not match.")] public string PasswordConfirm { get; set; } = null!; /// /// Gets or sets a value indicating whether the terms and conditions are accepted or not. /// + [MustBeTrue(ErrorMessage = "You must accept the terms and conditions.")] public bool AcceptTerms { get; set; } = false; } diff --git a/src/AliasVault.Shared/Models/Validation/MustBeTrueAttribute.cs b/src/AliasVault.Shared/Models/Validation/MustBeTrueAttribute.cs new file mode 100644 index 000000000..1162ee09a --- /dev/null +++ b/src/AliasVault.Shared/Models/Validation/MustBeTrueAttribute.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.Validation; + +using System.ComponentModel.DataAnnotations; + +/// +/// Validation attribute to ensure that a boolean property is true. +/// +public class MustBeTrueAttribute : ValidationAttribute +{ + /// + public override bool IsValid(object? value) + { + switch (value) + { + case null: + return false; + case bool b: + return b; + default: + throw new InvalidOperationException("Can only be used on boolean properties."); + } + } +} diff --git a/src/AliasVault.Shared/Models/WebApi/AliasEdit.cs b/src/AliasVault.Shared/Models/WebApi/AliasEdit.cs new file mode 100644 index 000000000..50b9608de --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/AliasEdit.cs @@ -0,0 +1,47 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi; + +using System.ComponentModel.DataAnnotations; + +/// +/// Alias model. +/// +public class AliasEdit +{ + /// + /// Gets or sets the name of the service. + /// + [Required] + public string ServiceName { get; set; } = null!; + + /// + /// Gets or sets the URL of the service. + /// + public string? ServiceUrl { get; set; } + + /// + /// Gets or sets the Alias Identity object. + /// + public Identity Identity { get; set; } = null!; + + /// + /// Gets or sets the Alias Password object. + /// + public Password Password { get; set; } = null!; + + /// + /// Gets or sets the Alias CreateDate. + /// + public DateTime CreateDate { get; set; } + + /// + /// Gets or sets the Alias LastUpdate. + /// + public DateTime LastUpdate { get; set; } +} diff --git a/src/AliasVault.Shared/Models/WebApi/ServerValidationErrorResponse.cs b/src/AliasVault.Shared/Models/WebApi/ServerValidationErrorResponse.cs new file mode 100644 index 000000000..86e7a004a --- /dev/null +++ b/src/AliasVault.Shared/Models/WebApi/ServerValidationErrorResponse.cs @@ -0,0 +1,93 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) lanedirt. All rights reserved. +// Licensed under the MIT license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Shared.Models.WebApi; + +using System.Text.Json.Serialization; + +/// +/// Represents the structure of a validation error response from the API. +/// +public class ServerValidationErrorResponse +{ + /// + /// Gets or sets the type of the error. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + /// + /// Gets or sets the title of the error. + /// + [JsonPropertyName("title")] + public string Title { get; set; } = null!; + + /// + /// Gets or sets the HTTP status code of the response. + /// + [JsonPropertyName("status")] + public int Status { get; set; } + + /// + /// Gets or sets the validation errors. The key is the name of the field that has the error, and the value is an array of error messages for that field. + /// + [JsonPropertyName("errors")] + public Dictionary Errors { get; set; } = new(); + + /// + /// Gets or sets the trace ID of the error. + /// + [JsonPropertyName("traceId")] + public string TraceId { get; set; } = null!; + + /// + /// Creates a new instance of . + /// + /// Title of the error. + /// Status code. + /// ServerValidationErrorResponse object. + public static ServerValidationErrorResponse Create(string title, int status) + { + var errors = new Dictionary + { + { title, [title] }, + }; + + return new ServerValidationErrorResponse + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", + Title = title, + Errors = errors, + Status = status, + TraceId = Guid.NewGuid().ToString(), + }; + } + + /// + /// Creates a new instance of . + /// + /// Array with errors. + /// Status code. + /// ServerValidationErrorResponse object. + public static ServerValidationErrorResponse Create(string[] errorArray, int status) + { + var errors = new Dictionary(); + foreach (var t in errorArray) + { + errors.Add(t, new[] { t }); + } + + return new ServerValidationErrorResponse + { + Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", + Title = errorArray.First(), + Errors = errors, + Status = status, + TraceId = Guid.NewGuid().ToString(), + }; + } +} diff --git a/src/AliasVault.Shared/Models/WebApi/Service.cs b/src/AliasVault.Shared/Models/WebApi/Service.cs index 15b30d82d..4377f108f 100644 --- a/src/AliasVault.Shared/Models/WebApi/Service.cs +++ b/src/AliasVault.Shared/Models/WebApi/Service.cs @@ -7,6 +7,8 @@ namespace AliasVault.Shared.Models.WebApi; +using System.ComponentModel.DataAnnotations; + /// /// Service model. /// @@ -15,6 +17,7 @@ public class Service /// /// Gets or sets the name of the service. /// + [Required] public string Name { get; set; } = null!; /// diff --git a/src/AliasVault.WebApp/Auth/Pages/Login.razor b/src/AliasVault.WebApp/Auth/Pages/Login.razor index 3c602c282..cf867beae 100644 --- a/src/AliasVault.WebApp/Auth/Pages/Login.razor +++ b/src/AliasVault.WebApp/Auth/Pages/Login.razor @@ -8,6 +8,7 @@ @inject AuthService AuthService @using System.Text.Json @using AliasVault.Shared.Models +@using AliasVault.Shared.Models.WebApi @using AliasVault.WebApp.Auth.Components @using AliasVault.WebApp.Auth.Services @@ -15,14 +16,20 @@ Sign in to AliasVault - + + + + +
- + +
- + +
@@ -41,12 +48,10 @@
- - - @code { - private LoginModel _user = new LoginModel(); + private readonly LoginModel _loginModel = new(); private FullScreenLoadingIndicator _loadingIndicator = new(); + private ServerValidationErrors _serverValidationErrors = new(); /// protected override async Task OnInitializedAsync() @@ -62,11 +67,20 @@ private async Task HandleLogin() { _loadingIndicator.Show(); + _serverValidationErrors.Clear(); try { - var result = await Http.PostAsJsonAsync("api/Auth/login", _user); + var result = await Http.PostAsJsonAsync("api/Auth/login", _loginModel); var responseContent = await result.Content.ReadAsStringAsync(); + + if (!result.IsSuccessStatusCode) + { + _serverValidationErrors.ParseResponse(responseContent); + StateHasChanged(); + return; + } + var tokenObject = JsonSerializer.Deserialize(responseContent); if (tokenObject != null) @@ -80,6 +94,7 @@ { // Handle the case where the token is not present in the response Console.WriteLine("Token not found in the response."); + return; } await AuthStateProvider.GetAuthenticationStateAsync(); diff --git a/src/AliasVault.WebApp/Auth/Pages/Register.razor b/src/AliasVault.WebApp/Auth/Pages/Register.razor index f0eccc770..ff73f3bbb 100644 --- a/src/AliasVault.WebApp/Auth/Pages/Register.razor +++ b/src/AliasVault.WebApp/Auth/Pages/Register.razor @@ -14,26 +14,34 @@ Create a Free Account - + + + + +
- + +
- + +
- + +
- +
+
@@ -43,79 +51,48 @@
-@if (validationErrors.Any()) -{ -
-
    - @foreach (var error in validationErrors) - { -
  • @error
  • - } -
-
-} - - - @code { - RegisterModel user = new(); - FullScreenLoadingIndicator loadingIndicator = new(); - List validationErrors = []; + private readonly RegisterModel _registerModel = new(); + private FullScreenLoadingIndicator _loadingIndicator = new(); + private ServerValidationErrors _serverValidationErrors = new(); async Task HandleRegister() { - loadingIndicator.Show(); - validationErrors.Clear(); + _loadingIndicator.Show(); + _serverValidationErrors.Clear(); try { - var result = await Http.PostAsJsonAsync("api/Auth/register", user); + var result = await Http.PostAsJsonAsync("api/Auth/register", _registerModel); + var responseContent = await result.Content.ReadAsStringAsync(); - if (result.IsSuccessStatusCode) + if (!result.IsSuccessStatusCode) { - var responseContent = await result.Content.ReadAsStringAsync(); - var tokenObject = JsonSerializer.Deserialize(responseContent); + _serverValidationErrors.ParseResponse(responseContent); + StateHasChanged(); + return; + } - if (tokenObject != null) - { - // Store the token as a plain string in local storage - await AuthService.StoreAccessTokenAsync(tokenObject.Token); - await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken); - await AuthStateProvider.GetAuthenticationStateAsync(); - } - else - { - // Handle the case where the token is not present in the response - Console.WriteLine("Token not found in the response."); - } + var tokenObject = JsonSerializer.Deserialize(responseContent); - NavigationManager.NavigateTo("/"); + if (tokenObject != null) + { + // Store the token as a plain string in local storage + await AuthService.StoreAccessTokenAsync(tokenObject.Token); + await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken); + await AuthStateProvider.GetAuthenticationStateAsync(); } else { - var responseContent = await result.Content.ReadAsStringAsync(); - var errorResponse = System.Text.Json.JsonSerializer.Deserialize(responseContent); - if (errorResponse != null && errorResponse.Errors != null) - { - foreach (var error in errorResponse.Errors.Values) - { - validationErrors.AddRange(error); - } - } + // Handle the case where the token is not present in the response + Console.WriteLine("Token not found in the response."); } + + NavigationManager.NavigateTo("/"); } finally { - loadingIndicator.Hide(); + _loadingIndicator.Hide(); } } - - public class ValidationErrorResponse - { - public string Type { get; set; } = null!; - public string Title { get; set; } = null!; - public int Status { get; set; } - public Dictionary Errors { get; set; } = new(); - public string TraceId { get; set; } = null!; - } } diff --git a/src/AliasVault.WebApp/Components/Alerts/AlertMessageError.razor b/src/AliasVault.WebApp/Components/Alerts/AlertMessageError.razor index 09782361f..8f646018f 100644 --- a/src/AliasVault.WebApp/Components/Alerts/AlertMessageError.razor +++ b/src/AliasVault.WebApp/Components/Alerts/AlertMessageError.razor @@ -11,6 +11,9 @@ @code { + /// + /// The message to show. + /// [Parameter] public string Message { get; set; } = string.Empty; } diff --git a/src/AliasVault.WebApp/Components/Alerts/AlertMessageSuccess.razor b/src/AliasVault.WebApp/Components/Alerts/AlertMessageSuccess.razor index cfb289137..c5694aaf3 100644 --- a/src/AliasVault.WebApp/Components/Alerts/AlertMessageSuccess.razor +++ b/src/AliasVault.WebApp/Components/Alerts/AlertMessageSuccess.razor @@ -11,7 +11,9 @@ @code { + /// + /// The message to show. + /// [Parameter] public string Message { get; set; } = string.Empty; - } diff --git a/src/AliasVault.WebApp/Components/Alerts/GlobalNotificationDisplay.razor b/src/AliasVault.WebApp/Components/Alerts/GlobalNotificationDisplay.razor index a684c9ffd..79afc9514 100644 --- a/src/AliasVault.WebApp/Components/Alerts/GlobalNotificationDisplay.razor +++ b/src/AliasVault.WebApp/Components/Alerts/GlobalNotificationDisplay.razor @@ -48,7 +48,7 @@ /// /// Refreshes the messages by adding any new messages from the PortalMessageService. /// - public void RefreshAddMessages() + private void RefreshAddMessages() { // We retrieve any additional messages from the GlobalNotificationService that we do not yet have. var newMessages = GlobalNotificationService.GetMessagesForDisplay(); diff --git a/src/AliasVault.WebApp/Components/ServerValidationErrors.razor b/src/AliasVault.WebApp/Components/ServerValidationErrors.razor new file mode 100644 index 000000000..8bf7d277b --- /dev/null +++ b/src/AliasVault.WebApp/Components/ServerValidationErrors.razor @@ -0,0 +1,41 @@ +@using AliasVault.Shared.Models.WebApi + +@if (_errors.Any()) +{ + @foreach (var error in _errors) + { + + } +} + +@code { + private bool IsVisible { get; set; } + private List _errors = []; + + /// + /// Parses the response content and displays the server validation errors. + /// + public void ParseResponse(string responseContent) + { + _errors.Clear(); + var errorResponse = System.Text.Json.JsonSerializer.Deserialize(responseContent); + if (errorResponse is not null) + { + foreach (var error in errorResponse.Errors) + { + _errors.AddRange(error.Value); + } + } + + StateHasChanged(); + } + + /// + /// Clears the server validation errors. + /// + public void Clear() + { + _errors.Clear(); + StateHasChanged(); + } +} diff --git a/src/AliasVault.WebApp/Layout/TopMenu.razor b/src/AliasVault.WebApp/Layout/TopMenu.razor index 7b610bea6..80e5d5840 100644 --- a/src/AliasVault.WebApp/Layout/TopMenu.razor +++ b/src/AliasVault.WebApp/Layout/TopMenu.razor @@ -43,7 +43,7 @@ {
- @Username + @_username
  • @@ -101,14 +101,16 @@ @code { private bool isMenuOpen = false; - public string Username { get; set; } = ""; + private string _username { get; set; } = ""; + /// protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); - Username = await GetUsernameAsync(); + _username = await GetUsernameAsync(); } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); diff --git a/src/AliasVault.WebApp/Pages/Aliases/AddEdit.razor b/src/AliasVault.WebApp/Pages/Aliases/AddEdit.razor index 2e9e3a261..9c994c04d 100644 --- a/src/AliasVault.WebApp/Pages/Aliases/AddEdit.razor +++ b/src/AliasVault.WebApp/Pages/Aliases/AddEdit.razor @@ -43,103 +43,107 @@ else { } else { -
    -
    -
    -

    Service

    -
    -
    - -
    -
    - + + +
    +
    +
    +

    Service

    +
    +
    + + +
    +
    + +
    -
    -
    -
    -

    Login credentials

    -
    - - - @if (IsIdentityLoading) - { -

    Loading...

    - } -
    -
    -
    - +
    +
    +

    Login credentials

    +
    + + + @if (IsIdentityLoading) + { +

    Loading...

    + }
    -
    - -
    -
    -
    - - +
    +
    + +
    +
    + +
    +
    +
    + + +
    -
    -
    -
    -
    -

    Identity

    -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - -
    -
    - +
    +
    +
    +

    Identity

    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    -
    - -
    + -@if (IsSaving) -{ -

    Saving...

    -} + @if (IsSaving) + { +

    Saving...

    + } } @@ -152,7 +156,7 @@ else private bool EditMode { get; set; } private bool Loading { get; set; } = true; - private Alias Obj { get; set; } = new(); + private AliasEdit Obj { get; set; } = new(); private bool IsIdentityLoading { get; set; } private bool IsSaving { get; set; } @@ -211,15 +215,17 @@ else return; } - Obj = alias; + Obj = AliasToAliasEdit(alias); } else { // Create new Obj - Obj = new Alias(); - Obj.Identity = new Shared.Models.WebApi.Identity(); - Obj.Service = new Shared.Models.WebApi.Service(); - Obj.Password = new Shared.Models.WebApi.Password(); + var alias = new Alias(); + alias.Identity = new Shared.Models.WebApi.Identity(); + alias.Service = new Shared.Models.WebApi.Service(); + alias.Password = new Shared.Models.WebApi.Password(); + + Obj = AliasToAliasEdit(alias); } // Hide loading spinner @@ -297,12 +303,12 @@ else { if (Id is not null) { - Id = await AliasService.UpdateAliasAsync(Obj, Id.Value); + Id = await AliasService.UpdateAliasAsync(AliasEditToAlias(Obj), Id.Value); } } else { - Id = await AliasService.InsertAliasAsync(Obj); + Id = await AliasService.InsertAliasAsync(AliasEditToAlias(Obj)); } IsSaving = false; @@ -327,4 +333,33 @@ else Navigation.NavigateTo("/alias/" + Id); } + + private AliasEdit AliasToAliasEdit(Alias alias) + { + return new AliasEdit + { + ServiceName = alias.Service.Name, + ServiceUrl = alias.Service.Url, + Password = alias.Password, + Identity = alias.Identity, + CreateDate = alias.CreateDate, + LastUpdate = alias.LastUpdate + }; + } + + private Alias AliasEditToAlias(AliasEdit alias) + { + return new Alias + { + Service = new Service + { + Name = alias.ServiceName, + Url = alias.ServiceUrl + }, + Password = alias.Password, + Identity = alias.Identity, + CreateDate = alias.CreateDate, + LastUpdate = alias.LastUpdate + }; + } } diff --git a/src/AliasVault.WebApp/Pages/Home.razor b/src/AliasVault.WebApp/Pages/Home.razor index 5c7ebabae..cf58b04bc 100644 --- a/src/AliasVault.WebApp/Pages/Home.razor +++ b/src/AliasVault.WebApp/Pages/Home.razor @@ -36,6 +36,7 @@ private bool IsLoading { get; set; } = true; private List Aliases { get; set; } = new(); + /// protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender)