Add multistep import flow (#542)

This commit is contained in:
Leendert de Borst
2025-03-25 18:37:33 +01:00
committed by Leendert de Borst
parent c00e6c6a4d
commit a2c2caed79
5 changed files with 272 additions and 45 deletions

View File

@@ -1,28 +1,54 @@
@inject ILogger<ImportServiceAliasVault> Logger
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@using AliasVault.ImportExport.Models
<ImportServiceCard
ServiceName="AliasVault"
Description="Import passwords from another AliasVault instance or manual back-up"
LogoUrl="img/logo.svg"
OnImportComplete="RefreshVault"
>
OnImportConfirmed="ProcessFile"
@ref="_importServiceCard">
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your AliasVault export file:</p>
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
</div>
<InputFile OnChange="HandleFileUpload" />
</ImportServiceCard>
@code {
private async Task HandleFileUpload(InputFileChangeEventArgs e)
private ImportServiceCard _importServiceCard = null!;
private IBrowserFile? _selectedFile;
private void HandleFileUpload(InputFileChangeEventArgs e)
{
// AliasVault specific file upload and parsing logic
await Task.Delay(500);
Logger.LogInformation($"Processing AliasVault file: {e.File.Name}");
Logger.LogInformation($"File selected: {e.File.Name}");
_selectedFile = e.File;
}
private async Task ProcessFile()
{
if (_selectedFile == null)
{
throw new ArgumentException("Please select a valid AliasVault export file to import");
}
Logger.LogInformation($"Processing AliasVault file: {_selectedFile.Name}");
try
{
await using var stream = _selectedFile.OpenReadStream();
using var reader = new StreamReader(stream);
var fileContents = await reader.ReadToEndAsync();
}
catch
{
throw new ArgumentException("Error processing AliasVault file. Please check the file format and try again.");
}
}
private void RefreshVault()
{
// Refresh the vault
// ...
GlobalNotificationService.AddSuccessMessage("AliasVault import successful!");
}
}

View File

@@ -12,14 +12,13 @@
@ref="_importServiceCard">
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your Bitwarden CSV export file:</p>
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
</div>
<InputFile OnChange="HandleFileUpload" />
</ImportServiceCard>
@code {
private ImportServiceCard _importServiceCard = null!;
private IBrowserFile? _selectedFile;
private IBrowserFile? _selectedFile;
private void HandleFileUpload(InputFileChangeEventArgs e)
{
@@ -29,13 +28,12 @@
private async Task ProcessFile()
{
if (_selectedFile == null)
if (_selectedFile == null || string.IsNullOrEmpty(_selectedFile.Name))
{
Logger.LogWarning("No file selected for import");
return;
throw new ArgumentException("Please select a valid Bitwarden CSV file to import");
}
Logger.LogInformation($"Processing KeePass file: {_selectedFile.Name}");
Logger.LogInformation($"Processing Bitwarden file: {_selectedFile.Name}");
try
{
@@ -46,15 +44,14 @@
var importCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContents);
_importServiceCard.SetImportedCredentials(importCredentials);
}
catch (Exception ex)
catch
{
Logger.LogError(ex, "Error processing KeePass CSV file");
throw;
throw new ArgumentException("Error processing Bitwarden CSV file. Please check the file format and try again.");
}
}
private void RefreshVault()
{
GlobalNotificationService.AddSuccessMessage("Bitwarden CSV import successful!");
NavigationManager.NavigateTo("/credentials");
}
}

View File

@@ -2,6 +2,7 @@
@inject IJSRuntime JSRuntime
@inject CredentialService CredentialService
@inject DbService DbService
@inject NavigationManager NavigationManager
@using AliasVault.ImportExport.Importers
@using AliasVault.ImportExport.Models
@@ -53,13 +54,70 @@
}
else
{
@ChildContent
}
@switch (CurrentStep)
{
case ImportStep.FileUpload:
@if (!string.IsNullOrEmpty(ImportError))
{
<div class="mb-4 p-4 text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
@ImportError
</div>
}
<div class="mb-4">
@ChildContent
</div>
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
</div>
break;
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@CloseModal" Color="secondary">Cancel</Button>
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
</div>
case ImportStep.Preview:
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Check if the credentials are correct before importing:</p>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Service</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Password</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
@foreach (var credential in ImportedCredentials.Take(10))
{
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.ServiceName</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.Username</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@(new string('*', credential.Password?.Length ?? 0))</td>
</tr>
}
</tbody>
</table>
@if (ImportedCredentials.Count > 10)
{
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">... and @(ImportedCredentials.Count - 10) more credentials</p>
}
</div>
</div>
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
<Button OnClick="@HandleNextStep" Color="primary">Next</Button>
</div>
break;
case ImportStep.Confirm:
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Are you sure you want to import @ImportedCredentials.Count credentials?</p>
</div>
<div class="flex justify-end mt-6 space-x-2">
<Button OnClick="@HandlePreviousStep" Color="secondary">Back</Button>
<Button OnClick="@HandleModalConfirm" Color="primary">Import</Button>
</div>
break;
}
}
</div>
</div>
</ModalWrapper>
@@ -67,6 +125,13 @@
}
@code {
private enum ImportStep
{
FileUpload,
Preview,
Confirm
}
/// <summary>
///
/// </summary>
@@ -96,8 +161,9 @@
private bool IsModalOpen { get; set; } = false;
private bool IsImporting { get; set; } = false;
private string? ImportErrorMessage { get; set; }
private string? ImportError { get; set; }
private string? ImportSuccessMessage { get; set; }
private ImportStep CurrentStep { get; set; } = ImportStep.FileUpload;
/// <summary>
/// Child content which is shown in the modal popup. This can contain custom instructions.
@@ -125,6 +191,7 @@
protected virtual void OpenImportModal()
{
IsModalOpen = true;
CurrentStep = ImportStep.FileUpload;
StateHasChanged();
}
@@ -134,8 +201,50 @@
protected virtual void CloseModal()
{
IsModalOpen = false;
ImportErrorMessage = null;
CurrentStep = ImportStep.FileUpload;
ImportError = null;
ImportSuccessMessage = null;
ImportedCredentials.Clear();
StateHasChanged();
}
protected virtual async Task HandleNextStep()
{
if (CurrentStep == ImportStep.FileUpload)
{
try
{
ImportError = null;
await OnImportConfirmed.InvokeAsync();
if (ImportedCredentials.Count > 0)
{
CurrentStep = ImportStep.Preview;
}
}
catch (Exception ex)
{
ImportError = ex.Message;
Logger.LogError(ex, "Error during import confirmation");
StateHasChanged();
}
}
else if (CurrentStep == ImportStep.Preview)
{
CurrentStep = ImportStep.Confirm;
}
StateHasChanged();
}
protected virtual void HandlePreviousStep()
{
if (CurrentStep == ImportStep.Confirm)
{
CurrentStep = ImportStep.Preview;
}
else if (CurrentStep == ImportStep.Preview)
{
CurrentStep = ImportStep.FileUpload;
}
StateHasChanged();
}
@@ -150,15 +259,12 @@
}
IsImporting = true;
ImportErrorMessage = null;
ImportError = null;
ImportSuccessMessage = null;
StateHasChanged();
try
{
// Call the OnImportConfirmed callback first
await OnImportConfirmed.InvokeAsync();
// Convert imported credentials to AliasVault format and insert them to the database.
var credentials = BaseImporter.ConvertToCredential(ImportedCredentials);
foreach (var credential in credentials)
@@ -171,24 +277,23 @@
if (success)
{
ImportSuccessMessage = $"Successfully imported {ImportedCredentials.Count} credentials.";
await OnImportComplete.InvokeAsync();
NavigationManager.NavigateTo("/credentials");
}
else
{
ImportErrorMessage = "Error saving database.";
ImportError = "Error saving database.";
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error importing credentials");
ImportErrorMessage = $"Error importing credentials: {ex.Message}";
ImportError = $"Error importing credentials: {ex.Message}";
}
finally
{
IsImporting = false;
StateHasChanged();
}
await OnImportComplete.InvokeAsync();
CloseModal();
}
}

