diff --git a/src/AliasVault.Client/Main/Layout/TopMenu.razor b/src/AliasVault.Client/Main/Layout/TopMenu.razor
index adb4929e1..4083ff7f5 100644
--- a/src/AliasVault.Client/Main/Layout/TopMenu.razor
+++ b/src/AliasVault.Client/Main/Layout/TopMenu.razor
@@ -43,7 +43,10 @@
diff --git a/src/AliasVault.Client/Main/Pages/Settings/General.razor b/src/AliasVault.Client/Main/Pages/Settings/General.razor
new file mode 100644
index 000000000..83efb19bb
--- /dev/null
+++ b/src/AliasVault.Client/Main/Pages/Settings/General.razor
@@ -0,0 +1,146 @@
+@page "/settings/general"
+@inherits MainBase
+@inject CredentialService CredentialService
+
+General settings
+
+
+
+
+
+
General settings
+
+
On this page you can configure general AliasVault settings.
+
+
+
+
+
Export vault
+
+
+
+
+
+
+
+
+
+
+
+
Import vault
+
+
+ Import unencrypted CSV file:
+
+
+
+
+
+@if (IsImporting)
+{
+ Loading...
+}
+else if (!string.IsNullOrEmpty(ImportErrorMessage))
+{
+ @ImportErrorMessage
+}
+else if (!string.IsNullOrEmpty(ImportSuccessMessage))
+{
+ @ImportSuccessMessage
+}
+
+@code {
+ private bool IsImporting = false;
+ private string ImportErrorMessage = string.Empty;
+ private string ImportSuccessMessage = string.Empty;
+
+ ///
+ protected override async Task OnInitializedAsync()
+ {
+ 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();
+ }
+ }
+}
diff --git a/src/AliasVault.Client/Program.cs b/src/AliasVault.Client/Program.cs
index 576c3be60..c06708327 100644
--- a/src/AliasVault.Client/Program.cs
+++ b/src/AliasVault.Client/Program.cs
@@ -68,6 +68,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddSingleton();
builder.Services.AddAuthorizationCore();
diff --git a/src/AliasVault.Client/Services/SettingsService.cs b/src/AliasVault.Client/Services/SettingsService.cs
new file mode 100644
index 000000000..c4683bc37
--- /dev/null
+++ b/src/AliasVault.Client/Services/SettingsService.cs
@@ -0,0 +1,185 @@
+//-----------------------------------------------------------------------
+//
+// 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.Client.Services;
+
+using System;
+using System.Text.Json;
+using System.Threading.Tasks;
+using AliasClientDb;
+
+///
+/// Service class for accessing and mutating general settings stored in database.
+///
+public class SettingsService(DbService dbService)
+{
+ ///
+ /// Gets the DefaultEmailDomain setting asynchronously.
+ ///
+ /// Default email domain as string.
+ public Task GetDefaultEmailDomainAsync() => GetSettingAsync("DefaultEmailDomain");
+
+ ///
+ /// Sets the DefaultEmailDomain setting asynchronously.
+ ///
+ /// The new DeafultEmailDomain setting.
+ /// Task.
+ public Task SetDefaultEmailDomainAsync(string value) => SetSettingAsync("DefaultEmailDomain", value);
+
+ ///
+ /// Gets the AutoEmailRefresh setting asynchronously as a string.
+ ///
+ /// AutoEmailRefresh setting as string.
+ public Task GetAutoEmailRefreshAsync() => GetSettingAsync("AutoEmailRefresh");
+
+ ///
+ /// Sets the AutoEmailRefresh setting asynchronously as a string.
+ ///
+ /// The new value.
+ /// Task.
+ public Task SetAutoEmailRefreshAsync(bool value) => SetSettingAsync("AutoEmailRefresh", value);
+
+ ///
+ /// Get setting value from database.
+ ///
+ /// Key of setting to retrieve.
+ /// Setting as string value.
+ private async Task GetSettingAsync(string key)
+ {
+ var db = await dbService.GetDbContextAsync();
+ var setting = await db.Settings.FindAsync(key);
+ return setting?.Value ?? string.Empty;
+ }
+
+ ///
+ /// Gets a setting asynchronously and casts it to the specified type.
+ ///
+ /// The type to cast the setting to.
+ /// The key of the setting.
+ /// The setting value cast to type T.
+ private async Task GetSettingAsync(string key)
+ {
+ string value = await GetSettingAsync(key);
+ return CastSetting(value);
+ }
+
+ ///
+ /// Sets a setting asynchronously, converting the value to a string so its compatible with the database field.
+ ///
+ /// The type of the value being set.
+ /// The key of the setting.
+ /// The value to set.
+ /// Task.
+ private Task SetSettingAsync(string key, T value)
+ {
+ string stringValue = ConvertToString(value);
+ return SetSettingAsync(key, stringValue);
+ }
+
+ ///
+ /// Set setting value in database.
+ ///
+ /// Key of setting to set.
+ /// Value of setting to set.
+ /// Task.
+ private async Task SetSettingAsync(string key, string value)
+ {
+ var db = await dbService.GetDbContextAsync();
+ var setting = await db.Settings.FindAsync(key);
+ if (setting == null)
+ {
+ setting = new Setting
+ {
+ Key = key,
+ Value = value,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ };
+ db.Settings.Add(setting);
+ }
+ else
+ {
+ setting.Value = value;
+ setting.UpdatedAt = DateTime.UtcNow;
+ db.Settings.Update(setting);
+ }
+
+ await dbService.SaveDatabaseAsync();
+ }
+
+ ///
+ /// Casts a setting value from the database string type to the specified requested type.
+ ///
+ /// Value (string) to cast.
+ /// Type to cast it to.
+ /// The value casted to the requested type.
+ private T? CastSetting(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ if (default(T) is null)
+ {
+ return default;
+ }
+
+ throw new ArgumentException($"Cannot cast null or empty string to non-nullable type {typeof(T)}");
+ }
+
+ if (typeof(T) == typeof(bool))
+ {
+ return (T)(object)(bool.TryParse(value, out bool result) && result);
+ }
+
+ if (typeof(T) == typeof(int))
+ {
+ return (T)(object)int.Parse(value);
+ }
+
+ if (typeof(T) == typeof(double))
+ {
+ return (T)(object)double.Parse(value);
+ }
+
+ if (typeof(T) == typeof(string))
+ {
+ return (T)(object)value;
+ }
+
+ // For complex types, attempt JSON deserialization
+ try
+ {
+ var result = JsonSerializer.Deserialize(value);
+ if (result is null && default(T) is not null)
+ {
+ throw new InvalidOperationException($"Deserialization resulted in null for non-nullable type {typeof(T)}");
+ }
+
+ return result;
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Failed to deserialize value to type {typeof(T)}", ex);
+ }
+ }
+
+ ///
+ /// Converts a value of any type to a string.
+ ///
+ /// The value to convert.
+ /// The type of the existing value.
+ /// Value converted to string.
+ private string ConvertToString(T value)
+ {
+ if (value is bool || value is int || value is double || value is string)
+ {
+ return value.ToString() ?? string.Empty;
+ }
+
+ // For complex types, use JSON serialization
+ return JsonSerializer.Serialize(value);
+ }
+}
diff --git a/src/Databases/AliasClientDb/AliasClientDbContext.cs b/src/Databases/AliasClientDb/AliasClientDbContext.cs
index c208ae485..88561b3db 100644
--- a/src/Databases/AliasClientDb/AliasClientDbContext.cs
+++ b/src/Databases/AliasClientDb/AliasClientDbContext.cs
@@ -73,6 +73,11 @@ public class AliasClientDbContext : DbContext
///
public DbSet EncryptionKeys { get; set; } = null!;
+ ///
+ /// Gets or sets the Settings DbSet.
+ ///
+ public DbSet Settings { get; set; } = null!;
+
///
/// The OnModelCreating method.
///
diff --git a/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.Designer.cs b/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.Designer.cs
new file mode 100644
index 000000000..307120061
--- /dev/null
+++ b/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.Designer.cs
@@ -0,0 +1,328 @@
+//
+using System;
+using AliasClientDb;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace AliasClientDb.Migrations
+{
+ [DbContext(typeof(AliasClientDbContext))]
+ [Migration("20240805073413_1.2.0-AddSettingsTable")]
+ partial class _120AddSettingsTable
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.7")
+ .HasAnnotation("Proxies:ChangeTracking", false)
+ .HasAnnotation("Proxies:CheckEquality", false)
+ .HasAnnotation("Proxies:LazyLoading", true);
+
+ modelBuilder.Entity("AliasClientDb.Alias", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddressCity")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("AddressCountry")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("AddressState")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("AddressStreet")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("AddressZipCode")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("BankAccountIBAN")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("BirthDate")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("FirstName")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("Gender")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("Hobbies")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("LastName")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("NickName")
+ .HasMaxLength(255)
+ .HasColumnType("VARCHAR");
+
+ b.Property("PhoneMobile")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Aliases");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Attachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Blob")
+ .IsRequired()
+ .HasColumnType("BLOB");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("Filename")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CredentialId");
+
+ b.ToTable("Attachment");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Credential", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AliasId")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Notes")
+ .HasColumnType("TEXT");
+
+ b.Property("ServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AliasId");
+
+ b.HasIndex("ServiceId");
+
+ b.ToTable("Credentials");
+ });
+
+ modelBuilder.Entity("AliasClientDb.EncryptionKey", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("IsPrimary")
+ .HasColumnType("INTEGER");
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("EncryptionKeys");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Password", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("CredentialId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CredentialId");
+
+ b.ToTable("Passwords");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Service", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Logo")
+ .HasColumnType("BLOB");
+
+ b.Property("Name")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Url")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Services");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Setting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Attachment", b =>
+ {
+ b.HasOne("AliasClientDb.Credential", "Credential")
+ .WithMany("Attachments")
+ .HasForeignKey("CredentialId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Credential");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Credential", b =>
+ {
+ b.HasOne("AliasClientDb.Alias", "Alias")
+ .WithMany("Credentials")
+ .HasForeignKey("AliasId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("AliasClientDb.Service", "Service")
+ .WithMany("Credentials")
+ .HasForeignKey("ServiceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Alias");
+
+ b.Navigation("Service");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Password", b =>
+ {
+ b.HasOne("AliasClientDb.Credential", "Credential")
+ .WithMany("Passwords")
+ .HasForeignKey("CredentialId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Credential");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Alias", b =>
+ {
+ b.Navigation("Credentials");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Credential", b =>
+ {
+ b.Navigation("Attachments");
+
+ b.Navigation("Passwords");
+ });
+
+ modelBuilder.Entity("AliasClientDb.Service", b =>
+ {
+ b.Navigation("Credentials");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.cs b/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.cs
new file mode 100644
index 000000000..d4b4fd751
--- /dev/null
+++ b/src/Databases/AliasClientDb/Migrations/20240805073413_1.2.0-AddSettingsTable.cs
@@ -0,0 +1,37 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace AliasClientDb.Migrations
+{
+ ///
+ public partial class _120AddSettingsTable : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Settings",
+ columns: table => new
+ {
+ Key = table.Column(type: "TEXT", maxLength: 255, nullable: false),
+ Value = table.Column(type: "TEXT", nullable: true),
+ CreatedAt = table.Column(type: "TEXT", nullable: false),
+ UpdatedAt = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Settings", x => x.Key);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Settings");
+ }
+ }
+}
diff --git a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs
index 249a24427..64a5bd707 100644
--- a/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs
+++ b/src/Databases/AliasClientDb/Migrations/AliasClientDbContextModelSnapshot.cs
@@ -242,6 +242,26 @@ namespace AliasClientDb.Migrations
b.ToTable("Services");
});
+ modelBuilder.Entity("AliasClientDb.Setting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
diff --git a/src/Databases/AliasClientDb/Setting.cs b/src/Databases/AliasClientDb/Setting.cs
new file mode 100644
index 000000000..2bbff8946
--- /dev/null
+++ b/src/Databases/AliasClientDb/Setting.cs
@@ -0,0 +1,38 @@
+//-----------------------------------------------------------------------
+//
+// Copyright (c) lanedirt. All rights reserved.
+// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
+//
+//-----------------------------------------------------------------------
+namespace AliasClientDb;
+
+using System.ComponentModel.DataAnnotations;
+
+///
+/// The service entity.
+///
+public class Setting
+{
+ ///
+ /// Gets or sets the setting key which is also the primary unique key.
+ ///
+ [Key]
+ [StringLength(255)]
+ public string Key { get; set; } = null!;
+
+ ///
+ /// Gets or sets the setting value. The field type is a string, but it can be used to store any type of data
+ /// via serialization.
+ ///
+ public string? Value { get; set; }
+
+ ///
+ /// Gets or sets the created timestamp.
+ ///
+ public DateTime CreatedAt { get; set; }
+
+ ///
+ /// Gets or sets the updated timestamp.
+ ///
+ public DateTime UpdatedAt { get; set; }
+}