diff --git a/src/AliasVault.WebApp/AliasVault.WebApp.csproj b/src/AliasVault.WebApp/AliasVault.WebApp.csproj index 4411ae927..6aa922302 100644 --- a/src/AliasVault.WebApp/AliasVault.WebApp.csproj +++ b/src/AliasVault.WebApp/AliasVault.WebApp.csproj @@ -60,6 +60,9 @@ + + true + PreserveNewest diff --git a/src/AliasVault.WebApp/Auth/Pages/Unlock.razor b/src/AliasVault.WebApp/Auth/Pages/Unlock.razor index 41f26bda9..84eb269fb 100644 --- a/src/AliasVault.WebApp/Auth/Pages/Unlock.razor +++ b/src/AliasVault.WebApp/Auth/Pages/Unlock.razor @@ -5,7 +5,7 @@ @inject NavigationManager NavigationManager @inject AuthService AuthService @inject GlobalNotificationService GlobalNotificationService -@inject AliasClientDbService AliasClientDbService +@inject DbService DbService @inject ILocalStorageService LocalStorage @using System.Text.Json @using AliasVault.Shared.Models @@ -145,7 +145,7 @@ AuthService.StoreEncryptionKey(passwordHash); // Try to retrieve vault again from remote. - await AliasClientDbService.GetDbContextAsync(); + await DbService.GetDbContextAsync(); // Redirect to home page. await AuthStateProvider.GetAuthenticationStateAsync(); diff --git a/src/AliasVault.WebApp/Components/Loading/SmallLoadingIndicator.razor b/src/AliasVault.WebApp/Components/Loading/SmallLoadingIndicator.razor new file mode 100644 index 000000000..4547c0479 --- /dev/null +++ b/src/AliasVault.WebApp/Components/Loading/SmallLoadingIndicator.razor @@ -0,0 +1,22 @@ +
+ + Loading... +
+ +@code { + /// + /// Optional title of the loading indicator. + /// + [Parameter] + public string Title { get; set; } = string.Empty; + + /// + /// Set spinning to false to stop the animation. + /// + [Parameter] + public bool Spinning { get; set; } = true; + +} diff --git a/src/AliasVault.WebApp/Layout/DbStatusIndicator.razor b/src/AliasVault.WebApp/Layout/DbStatusIndicator.razor new file mode 100644 index 000000000..e9759e235 --- /dev/null +++ b/src/AliasVault.WebApp/Layout/DbStatusIndicator.razor @@ -0,0 +1,65 @@ +@implements IDisposable +@inject DbService DbService + +@if (Loading) +{ +
+ +
+} +else +{ + +} + + + +@code { + private bool Loading { get; set; } = false; + private string Message { get; set; } = ""; + private string LoadingIndicatorMessage { get; set; } = ""; + + /// + protected override void OnInitialized() + { + DbService.GetState().StateChanged += OnDatabaseStateChanged; + } + + private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState) + { + await InvokeAsync(StateHasChanged); + if (newState.Status == DbServiceState.DatabaseStatus.Saving) + { + // Show loading indicator for at least 0.5 seconds even if the save operation is faster. + Message = "Saving..."; + await ShowLoadingIndicatorAsync(); + } + else if (newState.Status == DbServiceState.DatabaseStatus.Loading) + { + Message = "Loading..."; + await ShowLoadingIndicatorAsync(); + } + + LoadingIndicatorMessage = Message + " - " + newState.LastUpdated; + } + + private async Task ShowLoadingIndicatorAsync() + { + Loading = true; + StateHasChanged(); + await Task.Delay(800); + Loading = false; + StateHasChanged(); + } + + /// + /// Dispose method. + /// + public void Dispose() + { + DbService.GetState().StateChanged -= OnDatabaseStateChanged; + } +} diff --git a/src/AliasVault.WebApp/Layout/NavMenu.razor.css b/src/AliasVault.WebApp/Layout/DbStatusIndicator.razor.css similarity index 100% rename from src/AliasVault.WebApp/Layout/NavMenu.razor.css rename to src/AliasVault.WebApp/Layout/DbStatusIndicator.razor.css diff --git a/src/AliasVault.WebApp/Layout/NavMenu.razor b/src/AliasVault.WebApp/Layout/NavMenu.razor deleted file mode 100644 index 5a6a014ba..000000000 --- a/src/AliasVault.WebApp/Layout/NavMenu.razor +++ /dev/null @@ -1,39 +0,0 @@ - - -
- -
- -@code { - private bool collapseNavMenu = true; - - private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; - - private void ToggleNavMenu() - { - collapseNavMenu = !collapseNavMenu; - } -} diff --git a/src/AliasVault.WebApp/Layout/TopMenu.razor b/src/AliasVault.WebApp/Layout/TopMenu.razor index b459eeaf8..df78721dc 100644 --- a/src/AliasVault.WebApp/Layout/TopMenu.razor +++ b/src/AliasVault.WebApp/Layout/TopMenu.razor @@ -24,10 +24,8 @@ -
- +
+ - - -
@foreach (var password in Passwords) {
+

@password.CreatedAt

@password.Value

@@ -41,6 +39,14 @@ private bool IsLoading { get; set; } = true; private List Passwords { get; set; } = new(); + private async Task RemovePassword(Password password) + { + var db = await DbService.GetDbContextAsync(); + db.Passwords.Remove(password); + await DbService.SaveDatabaseAsync(); + await RefreshAliasFromDbAsync(); + } + /// protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -48,22 +54,20 @@ if (firstRender) { - await LoadAliasFromDbAsync(); + await RefreshAliasFromDbAsync(); } } - private async Task LoadAliasFromDbAsync() + private async Task RefreshAliasFromDbAsync() { IsLoading = true; StateHasChanged(); - var db = await AliasClientDbService.GetDbContextAsync(); + var db = await DbService.GetDbContextAsync(); // Load all passwords from the database var passwords = await db.Passwords.ToListAsync(); - // Show them on page - Passwords = passwords; IsLoading = false; StateHasChanged(); @@ -71,24 +75,13 @@ private async Task AddRecord() { - var db = await AliasClientDbService.GetDbContextAsync(); + var db = await DbService.GetDbContextAsync(); // Insert row in database db.Passwords.Add(new Password() { Id = Guid.NewGuid(), Value = "Test factory insert SQLite", CreatedAt = DateTime.Now }); - // Save changes - await db.SaveChangesAsync(); - - await LoadAliasFromDbAsync(); - } - - private async Task ExportDbToString() - { - await AliasClientDbService.SaveDatabaseAsync(); - } - - private async Task SaveDb() - { - await (await AliasClientDbService.GetDbContextAsync()).SaveChangesAsync(); + // Save changes and upload db to remote. + await DbService.SaveDatabaseAsync(); + await RefreshAliasFromDbAsync(); } } diff --git a/src/AliasVault.WebApp/Program.cs b/src/AliasVault.WebApp/Program.cs index bf9d73270..05d87887d 100644 --- a/src/AliasVault.WebApp/Program.cs +++ b/src/AliasVault.WebApp/Program.cs @@ -9,6 +9,7 @@ using AliasVault.WebApp; using AliasVault.WebApp.Auth.Providers; using AliasVault.WebApp.Auth.Services; using AliasVault.WebApp.Services; +using AliasVault.WebApp.Services.Database; using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Web; @@ -36,7 +37,7 @@ builder.Services.AddTransient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/src/AliasVault.WebApp/Services/AliasClientDbService.cs b/src/AliasVault.WebApp/Services/Database/DbService.cs similarity index 84% rename from src/AliasVault.WebApp/Services/AliasClientDbService.cs rename to src/AliasVault.WebApp/Services/Database/DbService.cs index 8c8dd0f54..f12ffb0eb 100644 --- a/src/AliasVault.WebApp/Services/AliasClientDbService.cs +++ b/src/AliasVault.WebApp/Services/Database/DbService.cs @@ -1,18 +1,17 @@ //----------------------------------------------------------------------- -// +// // 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.WebApp.Services; +namespace AliasVault.WebApp.Services.Database; using System.Data; using System.Net.Http.Json; using AliasClientDb; using AliasVault.Shared.Models.WebApi; using AliasVault.WebApp.Auth.Services; -using Microsoft.AspNetCore.Components; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.JSInterop; @@ -22,32 +21,45 @@ using Microsoft.JSInterop; /// with a AliasClientDb database instance that is only persisted in memory due to the encryption requirements of the /// database itself. The database should not be persisted to disk when in un-encrypted form. /// -public class AliasClientDbService +public class DbService { private readonly AuthService _authService; private readonly IJSRuntime _jsRuntime; private readonly HttpClient _httpClient; + private readonly DbServiceState _state = new(); private AliasClientDbContext? _dbContext; private Task _initializationTask; private bool _isSuccessfullyInitialized; private int _retryCount; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// AuthService. /// IJSRuntime. /// HttpClient. - public AliasClientDbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient) + public DbService(AuthService authService, IJSRuntime jsRuntime, HttpClient httpClient) { _authService = authService; _jsRuntime = jsRuntime; _httpClient = httpClient; + // Set the initial state of the database service. + _state.UpdateState(DbServiceState.DatabaseStatus.Idle); + // Initialize the database asynchronously _initializationTask = InitializeDatabaseAsync(); } + /// + /// Gets database service state object which can be subscribed to. + /// + /// DbServiceState instance. + public DbServiceState GetState() + { + return _state; + } + /// /// Ensures that the service initialization is complete before proceeding. /// @@ -91,46 +103,58 @@ public class AliasClientDbService { await EnsureInitializedAsync(); - using var memoryStream = new MemoryStream(); - using var connection = new SqliteConnection(_dbContext!.Database.GetDbConnection().ConnectionString); - await connection.OpenAsync(); - using var command = connection.CreateCommand(); - command.CommandText = "VACUUM main INTO @fileName"; + // Set the initial state of the database service. + _state.UpdateState(DbServiceState.DatabaseStatus.Saving); - var tempFileName = Path.GetRandomFileName(); - command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); - await command.ExecuteNonQueryAsync(); + // Save the actual dbContext. + await _dbContext!.SaveChangesAsync(); - var bytes = await File.ReadAllBytesAsync(tempFileName); + string base64String = await ExportSqliteToBase64Async(); - string base64String = Convert.ToBase64String(bytes); - File.Delete(tempFileName); - - // Encrypt base64 string. - // Encrypt using IJSInterop - Console.WriteLine("Encrypted using key: " + _authService.GetEncryptionKeyAsBase64Async()); + // Encrypt base64 string using IJSInterop. string encryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.encrypt", base64String, _authService.GetEncryptionKeyAsBase64Async()); - // Decrypt it again - string decryptedBase64String = await _jsRuntime.InvokeAsync("cryptoInterop.decrypt", encryptedBase64String, _authService.GetEncryptionKeyAsBase64Async()); - - // Print original, encrypted and decrypted sting to console - Console.WriteLine("Original: " + base64String); - Console.WriteLine("Encrypted: " + encryptedBase64String); - Console.WriteLine("Decrypted: " + decryptedBase64String); - // Save to webapi. var success = await SaveToServerAsync(encryptedBase64String); if (success) { Console.WriteLine("Database succesfully saved to server."); + _state.UpdateState(DbServiceState.DatabaseStatus.Idle); } else { Console.WriteLine("Failed to save database to server."); + _state.UpdateState(DbServiceState.DatabaseStatus.Error); } } + /// + /// Export the in-memory SQLite database to a base64 string. + /// + /// Base64 encoded string that represents SQLite database. + public async Task ExportSqliteToBase64Async() + { + var tempFileName = Path.GetRandomFileName(); + + // Export SQLite memory database to a temp file. + using var memoryStream = new MemoryStream(); + using var connection = new SqliteConnection(_dbContext!.Database.GetDbConnection().ConnectionString); + await connection.OpenAsync(); + using var command = connection.CreateCommand(); + command.CommandText = "VACUUM main INTO @fileName"; + command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); + await command.ExecuteNonQueryAsync(); + + // Get bytes. + var bytes = await File.ReadAllBytesAsync(tempFileName); + string base64String = Convert.ToBase64String(bytes); + + // Delete temp file. + File.Delete(tempFileName); + + return base64String; + } + private static async Task ImportDbContextFromBase64Async(AliasClientDbContext dbContext, string base64String) { var bytes = Convert.FromBase64String(base64String); @@ -202,6 +226,8 @@ public class AliasClientDbService return; } + _state.UpdateState(DbServiceState.DatabaseStatus.Loading); + // Create a new in-memory database. string connectionString = "Data Source=AliasClientDb.sqlite"; using var connection = new SqliteConnection(connectionString); @@ -221,10 +247,12 @@ public class AliasClientDbService if (loaded) { _isSuccessfullyInitialized = true; + _state.UpdateState(DbServiceState.DatabaseStatus.Idle); Console.WriteLine("Database succesfully loaded from server."); } else { + _state.UpdateState(DbServiceState.DatabaseStatus.Error); Console.WriteLine("Failed to load database from server."); } } @@ -284,7 +312,7 @@ public class AliasClientDbService try { - await _httpClient.PostAsJsonAsync("api/v1/Vault", vaultObject); + await _httpClient.PostAsJsonAsync("api/v1/Vault", vaultObject); return true; } catch diff --git a/src/AliasVault.WebApp/Services/Database/DbServiceState.cs b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs new file mode 100644 index 000000000..8f35343ba --- /dev/null +++ b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs @@ -0,0 +1,122 @@ +//----------------------------------------------------------------------- +// +// 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.WebApp.Services.Database; + +/// +/// Class to manage the state of the AliasClientDbService that others can subscribe to events for. +/// +public class DbServiceState +{ + private DatabaseState _currentState = new(); + + /// + /// Subscribe to this event to get notified when the state of the database changes. + /// + public event EventHandler StateChanged = (sender, e) => { }; + + /// + /// Database status enum. + /// + public enum DatabaseStatus + { + /// + /// No database operation is in progress. + /// + Idle, + + /// + /// Database is loading from server. + /// + Loading, + + /// + /// Database is saving to server. + /// + Saving, + + /// + /// An error occurred during a database operation. + /// + Error, + } + + /// + /// Gets the current state of the database. + /// + public DatabaseState CurrentState + { + get => _currentState; + private set + { + if (_currentState != value) + { + _currentState = value; + OnStateChanged(_currentState); + } + } + } + + /// + /// Update the state of the database. + /// + /// New status. + public void UpdateState(DatabaseStatus status) + { + CurrentState = new DatabaseState + { + Status = status, + Message = string.Empty, + LastUpdated = DateTime.Now, + }; + } + + /// + /// Update the state of the database with an additional message. + /// + /// New status. + /// Status message. + public void UpdateState(DatabaseStatus status, string message) + { + CurrentState = new DatabaseState + { + Status = status, + Message = message, + LastUpdated = DateTime.Now, + }; + } + + /// + /// OnStateChanged event handler. + /// + /// The new state. + protected virtual void OnStateChanged(DatabaseState newState) + { + StateChanged?.Invoke(this, newState); + } + + /// + /// Database state class. + /// + public class DatabaseState + { + /// + /// Gets or sets the current status of the database. + /// + public DatabaseStatus Status { get; set; } = DatabaseStatus.Idle; + + /// + /// Gets or sets the message associated with the current status. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets the last time the state was updated. + /// + public DateTime LastUpdated { get; set; } = DateTime.Now; + } +} diff --git a/src/AliasVault.WebApp/_Imports.razor b/src/AliasVault.WebApp/_Imports.razor index 3b0f46fa9..bae80ee01 100644 --- a/src/AliasVault.WebApp/_Imports.razor +++ b/src/AliasVault.WebApp/_Imports.razor @@ -17,6 +17,7 @@ @using AliasVault.WebApp.Components.Loading @using AliasVault.WebApp.Pages.Base @using AliasVault.WebApp.Services +@using AliasVault.WebApp.Services.Database @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.WebAssembly.Authentication @using Blazored.LocalStorage diff --git a/src/AliasVault.WebApp/wwwroot/css/tailwind.css b/src/AliasVault.WebApp/wwwroot/css/tailwind.css index 9da74be88..c354fcb5f 100644 --- a/src/AliasVault.WebApp/wwwroot/css/tailwind.css +++ b/src/AliasVault.WebApp/wwwroot/css/tailwind.css @@ -607,10 +607,6 @@ video { top: 2.5rem; } -.bottom-0 { - bottom: 0px; -} - .z-10 { z-index: 10; } @@ -635,10 +631,6 @@ video { grid-column: 1 / -1; } -.m-4 { - margin: 1rem; -} - .mx-3 { margin-left: 0.75rem; margin-right: 0.75rem; @@ -690,6 +682,10 @@ video { margin-left: 0.25rem; } +.ml-2 { + margin-left: 0.5rem; +} + .ml-3 { margin-left: 0.75rem; } @@ -722,6 +718,10 @@ video { margin-top: 0px; } +.mt-2 { + margin-top: 0.5rem; +} + .mt-4 { margin-top: 1rem; } @@ -734,10 +734,6 @@ video { margin-top: 2rem; } -.ml-2 { - margin-left: 0.5rem; -} - .block { display: block; } @@ -802,12 +798,8 @@ video { height: 100%; } -.h-24 { - height: 6rem; -} - -.min-h-screen { - min-height: 100vh; +.h-7 { + height: 1.75rem; } .w-1\/2 { @@ -850,8 +842,8 @@ video { width: 100%; } -.w-24 { - width: 6rem; +.w-7 { + width: 1.75rem; } .min-w-full { @@ -932,6 +924,10 @@ video { justify-content: flex-start; } +.justify-end { + justify-content: flex-end; +} + .justify-center { justify-content: center; } @@ -1139,16 +1135,6 @@ video { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); -} - -.bg-red-100 { - --tw-bg-opacity: 1; - background-color: rgb(254 226 226 / var(--tw-bg-opacity)); -} - .bg-opacity-50 { --tw-bg-opacity: 0.5; } @@ -1157,6 +1143,10 @@ video { fill: #d68338; } +.fill-none { + fill: none; +} + .p-2 { padding: 0.5rem; } @@ -1173,6 +1163,11 @@ video { padding: 1.5rem; } +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + .px-3 { padding-left: 0.75rem; padding-right: 0.75rem; @@ -1279,11 +1274,6 @@ video { line-height: 1rem; } -.text-lg { - font-size: 1.125rem; - line-height: 1.75rem; -} - .font-bold { font-weight: 700; } @@ -1375,20 +1365,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } -.text-blue-600 { - --tw-text-opacity: 1; - color: rgb(37 99 235 / var(--tw-text-opacity)); -} - -.text-red-700 { - --tw-text-opacity: 1; - color: rgb(185 28 28 / var(--tw-text-opacity)); -} - -.underline { - text-decoration-line: underline; -} - .opacity-0 { opacity: 0; } @@ -1625,11 +1601,6 @@ video { background-color: rgb(239 68 68 / var(--tw-bg-opacity)); } -.dark\:bg-red-900:is(.dark *) { - --tw-bg-opacity: 1; - background-color: rgb(127 29 29 / var(--tw-bg-opacity)); -} - .dark\:bg-opacity-80:is(.dark *) { --tw-bg-opacity: 0.8; } @@ -1679,16 +1650,6 @@ video { color: rgb(255 255 255 / var(--tw-text-opacity)); } -.dark\:text-gray-700:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(55 65 81 / var(--tw-text-opacity)); -} - -.dark\:text-red-300:is(.dark *) { - --tw-text-opacity: 1; - color: rgb(252 165 165 / 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)); diff --git a/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs b/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs index 356608a14..f5cb928ee 100644 --- a/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs +++ b/src/Tests/AliasVault.E2ETests/Tests/UnlockTests.cs @@ -14,8 +14,6 @@ namespace AliasVault.E2ETests.Tests; [TestFixture] public class UnlockTests : PlaywrightTest { - private static readonly Random Random = new(); - /// /// Test that the unlock page is displayed after hard refresh which should /// clear the encryption key from memory.