View File

@@ -12,8 +12,8 @@
@ref="_importServiceCard">
<div class="mb-4">
<p class="mb-4 text-gray-700 dark:text-gray-300">Upload your KeePass export file:</p>
<InputFile OnChange="HandleFileUpload" class="text-gray-700 dark:text-gray-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 dark:file:bg-gray-700 dark:file:text-gray-300 dark:hover:file:bg-gray-600" />
</div>
<InputFile OnChange="HandleFileUpload" />
</ImportServiceCard>
@code {
@@ -28,12 +28,10 @@
private async Task ProcessFile()
{
if (_selectedFile == null)
if (_selectedFile == null || string.IsNullOrEmpty(_selectedFile.Name))
{
Logger.LogWarning("No file selected for import");
return;
throw new ArgumentException("Please select a valid KeePass CSV file to import");
}
Logger.LogInformation($"Processing KeePass file: {_selectedFile.Name}");
try
@@ -45,16 +43,14 @@
var importCredentials = await KeePassImporter.ImportFromCsvAsync(fileContents);
_importServiceCard.SetImportedCredentials(importCredentials);
}
catch (Exception ex)
catch
{
Logger.LogError(ex, "Error processing KeePass CSV file");
throw;
throw new ArgumentException("Error processing KeePass CSV file. Please check the file format and try again.");
}
}
private void RefreshVault()
{
GlobalNotificationService.AddSuccessMessage("KeePass CSV import successful!");
NavigationManager.NavigateTo("/credentials");
}
}

