diff --git a/apps/server/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor b/apps/server/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor index 11ac83cfe..8352add53 100644 --- a/apps/server/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor +++ b/apps/server/AliasVault.Admin/Main/Pages/Account/Manage/ChangePassword.razor @@ -19,16 +19,18 @@
- +
- +
- Update password +
@@ -38,6 +40,34 @@ [SupplyParameterFromForm] private InputModel Input { get; set; } = default!; + /// + /// Gets a value indicating whether the submit button should be enabled. + /// + private bool IsSubmitEnabled => + !string.IsNullOrWhiteSpace(Input.OldPassword) && + !string.IsNullOrWhiteSpace(Input.NewPassword) && + !string.IsNullOrWhiteSpace(Input.ConfirmPassword) && + Input.NewPassword == Input.ConfirmPassword && + Input.NewPassword.Length >= 10; + + /// + /// Handles password input to update state. + /// + private void OnPasswordInput(ChangeEventArgs e) + { + Input.NewPassword = e.Value?.ToString() ?? string.Empty; + StateHasChanged(); + } + + /// + /// Handles confirm password input to update button state. + /// + 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) { diff --git a/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor b/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor index f310685d3..88e4902a2 100644 --- a/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor +++ b/apps/server/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor @@ -2,6 +2,7 @@ @using System.Timers @using Microsoft.Extensions.Localization @using AliasVault.Client.Main.Components.Shared +@using AliasVault.Client.Main.Constants
@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); diff --git a/apps/server/AliasVault.Client/Main/Components/Shared/PasswordStrengthIndicator.razor b/apps/server/AliasVault.Client/Main/Components/Shared/PasswordStrengthIndicator.razor index 0a9c717cd..be764ac37 100644 --- a/apps/server/AliasVault.Client/Main/Components/Shared/PasswordStrengthIndicator.razor +++ b/apps/server/AliasVault.Client/Main/Components/Shared/PasswordStrengthIndicator.razor @@ -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; } + + /// + /// Gets the current password strength level. + /// + /// The password strength level (0-4). + public int GetPasswordStrength() + { + return _passwordStrength; + } + + /// + /// Checks if the current password meets the minimum required strength. + /// + /// True if the password meets minimum requirements, false otherwise. + public bool MeetsMinimumRequirement() + { + return _passwordStrength >= PasswordStrengthConstants.MinimumRequiredStrength; + } } diff --git a/apps/server/AliasVault.Client/Main/Constants/PasswordStrengthConstants.cs b/apps/server/AliasVault.Client/Main/Constants/PasswordStrengthConstants.cs new file mode 100644 index 000000000..0d78fb0b7 --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Constants/PasswordStrengthConstants.cs @@ -0,0 +1,25 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.Client.Main.Constants; + +/// +/// Constants for password strength validation and requirements. +/// +public static class PasswordStrengthConstants +{ + /// + /// Minimum password strength level required for account creation and password changes. + /// Level 2 corresponds to "Good" (12-15 characters). + /// + public const int MinimumRequiredStrength = 2; + + /// + /// Minimum password length for "Good" strength level. + /// + public const int MinimumGoodPasswordLength = 12; +} diff --git a/apps/server/AliasVault.Client/Main/Models/Validation/PasswordChangeFormModel.cs b/apps/server/AliasVault.Client/Main/Models/Validation/PasswordChangeFormModel.cs index cfbcc6177..7df3f8e92 100644 --- a/apps/server/AliasVault.Client/Main/Models/Validation/PasswordChangeFormModel.cs +++ b/apps/server/AliasVault.Client/Main/Models/Validation/PasswordChangeFormModel.cs @@ -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. /// [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!; /// diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor index e71e0d8c1..a9eb1657d 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/Security/ChangePassword.razor @@ -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
- + +
- +
+ @if (!string.IsNullOrEmpty(PasswordStrengthError)) + { +
+ @PasswordStrengthError +
+ } + @@ -104,6 +115,46 @@ else private SrpSession ClientSession = new(); private string PrivateKey = string.Empty; + /// + /// Reference to the password strength indicator component. + /// + private PasswordStrengthIndicator? PasswordStrengthIndicatorRef { get; set; } + + /// + /// Error message for password strength validation. + /// + private string PasswordStrengthError { get; set; } = string.Empty; + + /// + /// Gets a value indicating whether the submit button should be enabled. + /// + private bool IsSubmitEnabled => + !string.IsNullOrWhiteSpace(PasswordChangeFormModel.CurrentPassword) && + !string.IsNullOrWhiteSpace(PasswordChangeFormModel.NewPassword) && + !string.IsNullOrWhiteSpace(PasswordChangeFormModel.NewPasswordConfirm) && + PasswordChangeFormModel.NewPassword == PasswordChangeFormModel.NewPasswordConfirm && + PasswordStrengthIndicatorRef != null && + PasswordStrengthIndicatorRef.MeetsMinimumRequirement(); + + /// + /// Handles real-time password input to update the strength indicator. + /// + private void OnPasswordInput(ChangeEventArgs e) + { + PasswordChangeFormModel.NewPassword = e.Value?.ToString() ?? string.Empty; + PasswordStrengthError = string.Empty; + StateHasChanged(); + } + + /// + /// Handles real-time confirm password input to update button state. + /// + private void OnConfirmPasswordInput(ChangeEventArgs e) + { + PasswordChangeFormModel.NewPasswordConfirm = e.Value?.ToString() ?? string.Empty; + StateHasChanged(); + } + /// protected override async Task OnInitializedAsync() { @@ -157,6 +208,17 @@ else ///
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(); diff --git a/apps/server/AliasVault.Client/Resources/Components/Auth/Setup/PasswordStep.en.resx b/apps/server/AliasVault.Client/Resources/Components/Auth/Setup/PasswordStep.en.resx index c6caa6359..37af213c8 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Auth/Setup/PasswordStep.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Auth/Setup/PasswordStep.en.resx @@ -65,7 +65,7 @@ Success message for valid password - Master password must be at least 10 characters long. + Master password must be at least 12 characters long (Good strength or higher). Error message for password too short diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Pages/Settings/Security/ChangePassword.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Pages/Settings/Security/ChangePassword.en.resx index 4a9c21136..89b229d8e 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Main/Pages/Settings/Security/ChangePassword.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Pages/Settings/Security/ChangePassword.en.resx @@ -116,4 +116,8 @@ Failed to change password. Please refresh the page and try again. Error message when password change fails + + Your new password must be at least 12 characters long (Good strength or higher). + Error message when password strength is too weak + \ No newline at end of file diff --git a/apps/server/AliasVault.Client/Resources/ValidationMessages.en.resx b/apps/server/AliasVault.Client/Resources/ValidationMessages.en.resx index d813bd1e6..8d27c046e 100644 --- a/apps/server/AliasVault.Client/Resources/ValidationMessages.en.resx +++ b/apps/server/AliasVault.Client/Resources/ValidationMessages.en.resx @@ -61,7 +61,7 @@ - The new password must be at least 10 characters long. + The new password must be at least 12 characters long (Good strength or higher). Error message for password minimum length validation @@ -69,7 +69,7 @@ Error message when password confirmation doesn't match - Password must be at least 10 characters long. + Password must be at least 12 characters long (Good strength or higher). Generic error message for password minimum length validation diff --git a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css index ed57844e3..7d4920cc4 100644 --- a/apps/server/AliasVault.Client/wwwroot/css/tailwind.css +++ b/apps/server/AliasVault.Client/wwwroot/css/tailwind.css @@ -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));