diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor b/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor new file mode 100644 index 000000000..b5c0c0f42 --- /dev/null +++ b/src/AliasVault.Client/Auth/Pages/Setup/Components/PasswordStep.razor @@ -0,0 +1,209 @@ +@using System.Timers +@using System.Net.Http.Json +@implements IDisposable + +
+ @if (isLoading) + { +
+
+
+ } +
+
+
+
+ AliasVault Assistant +
+
+

+ Great! Now, let's set up your master password for AliasVault. +

+

+ Please enter a strong master password for your account. Your username is: @Username +

+

+ Important: This master password will be used to encrypt your vault. It should be a long, complex string that you can remember. If you forget this password, your data will be permanently inaccessible. +

+
    +
  • Your master password never leaves your device
  • +
  • The server has no access to your unencrypted data
  • +
  • Even the server admin cannot restore your access if you forget this password
  • +
+
+
+
+ +
+
+
+ +
+
+ +
+ @if (isValidating) + { +
Validating password...
+ } + else if (isValid) + { + @if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } + else + { +
Password is valid and strong!
+ } + } + else if (!string.IsNullOrEmpty(errorMessage)) + { +
@errorMessage
+ } +
+
+
+
+ +@code { + private string _password = string.Empty; + private string _confirmPassword = string.Empty; + private bool isValid = false; + private bool isValidating = false; + private string errorMessage = string.Empty; + private Timer? debounceTimer; + + [Inject] + private HttpClient Http { get; set; } = null!; + + [Parameter] + public string Username { get; set; } = string.Empty; + + public string Password + { + get => _password; + set + { + if (_password != value) + { + _password = value; + ValidatePassword(); + } + } + } + + public string ConfirmPassword + { + get => _confirmPassword; + set + { + if (_confirmPassword != value) + { + _confirmPassword = value; + ValidatePassword(); + } + } + } + + [Parameter] + public EventCallback OnPasswordChange { get; set; } + + private bool isLoading = true; + private Timer? loadingTimer; + + protected override void OnInitialized() + { + loadingTimer = new Timer(300); + loadingTimer.Elapsed += (sender, e) => FinishLoading(); + loadingTimer.AutoReset = false; + loadingTimer.Start(); + + debounceTimer = new Timer(300); + debounceTimer.Elapsed += async (sender, e) => await ValidatePasswordDebounced(); + debounceTimer.AutoReset = false; + } + + private void FinishLoading() + { + isLoading = false; + InvokeAsync(StateHasChanged); + } + + private void ValidatePassword() + { + isValidating = true; + isValid = false; + errorMessage = string.Empty; + StateHasChanged(); + + debounceTimer?.Stop(); + debounceTimer?.Start(); + } + + private async Task ValidatePasswordDebounced() + { + await InvokeAsync(async () => + { + if (string.IsNullOrWhiteSpace(Password) || string.IsNullOrWhiteSpace(ConfirmPassword)) + { + isValidating = false; + isValid = false; + errorMessage = "Both password fields are required."; + await OnPasswordChange.InvokeAsync(string.Empty); + StateHasChanged(); + return; + } + + if (Password != ConfirmPassword) + { + isValidating = false; + isValid = false; + errorMessage = "Passwords do not match."; + await OnPasswordChange.InvokeAsync(string.Empty); + StateHasChanged(); + return; + } + + if (Password.Length < 10) + { + isValidating = false; + isValid = false; + errorMessage = "Master password must be at least 10 characters long."; + await OnPasswordChange.InvokeAsync(string.Empty); + StateHasChanged(); + return; + } + + // If password is valid + isValid = true; + errorMessage = string.Empty; + + // Show warning for passwords between 10 and 13 characters + if (Password.Length < 14) + { + errorMessage = "Password is acceptable, but can be improved when longer."; + } + + await OnPasswordChange.InvokeAsync(Password); + + isValidating = false; + StateHasChanged(); + }); + } + + private void OnPasswordInputFocus(FocusEventArgs args) + { + // Reset validation state when the input is focused + isValid = false; + isValidating = false; + errorMessage = string.Empty; + StateHasChanged(); + } + + public void Dispose() + { + loadingTimer?.Dispose(); + debounceTimer?.Dispose(); + } +} diff --git a/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor b/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor index baf845fe0..6ff5173c3 100644 --- a/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor +++ b/src/AliasVault.Client/Auth/Pages/Setup/Setup.razor @@ -3,6 +3,12 @@ @inherits AliasVault.Client.Auth.Pages.Base.LoginBase @layout Auth.Layout.EmptyLayout @attribute [AllowAnonymous] +@inject IConfiguration Configuration +@using System.Text.Json +@using AliasVault.Shared.Models.WebApi.Auth +@using AliasVault.Client.Utilities +@using AliasVault.Cryptography.Client +@using SecureRemotePassword
@@ -25,11 +31,11 @@ break; - /*case SetupStep.Password: + case SetupStep.Password: - break; */ + break; }
@@ -43,7 +49,14 @@ }
- @if (currentStep != SetupStep.Password) + @if (currentStep == SetupStep.Password && !string.IsNullOrWhiteSpace(setupData.Password)) + { + + } + else {
+ + + @code { + private FullScreenLoadingIndicator LoadingIndicator = new(); private SetupStep currentStep = SetupStep.Welcome; private SetupData setupData = new(); private bool isNextEnabled => currentStep switch @@ -101,10 +122,73 @@ }; } - private void CompleteSetup() + private async Task CompleteSetup() + { + LoadingIndicator.Show(); + StateHasChanged(); + + try + { + var client = new SrpClient(); + var salt = client.GenerateSalt(); + + string encryptionType = Defaults.EncryptionType; + string encryptionSettings = Defaults.EncryptionSettings; + if (Configuration["CryptographyOverrideType"] is not null && Configuration["CryptographyOverrideSettings"] is not null) + { + encryptionType = Configuration["CryptographyOverrideType"]!; + encryptionSettings = Configuration["CryptographyOverrideSettings"]!; + } + + var passwordHash = await Encryption.DeriveKeyFromPasswordAsync(setupData.Password, salt, encryptionType, encryptionSettings); + var passwordHashString = BitConverter.ToString(passwordHash).Replace("-", string.Empty); + var srpSignup = Srp.PasswordChangeAsync(client, salt, setupData.Username, passwordHashString); + + var registerRequest = new RegisterRequest(srpSignup.Username, srpSignup.Salt, srpSignup.Verifier, encryptionType, encryptionSettings); + var result = await Http.PostAsJsonAsync("api/v1/Auth/register", registerRequest); + var responseContent = await result.Content.ReadAsStringAsync(); + + if (!result.IsSuccessStatusCode) + { + foreach (var error in ApiResponseUtility.ParseErrorResponse(responseContent)) + { + GlobalNotificationService.AddErrorMessage(error); + } + StateHasChanged(); + return; + } + + var tokenObject = JsonSerializer.Deserialize(responseContent); + + if (tokenObject != null) + { + await AuthService.StoreEncryptionKeyAsync(passwordHash); + await AuthService.StoreAccessTokenAsync(tokenObject.Token); + await AuthService.StoreRefreshTokenAsync(tokenObject.RefreshToken); + await AuthStateProvider.GetAuthenticationStateAsync(); + + GlobalNotificationService.AddSuccessMessage("Account created successfully!"); + NavigationManager.NavigateTo("/"); + } + else + { + GlobalNotificationService.AddErrorMessage("An error occurred during registration."); + StateHasChanged(); + } + } + catch (Exception ex) + { + GlobalNotificationService.AddErrorMessage($"An error occurred: {ex.Message}"); + } + finally + { + LoadingIndicator.Hide(); + StateHasChanged(); + } + } + + private void CancelSetup() { - // TODO: Implement the logic to save user information and create the account - // For example: await UserService.CreateUser(setupData.UserInfo, setupData.Password); NavigationManager.NavigateTo("/"); } diff --git a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor index bb74a0101..9f8693c53 100644 --- a/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor +++ b/src/AliasVault.Client/Main/Components/Forms/EditFormRow.razor @@ -6,7 +6,7 @@ } else { - + } diff --git a/src/AliasVault.Client/wwwroot/css/tailwind.css b/src/AliasVault.Client/wwwroot/css/tailwind.css index 68debdcee..8dab89298 100644 --- a/src/AliasVault.Client/wwwroot/css/tailwind.css +++ b/src/AliasVault.Client/wwwroot/css/tailwind.css @@ -678,6 +678,14 @@ video { top: 50%; } +.right-4 { + right: 1rem; +} + +.top-4 { + top: 1rem; +} + .z-10 { z-index: 10; } @@ -1923,6 +1931,11 @@ video { color: rgb(133 77 14 / var(--tw-text-opacity)); } +.text-yellow-600 { + --tw-text-opacity: 1; + color: rgb(202 138 4 / var(--tw-text-opacity)); +} + .opacity-25 { opacity: 0.25; } @@ -2184,6 +2197,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.hover\:text-gray-700:hover { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + .hover\:underline:hover { text-decoration-line: underline; } @@ -2517,6 +2535,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:text-yellow-400:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(250 204 21 / 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)); @@ -2601,6 +2624,11 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } +.dark\:hover\:text-gray-200:hover:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(229 231 235 / var(--tw-text-opacity)); +} + .dark\:focus\:border-blue-500:focus:is(.dark *) { --tw-border-opacity: 1; border-color: rgb(59 130 246 / var(--tw-border-opacity));