View File

@@ -741,6 +741,10 @@ video {
margin-bottom: 1rem;
}
.mb-5 {
margin-bottom: 1.25rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
@@ -833,6 +837,10 @@ video {
margin-top: 2rem;
}
.mt-auto {
margin-top: auto;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
@@ -1028,6 +1036,10 @@ video {
max-width: 80rem;
}
.max-w-lg {
max-width: 32rem;
}
.max-w-md {
max-width: 28rem;
}
@@ -1609,6 +1621,11 @@ video {
fill: #d68338;
}
.object-contain {
-o-object-fit: contain;
object-fit: contain;
}
.\!p-0 {
padding: 0px !important;
}
@@ -1984,6 +2001,11 @@ video {
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.text-red-700 {
--tw-text-opacity: 1;
color: rgb(185 28 28 / var(--tw-text-opacity));
}
.text-red-800 {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
@@ -2109,6 +2131,47 @@ video {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.file\:mr-4::file-selector-button {
margin-right: 1rem;
}
.file\:rounded-lg::file-selector-button {
border-radius: 0.5rem;
}
.file\:border-0::file-selector-button {
border-width: 0px;
}
.file\:bg-blue-50::file-selector-button {
--tw-bg-opacity: 1;
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
}
.file\:px-4::file-selector-button {
padding-left: 1rem;
padding-right: 1rem;
}
.file\:py-2::file-selector-button {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.file\:text-sm::file-selector-button {
font-size: 0.875rem;
line-height: 1.25rem;
}
.file\:font-semibold::file-selector-button {
font-weight: 600;
}
.file\:text-blue-700::file-selector-button {
--tw-text-opacity: 1;
color: rgb(29 78 216 / var(--tw-text-opacity));
}
.hover\:scale-105:hover {
--tw-scale-x: 1.05;
--tw-scale-y: 1.05;
@@ -2235,6 +2298,11 @@ video {
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.hover\:text-gray-700:hover {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.hover\:text-gray-800:hover {
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity));
@@ -2279,6 +2347,11 @@ video {
text-decoration-line: underline;
}
.hover\:file\:bg-blue-100::file-selector-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(219 234 254 / var(--tw-bg-opacity));
}
.focus\:border-blue-500:focus {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
@@ -2529,6 +2602,11 @@ video {
background-color: rgb(123 74 30 / var(--tw-bg-opacity));
}
.dark\:bg-red-200:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(254 202 202 / var(--tw-bg-opacity));
}
.dark\:bg-red-600:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
@@ -2647,6 +2725,11 @@ video {
color: rgb(239 68 68 / var(--tw-text-opacity));
}
.dark\:text-red-800:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(153 27 27 / var(--tw-text-opacity));
}
.dark\:text-white:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
@@ -2676,6 +2759,16 @@ video {
--tw-ring-offset-color: #1f2937;
}
.dark\:file\:bg-gray-700:is(.dark *)::file-selector-button {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity));
}
.dark\:file\:text-gray-300:is(.dark *)::file-selector-button {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
--tw-bg-opacity: 1;
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
@@ -2756,6 +2849,11 @@ video {
color: rgb(229 231 235 / var(--tw-text-opacity));
}
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity));
}
.dark\:hover\:text-primary-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(248 185 99 / var(--tw-text-opacity));
@@ -2781,6 +2879,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark\:hover\:file\:bg-gray-600:is(.dark *)::file-selector-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));