Add password strength indicator to change password screen (#773)

This commit is contained in:
Leendert de Borst
2026-03-14 20:49:29 +01:00
parent 24b3182cbc
commit d4d4a2e704
10 changed files with 250 additions and 23 deletions

View File

@@ -19,16 +19,18 @@
</div>
<div>
<label for="new-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">New password</label>
<InputText type="password" @bind-Value="Input.NewPassword" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
<input type="password" @bind="Input.NewPassword" @oninput="OnPasswordInput" id="new-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password."/>
<ValidationMessage For="() => Input.NewPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
</div>
<div>
<label for="confirm-password" class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">Confirm password</label>
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
<input type="password" @bind="Input.ConfirmPassword" @oninput="OnConfirmPasswordInput" id="confirm-password" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
<ValidationMessage For="() => Input.ConfirmPassword" class="mt-1 text-sm text-red-600 dark:text-red-400"/>
</div>
<div>
<SubmitButton>Update password</SubmitButton>
<button type="submit" disabled="@(!IsSubmitEnabled)" class="w-full px-4 py-2 text-white bg-primary-600 hover:bg-primary-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition duration-150 ease-in-out @(IsSubmitEnabled ? "" : "opacity-50 cursor-not-allowed")">
Update password
</button>
</div>
</EditForm>
</div>
@@ -38,6 +40,34 @@
[SupplyParameterFromForm] private InputModel Input { get; set; } = default!;
/// <summary>
/// Gets a value indicating whether the submit button should be enabled.
/// </summary>
private bool IsSubmitEnabled =>
!string.IsNullOrWhiteSpace(Input.OldPassword) &&
!string.IsNullOrWhiteSpace(Input.NewPassword) &&
!string.IsNullOrWhiteSpace(Input.ConfirmPassword) &&
Input.NewPassword == Input.ConfirmPassword &&
Input.NewPassword.Length >= 10;
/// <summary>
/// Handles password input to update state.
/// </summary>
private void OnPasswordInput(ChangeEventArgs e)
{
Input.NewPassword = e.Value?.ToString() ?? string.Empty;
StateHasChanged();
}
/// <summary>
/// Handles confirm password input to update button state.
/// </summary>
private void OnConfirmPasswordInput(ChangeEventArgs e)
{
Input.ConfirmPassword = e.Value?.ToString() ?? string.Empty;
StateHasChanged();
}
protected override void OnInitialized()
{
Input ??= new();
@@ -45,6 +75,7 @@
private async Task OnValidSubmitAsync()
{
var user = await UserManager.FindByIdAsync(UserService.User().Id);
if (user == null)
{

View File

@@ -2,6 +2,7 @@
@using System.Timers
@using Microsoft.Extensions.Localization
@using AliasVault.Client.Main.Components.Shared
@using AliasVault.Client.Main.Constants
<div class="w-full mx-auto">
@if (_isLoading)
@@ -158,7 +159,7 @@
{
await InvokeAsync(async () =>
{
if (Password.Length < 10)
if (Password.Length < PasswordStrengthConstants.MinimumGoodPasswordLength)
{
_errorMessage = Localizer["PasswordTooShortError"];
await OnPasswordChange.InvokeAsync(string.Empty);

View File

@@ -1,4 +1,5 @@
@using Microsoft.Extensions.Localization
@using AliasVault.Client.Main.Constants
@inject IStringLocalizerFactory LocalizerFactory
@if (!string.IsNullOrEmpty(Password))
@@ -88,10 +89,10 @@
return _passwordStrength switch
{
0 => "bg-orange-400 dark:bg-orange-500",
1 => "bg-orange-500 dark:bg-orange-600",
2 => "bg-yellow-500 dark:bg-yellow-600",
3 => "bg-blue-500 dark:bg-blue-600",
4 => "bg-green-500 dark:bg-green-600",
1 => "bg-yellow-500 dark:bg-yellow-600",
2 => "bg-green-500 dark:bg-green-600",
3 => "bg-green-600 dark:bg-green-700",
4 => "bg-green-700 dark:bg-green-800",
_ => "bg-gray-300 dark:bg-gray-600"
};
}
@@ -101,10 +102,10 @@
return _passwordStrength switch
{
0 => "text-orange-600 dark:text-orange-400",
1 => "text-orange-600 dark:text-orange-400",
2 => "text-yellow-600 dark:text-yellow-400",
3 => "text-blue-600 dark:text-blue-400",
4 => "text-green-600 dark:text-green-400",
1 => "text-yellow-600 dark:text-yellow-400",
2 => "text-green-600 dark:text-green-400",
3 => "text-green-700 dark:text-green-300",
4 => "text-green-800 dark:text-green-200",
_ => "text-gray-500 dark:text-gray-400"
};
}
@@ -113,4 +114,22 @@
{
return (_passwordStrength + 1) * 20;
}
/// <summary>
/// Gets the current password strength level.
/// </summary>
/// <returns>The password strength level (0-4).</returns>
public int GetPasswordStrength()
{
return _passwordStrength;
}
/// <summary>
/// Checks if the current password meets the minimum required strength.
/// </summary>
/// <returns>True if the password meets minimum requirements, false otherwise.</returns>
public bool MeetsMinimumRequirement()
{
return _passwordStrength >= PasswordStrengthConstants.MinimumRequiredStrength;
}
}

View File

@@ -0,0 +1,25 @@
//-----------------------------------------------------------------------
// <copyright file="PasswordStrengthConstants.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Client.Main.Constants;
/// <summary>
/// Constants for password strength validation and requirements.
/// </summary>
public static class PasswordStrengthConstants
{
/// <summary>
/// Minimum password strength level required for account creation and password changes.
/// Level 2 corresponds to "Good" (12-15 characters).
/// </summary>
public const int MinimumRequiredStrength = 2;
/// <summary>
/// Minimum password length for "Good" strength level.
/// </summary>
public const int MinimumGoodPasswordLength = 12;
}

View File

@@ -8,6 +8,7 @@
namespace AliasVault.Client.Main.Models.Validation;
using System.ComponentModel.DataAnnotations;
using AliasVault.Client.Main.Constants;
using AliasVault.Client.Resources;
using AliasVault.Shared.Models.WebApi.PasswordChange;
@@ -26,7 +27,7 @@ public class PasswordChangeFormModel : PasswordChangeModel
/// Gets or sets the new password.
/// </summary>
[Required(ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.PasswordRequired))]
[MinLength(10, ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.PasswordMinLength))]
[MinLength(PasswordStrengthConstants.MinimumGoodPasswordLength, ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.PasswordMinLength))]
public new string NewPassword { get; set; } = null!;
/// <summary>

View File

@@ -5,6 +5,8 @@
@using AliasVault.Shared.Models.WebApi.Vault;
@using AliasVault.Cryptography.Client
@using AliasVault.Client.Services.JsInterop.RustCore
@using AliasVault.Client.Main.Components.Shared
@using AliasVault.Client.Main.Constants
@using Microsoft.Extensions.Localization
@inherits MainBase
@inject HttpClient Http
@@ -40,20 +42,29 @@ else
<div>
<label for="newPassword" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["NewPasswordLabel"]</label>
<InputText type="password" id="newPassword" @bind-Value="PasswordChangeFormModel.NewPassword"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required />
<input type="password" id="newPassword" @bind="PasswordChangeFormModel.NewPassword" @oninput="OnPasswordInput"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required />
<PasswordStrengthIndicator @ref="PasswordStrengthIndicatorRef" Password="@PasswordChangeFormModel.NewPassword" />
</div>
<div>
<label for="newPasswordConfirm" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">@Localizer["ConfirmNewPasswordLabel"]</label>
<InputText type="password" id="newPasswordConfirm" @bind-Value="PasswordChangeFormModel.NewPasswordConfirm"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required />
<input type="password" id="newPasswordConfirm" @bind="PasswordChangeFormModel.NewPasswordConfirm" @oninput="OnConfirmPasswordInput"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-500 focus:border-primary-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500"
required />
</div>
@if (!string.IsNullOrEmpty(PasswordStrengthError))
{
<div class="p-3 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-lg">
@PasswordStrengthError
</div>
}
<button type="submit"
class="w-full bg-primary-500 text-white py-2 px-4 rounded-md hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition duration-150 ease-in-out">
disabled="@(!IsSubmitEnabled)"
class="w-full bg-primary-500 text-white py-2 px-4 rounded-md hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition duration-150 ease-in-out @(IsSubmitEnabled ? "" : "opacity-50 cursor-not-allowed")">
@Localizer["ChangePasswordButton"]
</button>
</EditForm>
@@ -104,6 +115,46 @@ else
private SrpSession ClientSession = new();
private string PrivateKey = string.Empty;
/// <summary>
/// Reference to the password strength indicator component.
/// </summary>
private PasswordStrengthIndicator? PasswordStrengthIndicatorRef { get; set; }
/// <summary>
/// Error message for password strength validation.
/// </summary>
private string PasswordStrengthError { get; set; } = string.Empty;
/// <summary>
/// Gets a value indicating whether the submit button should be enabled.
/// </summary>
private bool IsSubmitEnabled =>
!string.IsNullOrWhiteSpace(PasswordChangeFormModel.CurrentPassword) &&
!string.IsNullOrWhiteSpace(PasswordChangeFormModel.NewPassword) &&
!string.IsNullOrWhiteSpace(PasswordChangeFormModel.NewPasswordConfirm) &&
PasswordChangeFormModel.NewPassword == PasswordChangeFormModel.NewPasswordConfirm &&
PasswordStrengthIndicatorRef != null &&
PasswordStrengthIndicatorRef.MeetsMinimumRequirement();
/// <summary>
/// Handles real-time password input to update the strength indicator.
/// </summary>
private void OnPasswordInput(ChangeEventArgs e)
{
PasswordChangeFormModel.NewPassword = e.Value?.ToString() ?? string.Empty;
PasswordStrengthError = string.Empty;
StateHasChanged();
}
/// <summary>
/// Handles real-time confirm password input to update button state.
/// </summary>
private void OnConfirmPasswordInput(ChangeEventArgs e)
{
PasswordChangeFormModel.NewPasswordConfirm = e.Value?.ToString() ?? string.Empty;
StateHasChanged();
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
@@ -157,6 +208,17 @@ else
/// </summary>
private async Task InitiatePasswordChange()
{
// Clear any previous errors
PasswordStrengthError = string.Empty;
// Validate password strength before proceeding
if (PasswordStrengthIndicatorRef == null || !PasswordStrengthIndicatorRef.MeetsMinimumRequirement())
{
PasswordStrengthError = Localizer["PasswordStrengthTooWeakError"];
StateHasChanged();
return;
}
GlobalLoadingSpinner.Show(Localizer["ChangingPasswordMessage"]);
GlobalNotificationService.ClearMessages();
StateHasChanged();

View File

@@ -65,7 +65,7 @@
<comment>Success message for valid password</comment>
</data>
<data name="PasswordTooShortError">
<value>Master password must be at least 10 characters long.</value>
<value>Master password must be at least 12 characters long (Good strength or higher).</value>
<comment>Error message for password too short</comment>
</data>
<data name="ConfirmPasswordPrompt">

View File

@@ -116,4 +116,8 @@
<value>Failed to change password. Please refresh the page and try again.</value>
<comment>Error message when password change fails</comment>
</data>
<data name="PasswordStrengthTooWeakError" xml:space="preserve">
<value>Your new password must be at least 12 characters long (Good strength or higher).</value>
<comment>Error message when password strength is too weak</comment>
</data>
</root>

View File

@@ -61,7 +61,7 @@
<!-- Password validation messages -->
<data name="PasswordMinLength" xml:space="preserve">
<value>The new password must be at least 10 characters long.</value>
<value>The new password must be at least 12 characters long (Good strength or higher).</value>
<comment>Error message for password minimum length validation</comment>
</data>
<data name="PasswordsDoNotMatch" xml:space="preserve">
@@ -69,7 +69,7 @@
<comment>Error message when password confirmation doesn't match</comment>
</data>
<data name="PasswordMinLengthGeneric" xml:space="preserve">
<value>Password must be at least 10 characters long.</value>
<value>Password must be at least 12 characters long (Good strength or higher).</value>
<comment>Generic error message for password minimum length validation</comment>
</data>
<data name="PasswordsDoNotMatchGeneric" xml:space="preserve">

View File

@@ -1539,6 +1539,12 @@ video {
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
.space-y-1\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.375rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.375rem * var(--tw-space-y-reverse));
}
.divide-y > :not([hidden]) ~ :not([hidden]) {
--tw-divide-y-reverse: 0;
border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
@@ -1715,6 +1721,11 @@ video {
border-color: rgb(251 191 36 / var(--tw-border-opacity));
}
.border-blue-200 {
--tw-border-opacity: 1;
border-color: rgb(191 219 254 / var(--tw-border-opacity));
}
.border-blue-700 {
--tw-border-opacity: 1;
border-color: rgb(29 78 216 / var(--tw-border-opacity));
@@ -1894,6 +1905,11 @@ video {
background-color: rgb(240 253 244 / var(--tw-bg-opacity));
}
.bg-green-500 {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity));
}
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
@@ -2008,6 +2024,11 @@ video {
background-color: rgb(234 179 8 / var(--tw-bg-opacity));
}
.bg-orange-400 {
--tw-bg-opacity: 1;
background-color: rgb(251 146 60 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -2584,6 +2605,12 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-inner {
--tw-shadow: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);
--tw-shadow-colored: inset 0 2px 4px 0 var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.outline-0 {
outline-width: 0px;
}
@@ -2664,6 +2691,10 @@ video {
transition-duration: 300ms;
}
.duration-500 {
transition-duration: 500ms;
}
.ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
@@ -2672,6 +2703,10 @@ video {
transition-timing-function: linear;
}
.ease-out {
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
.file\:mr-4::file-selector-button {
margin-right: 1rem;
}
@@ -3154,6 +3189,11 @@ video {
cursor: not-allowed;
}
.disabled\:bg-gray-400:disabled {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.disabled\:opacity-50:disabled {
opacity: 0.5;
}
@@ -3181,6 +3221,11 @@ video {
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.dark\:border-blue-800:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(30 64 175 / var(--tw-border-opacity));
}
.dark\:border-gray-400:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(156 163 175 / var(--tw-border-opacity));
@@ -3285,6 +3330,10 @@ video {
background-color: rgb(30 58 138 / var(--tw-bg-opacity));
}
.dark\:bg-blue-900\/20:is(.dark *) {
background-color: rgb(30 58 138 / 0.2);
}
.dark\:bg-gray-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(107 114 128 / var(--tw-bg-opacity));
@@ -3428,6 +3477,26 @@ video {
background-color: rgb(113 63 18 / 0.2);
}
.dark\:bg-orange-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
}
.dark\:bg-yellow-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(202 138 4 / var(--tw-bg-opacity));
}
.dark\:bg-orange-500:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(249 115 22 / var(--tw-bg-opacity));
}
.dark\:bg-green-700:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.dark\:bg-opacity-75:is(.dark *) {
--tw-bg-opacity: 0.75;
}
@@ -3456,6 +3525,11 @@ video {
color: rgb(251 191 36 / var(--tw-text-opacity));
}
.dark\:text-blue-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(191 219 254 / var(--tw-text-opacity));
}
.dark\:text-blue-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity));
@@ -3599,6 +3673,16 @@ video {
color: rgb(250 204 21 / var(--tw-text-opacity));
}
.dark\:text-green-300:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(134 239 172 / var(--tw-text-opacity));
}
.dark\:text-green-200:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(187 247 208 / var(--tw-text-opacity));
}
.dark\:placeholder-gray-400:is(.dark *)::-moz-placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity));