mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-07 06:46:01 -04:00
Refactor SettingsService structure so it initializes when the DbService itself is ready (#145)
This commit is contained in:
@@ -1,62 +1,63 @@
|
||||
@page "/settings/general"
|
||||
@inherits MainBase
|
||||
@inject CredentialService CredentialService
|
||||
|
||||
<LayoutPageTitle>General settings</LayoutPageTitle>
|
||||
|
||||
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
|
||||
<div class="mb-4 col-span-full xl:mb-2">
|
||||
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">General settings</h1>
|
||||
</div>
|
||||
<p>On this page you can configure general AliasVault settings.</p>
|
||||
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">General settings</h1>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Configure general AliasVault settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Export vault</h3>
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Email Settings</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
<button @onclick="ExportVaultSqlite" type="button" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
|
||||
Export vault to unencrypted SQLite file
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button @onclick="ExportVaultCsv" type="button" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
|
||||
Export vault to unencrypted CSV file
|
||||
</button>
|
||||
</div>
|
||||
<label for="defaultEmailDomain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Email Domain</label>
|
||||
<select @bind="DefaultEmailDomain" id="defaultEmailDomain" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-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-blue-500 dark:focus:border-blue-500">
|
||||
@if (ShowPrivateDomains)
|
||||
{
|
||||
<optgroup label="Private Domains">
|
||||
@foreach (var domain in PrivateDomains)
|
||||
{
|
||||
<option value="@domain">@domain</option>
|
||||
}
|
||||
</optgroup>
|
||||
}
|
||||
<optgroup label="Public Domains">
|
||||
@foreach (var domain in PublicDomains)
|
||||
{
|
||||
<option value="@domain">@domain</option>
|
||||
}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-4">
|
||||
<input @bind="AutoEmailRefresh" id="autoEmailRefresh" type="checkbox" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
|
||||
<label for="autoEmailRefresh" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Auto email refresh on credential page</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-xl font-semibold dark:text-white">Import vault</h3>
|
||||
<div class="mb-4">
|
||||
<div>
|
||||
Import unencrypted CSV file:
|
||||
<InputFile OnChange="@LoadFiles" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (IsImporting)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(ImportErrorMessage))
|
||||
{
|
||||
<p class="text-danger">@ImportErrorMessage</p>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(ImportSuccessMessage))
|
||||
{
|
||||
<p class="text-success">@ImportSuccessMessage</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool IsImporting = false;
|
||||
private string ImportErrorMessage = string.Empty;
|
||||
private string ImportSuccessMessage = string.Empty;
|
||||
private List<string> PrivateDomains => Config.PrivateEmailDomains;
|
||||
private List<string> PublicDomains => Config.PublicEmailDomains;
|
||||
private bool ShowPrivateDomains => PrivateDomains.Count > 0 && !(PrivateDomains.Count == 1 && PrivateDomains[0] == "DISABLED.TLD");
|
||||
|
||||
private string DefaultEmailDomain
|
||||
{
|
||||
get => DbService.Settings.DefaultEmailDomain;
|
||||
set => DbService.Settings.SetDefaultEmailDomain(value).Wait();
|
||||
}
|
||||
|
||||
private bool AutoEmailRefresh
|
||||
{
|
||||
get => DbService.Settings.AutoEmailRefresh;
|
||||
set => DbService.Settings.SetAutoEmailRefreshAsync(value).Wait();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -64,83 +65,4 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage))
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Vault settings" });
|
||||
}
|
||||
|
||||
private async Task ExportVaultSqlite()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Decode the base64 string to a byte array
|
||||
byte[] fileBytes = Convert.FromBase64String(await DbService.ExportSqliteToBase64Async());
|
||||
|
||||
// Create a memory stream from the byte array
|
||||
using (MemoryStream memoryStream = new MemoryStream(fileBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download
|
||||
await JsInteropService.DownloadFileFromStream("aliasvault-client.sqlite", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error downloading file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportVaultCsv()
|
||||
{
|
||||
try
|
||||
{
|
||||
var credentials = await CredentialService.LoadAllAsync();
|
||||
|
||||
var csvBytes = CsvImportExport.CredentialCsvService.ExportCredentialsToCsv(credentials);
|
||||
|
||||
// Create a memory stream from the byte array
|
||||
using (MemoryStream memoryStream = new MemoryStream(csvBytes))
|
||||
{
|
||||
// Invoke JavaScript to initiate the download
|
||||
await JsInteropService.DownloadFileFromStream("aliasvault-client.csv", memoryStream.ToArray());
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error downloading file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadFiles(InputFileChangeEventArgs e)
|
||||
{
|
||||
IsImporting = true;
|
||||
StateHasChanged();
|
||||
ImportErrorMessage = string.Empty;
|
||||
ImportSuccessMessage = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var file = e.File;
|
||||
var buffer = new byte[file.Size];
|
||||
await file.OpenReadStream().ReadAsync(buffer);
|
||||
var fileContent = System.Text.Encoding.UTF8.GetString(buffer);
|
||||
|
||||
var importedCredentials = CsvImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContent);
|
||||
|
||||
// Loop through the imported credentials and actually add them to the database
|
||||
foreach (var importedCredential in importedCredentials)
|
||||
{
|
||||
await CredentialService.InsertEntryAsync(importedCredential, false);
|
||||
}
|
||||
|
||||
// Save the database.
|
||||
await DbService.SaveDatabaseAsync();
|
||||
|
||||
ImportSuccessMessage = $"Succesfully imported {importedCredentials.Count} credentials.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ImportErrorMessage = $"Error importing file: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsImporting = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ builder.Services.AddScoped<DbService>();
|
||||
builder.Services.AddScoped<GlobalNotificationService>();
|
||||
builder.Services.AddScoped<GlobalLoadingService>();
|
||||
builder.Services.AddScoped<JsInteropService>();
|
||||
builder.Services.AddScoped<SettingsService>();
|
||||
builder.Services.AddSingleton<ClipboardCopyService>();
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
|
||||
@@ -27,6 +27,7 @@ public class DbService : IDisposable
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly DbServiceState _state = new();
|
||||
private readonly Config _config;
|
||||
private SettingsService _settingsService = new();
|
||||
private SqliteConnection _sqlConnection;
|
||||
private AliasClientDbContext _dbContext;
|
||||
private bool _isSuccessfullyInitialized;
|
||||
@@ -54,6 +55,12 @@ public class DbService : IDisposable
|
||||
(_sqlConnection, _dbContext) = InitializeEmptyDatabase();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the settings service instance which can be used to interact with general settings stored in the database.
|
||||
/// </summary>
|
||||
/// <returns>SettingsService.</returns>
|
||||
public SettingsService Settings => _settingsService;
|
||||
|
||||
/// <summary>
|
||||
/// Gets database service state object which can be subscribed to.
|
||||
/// </summary>
|
||||
@@ -416,7 +423,7 @@ public class DbService : IDisposable
|
||||
string decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
|
||||
await ImportDbContextFromBase64Async(decryptedBase64String);
|
||||
|
||||
// Check if database is up to date with migrations.
|
||||
// Check if database is up-to-date with migrations.
|
||||
var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync();
|
||||
if (pendingMigrations.Any())
|
||||
{
|
||||
@@ -425,6 +432,10 @@ public class DbService : IDisposable
|
||||
}
|
||||
|
||||
_isSuccessfullyInitialized = true;
|
||||
|
||||
// Initialize child settings service if it's not already.
|
||||
await _settingsService.InitializeAsync(this);
|
||||
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -11,30 +11,39 @@ using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AliasClientDb;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Service class for accessing and mutating general settings stored in database.
|
||||
/// </summary>
|
||||
public class SettingsService(DbService dbService)
|
||||
/// <remarks>Note: this service does not use DI but instead is initialized by and can be accessed through the DbService.
|
||||
/// This is done because the SettingsService requires a DbContext during initialization and the context is not yet
|
||||
/// available during application boot because of encryption/decryption of remote database file. When accessing the
|
||||
/// settings through the DbService we can ensure proper data flow.</remarks>
|
||||
public class SettingsService
|
||||
{
|
||||
private readonly Dictionary<string, string?> _settings = new();
|
||||
private DbService? _dbService;
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DefaultEmailDomain setting asynchronously.
|
||||
/// </summary>
|
||||
/// <returns>Default email domain as string.</returns>
|
||||
public Task<string> GetDefaultEmailDomainAsync() => GetSettingAsync("DefaultEmailDomain");
|
||||
public string DefaultEmailDomain => GetSetting("DefaultEmailDomain");
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether email refresh should be done automatically on the credentials page.
|
||||
/// </summary>
|
||||
/// <returns>AutoEmailRefresh setting as string.</returns>
|
||||
public bool AutoEmailRefresh => GetSetting<bool>("AutoEmailRefresh", true);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the DefaultEmailDomain setting asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="value">The new DeafultEmailDomain setting.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public Task SetDefaultEmailDomainAsync(string value) => SetSettingAsync("DefaultEmailDomain", value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the AutoEmailRefresh setting asynchronously as a string.
|
||||
/// </summary>
|
||||
/// <returns>AutoEmailRefresh setting as string.</returns>
|
||||
public Task<bool> GetAutoEmailRefreshAsync() => GetSettingAsync<bool>("AutoEmailRefresh");
|
||||
public Task SetDefaultEmailDomain(string value) => SetSettingAsync("DefaultEmailDomain", value);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the AutoEmailRefresh setting asynchronously as a string.
|
||||
@@ -43,28 +52,68 @@ public class SettingsService(DbService dbService)
|
||||
/// <returns>Task.</returns>
|
||||
public Task SetAutoEmailRefreshAsync(bool value) => SetSettingAsync<bool>("AutoEmailRefresh", value);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the settings service asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="dbService">DbService instance.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public async Task InitializeAsync(DbService dbService)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the DbService instance for later use.
|
||||
_dbService = dbService;
|
||||
|
||||
var db = await _dbService.GetDbContextAsync();
|
||||
var settings = await db.Settings.ToListAsync();
|
||||
foreach (var setting in settings)
|
||||
{
|
||||
_settings[setting.Key] = setting.Value;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get setting value from database.
|
||||
/// </summary>
|
||||
/// <param name="key">Key of setting to retrieve.</param>
|
||||
/// <returns>Setting as string value.</returns>
|
||||
private async Task<string> GetSettingAsync(string key)
|
||||
private string GetSetting(string key)
|
||||
{
|
||||
var db = await dbService.GetDbContextAsync();
|
||||
var setting = await db.Settings.FindAsync(key);
|
||||
return setting?.Value ?? string.Empty;
|
||||
var setting = _settings.GetValueOrDefault(key);
|
||||
return setting ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a setting asynchronously and casts it to the specified type.
|
||||
/// Gets a setting and casts it to the specified type.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type to cast the setting to.</typeparam>
|
||||
/// <param name="key">The key of the setting.</param>
|
||||
/// <param name="defaultValue">The default value to use if no setting is set in database.</param>
|
||||
/// <returns>The setting value cast to type T.</returns>
|
||||
private async Task<T?> GetSettingAsync<T>(string key)
|
||||
private T? GetSetting<T>(string key, T? defaultValue = default)
|
||||
{
|
||||
string value = await GetSettingAsync(key);
|
||||
return CastSetting<T>(value);
|
||||
string value = GetSetting(key);
|
||||
|
||||
try
|
||||
{
|
||||
return CastSetting<T>(value);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// If no value is available in database but default value is set, return default value.
|
||||
if (defaultValue is not null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// No value in database and no default value set, throw exception.
|
||||
throw new InvalidOperationException($"Failed to cast setting {key} to type {typeof(T)}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,7 +137,7 @@ public class SettingsService(DbService dbService)
|
||||
/// <returns>Task.</returns>
|
||||
private async Task SetSettingAsync(string key, string value)
|
||||
{
|
||||
var db = await dbService.GetDbContextAsync();
|
||||
var db = await _dbService!.GetDbContextAsync();
|
||||
var setting = await db.Settings.FindAsync(key);
|
||||
if (setting == null)
|
||||
{
|
||||
@@ -108,7 +157,7 @@ public class SettingsService(DbService dbService)
|
||||
db.Settings.Update(setting);
|
||||
}
|
||||
|
||||
await dbService.SaveDatabaseAsync();
|
||||
await _dbService.SaveDatabaseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,7 +175,7 @@ public class SettingsService(DbService dbService)
|
||||
return default;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Cannot cast null or empty string to non-nullable type {typeof(T)}");
|
||||
throw new InvalidOperationException($"Setting value is null or empty for non-nullable type {typeof(T)}");
|
||||
}
|
||||
|
||||
if (typeof(T) == typeof(bool))
|
||||
|
||||
@@ -772,6 +772,10 @@ video {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
@@ -1608,6 +1612,11 @@ video {
|
||||
color: rgb(255 255 255 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(220 38 38 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1777,6 +1786,11 @@ video {
|
||||
border-color: rgb(244 149 65 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-blue-500:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:outline-none:focus {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
@@ -1839,6 +1853,11 @@ video {
|
||||
--tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-blue-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-offset-2:focus {
|
||||
--tw-ring-offset-width: 2px;
|
||||
}
|
||||
@@ -2041,6 +2060,11 @@ video {
|
||||
border-color: rgb(244 149 65 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:border-blue-500:focus:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
|
||||
@@ -2091,6 +2115,16 @@ video {
|
||||
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-blue-500:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.dark\:focus\:ring-blue-600:focus:is(.dark *) {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:col-span-3 {
|
||||
grid-column: span 3 / span 3;
|
||||
@@ -2108,6 +2142,10 @@ video {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||
--tw-space-x-reverse: 0;
|
||||
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||
|
||||
Reference in New Issue
Block a user