From a27d1285f0af1af1afee9b5ad891bec9f54eda51 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 24 Jan 2026 22:27:43 +0100 Subject: [PATCH 1/8] Update importers to detect folders and optionally new item types (#1481) --- .../Components/ImportServiceCard.razor | 58 +++- .../ImportExport/ImportServiceCard.en.resx | 8 + .../Utilities/ImportExportTests.cs | 253 +++++++++++++++++- .../Importers/BaseImporter.cs | 160 ++++++++++- .../Importers/BitwardenImporter.cs | 25 +- .../Importers/DashlaneImporter.cs | 8 +- .../Importers/KeePassXcImporter.cs | 3 +- .../Importers/LastPassImporter.cs | 133 ++++++++- .../Importers/OnePasswordImporter.cs | 3 +- .../Importers/ProtonPassImporter.cs | 23 ++ .../Models/ImportedCredential.cs | 18 ++ .../Models/ImportedCreditcard.cs | 45 ++++ .../Models/ImportedItemType.cs | 35 +++ 13 files changed, 729 insertions(+), 43 deletions(-) create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCreditcard.cs create mode 100644 apps/server/Utilities/AliasVault.ImportExport/Models/ImportedItemType.cs diff --git a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor index c51b46906..5fcaaa41f 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Settings/ImportExport/Components/ImportServiceCard.razor @@ -1,6 +1,7 @@ @inject ILogger Logger @inject ItemService ItemService @inject DbService DbService +@inject FolderService FolderService @inject NavigationManager NavigationManager @inject GlobalNotificationService GlobalNotificationService @inject HttpClient HttpClient @@ -128,11 +129,19 @@ @if (ImportedCredentials.Count > 0) { -
+
+ @if (DetectedFolderCount > 0) + { + + }
}
@@ -248,6 +257,9 @@ private List ImportedCredentials { get; set; } = new(); private bool ExtractFavicons { get; set; } = true; + private bool ImportFolders { get; set; } = true; + private int DetectedFolderCount { get; set; } = 0; + private HashSet DetectedFolderNames { get; set; } = new(); private bool IsExtractingFavicons { get; set; } private int FaviconExtractionProgress { get; set; } private int TotalFaviconsToExtract { get; set; } @@ -475,7 +487,14 @@ /// private async Task ImportCredentialsToDatabase() { - var items = BaseImporter.ConvertToItem(ImportedCredentials); + // Build folder mapping if folder import is enabled + Dictionary? folderNameToId = null; + if (ImportFolders && DetectedFolderCount > 0) + { + folderNameToId = await CreateOrGetFolders(); + } + + var items = BaseImporter.ConvertToItem(ImportedCredentials, folderNameToId); foreach (var item in items) { await ProcessSingleItem(item); @@ -494,6 +513,35 @@ } } + /// + /// Creates folders that don't exist and returns a mapping of folder names to folder IDs. + /// + private async Task> CreateOrGetFolders() + { + var folderNameToId = new Dictionary(StringComparer.OrdinalIgnoreCase); + var existingFolders = await FolderService.GetAllWithCountsAsync(); + + foreach (var folderName in DetectedFolderNames) + { + // Check if folder already exists (case-insensitive) + var existingFolder = existingFolders.FirstOrDefault(f => + f.Name.Equals(folderName, StringComparison.OrdinalIgnoreCase)); + + if (existingFolder != null) + { + folderNameToId[folderName] = existingFolder.Id; + } + else + { + // Create new folder - CreateAsync returns the new folder ID directly + var newFolderId = await FolderService.CreateAsync(folderName); + folderNameToId[folderName] = newFolderId; + } + } + + return folderNameToId; + } + /// /// Processes a single item. /// @@ -603,7 +651,7 @@ } /// - /// Detects and removes duplicates from the import list. + /// Detects and removes duplicates from the import list, and also detects folders. /// private async Task DetectAndRemoveDuplicates() { @@ -621,6 +669,10 @@ // Remove duplicates from the import list ImportedCredentials = ImportedCredentials.Except(duplicates).ToList(); + + // Detect folders in the import + DetectedFolderNames = BaseImporter.CollectUniqueFolderNames(ImportedCredentials); + DetectedFolderCount = DetectedFolderNames.Count; } /// diff --git a/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServiceCard.en.resx b/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServiceCard.en.resx index 463424969..03c34cf70 100644 --- a/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServiceCard.en.resx +++ b/apps/server/AliasVault.Client/Resources/Components/Main/Settings/ImportExport/ImportServiceCard.en.resx @@ -86,4 +86,12 @@ Import Import button text + + Import folders (if available) + Checkbox label for importing folder structure from the source password manager + + + {0} folder(s) detected + Info text showing number of folders detected in the import. {0} is the count + \ No newline at end of file diff --git a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs index b3feee1c1..9be45b701 100644 --- a/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs +++ b/apps/server/Tests/AliasVault.UnitTests/Utilities/ImportExportTests.cs @@ -11,6 +11,7 @@ using AliasClientDb; using AliasClientDb.Models; using AliasVault.ImportExport; using AliasVault.ImportExport.Importers; +using AliasVault.ImportExport.Models; using AliasVault.UnitTests.Common; /// @@ -30,7 +31,7 @@ public class ImportExportTests { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "Test Service", - ItemType = "Login", + ItemType = ItemType.Login, CreatedAt = DateTime.Now, UpdatedAt = DateTime.Now, }; @@ -533,7 +534,7 @@ public class ImportExportTests Assert.That(secureNoteCredential.TwoFactorSecret, Is.Empty); }); - // Test credit card entry (stored as note-only credential) + // Test credit card entry var creditCardCredential = importedCredentials.First(c => c.ServiceName == "Paymentcard1"); Assert.Multiple(() => { @@ -541,11 +542,12 @@ public class ImportExportTests Assert.That(creditCardCredential.ServiceUrl, Is.Null); // Should be normalized to null Assert.That(creditCardCredential.Username, Is.Empty); Assert.That(creditCardCredential.Password, Is.Empty); - Assert.That(creditCardCredential.Notes, Does.Contain("NoteType:Credit Card")); - Assert.That(creditCardCredential.Notes, Does.Contain("Name on Card:Cardname")); - Assert.That(creditCardCredential.Notes, Does.Contain("Number:123456781234")); - Assert.That(creditCardCredential.Notes, Does.Contain("Security Code:1234")); - Assert.That(creditCardCredential.Notes, Does.Contain("Creditcardnotes here")); + Assert.That(creditCardCredential.ItemType, Is.EqualTo(ImportedItemType.Creditcard)); + Assert.That(creditCardCredential.Creditcard, Is.Not.Null); + Assert.That(creditCardCredential.Creditcard!.CardholderName, Is.EqualTo("Cardname")); + Assert.That(creditCardCredential.Creditcard.Number, Is.EqualTo("123456781234")); + Assert.That(creditCardCredential.Creditcard.Cvv, Is.EqualTo("1234")); + Assert.That(creditCardCredential.Notes, Is.EqualTo("Creditcardnotes here")); // Extracted notes Assert.That(creditCardCredential.TwoFactorSecret, Is.Empty); }); } @@ -758,6 +760,243 @@ public class ImportExportTests Assert.That(template, Does.Contain("your_totp_secret_here")); } + /// + /// Test case for Bitwarden import with folder path extraction. + /// + /// Async task. + [Test] + public async Task ImportBitwardenWithFolders() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.bitwarden.csv"); + + // Act + var importedCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContent); + + // Assert - check folder path is extracted (6 items in Business folder in test data) + var businessFolderItems = importedCredentials.Where(c => c.FolderPath == "Business").ToList(); + Assert.That(businessFolderItems, Has.Count.EqualTo(6), "Should have 6 items in Business folder"); + + // Verify folder names are collected correctly + var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials); + Assert.That(folderNames, Does.Contain("Business")); + } + + /// + /// Test case for multi-level folder path extraction (takes deepest folder). + /// + [Test] + public void ExtractDeepestFolderName() + { + Assert.Multiple(() => + { + Assert.That(BaseImporter.ExtractDeepestFolderName("Root/Business/Banking"), Is.EqualTo("Banking")); + Assert.That(BaseImporter.ExtractDeepestFolderName("Business"), Is.EqualTo("Business")); + Assert.That(BaseImporter.ExtractDeepestFolderName("Root\\Work\\Finance"), Is.EqualTo("Finance")); + Assert.That(BaseImporter.ExtractDeepestFolderName(string.Empty), Is.Null); + Assert.That(BaseImporter.ExtractDeepestFolderName(null), Is.Null); + Assert.That(BaseImporter.ExtractDeepestFolderName(" / "), Is.Null); + Assert.That(BaseImporter.ExtractDeepestFolderName("Single"), Is.EqualTo("Single")); + }); + } + + /// + /// Test case for Bitwarden type detection (login, note, card). + /// + /// Async task. + [Test] + public async Task BitwardenTypeDetection() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.bitwarden.csv"); + + // Act + var importedCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContent); + var items = BaseImporter.ConvertToItem(importedCredentials); + + // Assert - verify login type items have Login item type + var loginItems = items.Where(i => i.ItemType == ItemType.Login).ToList(); + Assert.That(loginItems, Has.Count.GreaterThan(0), "Should have at least one Login item"); + } + + /// + /// Test case for LastPass secure note detection. + /// + /// Async task. + [Test] + public async Task LastPassSecureNoteDetection() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv"); + + // Act + var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent); + var items = BaseImporter.ConvertToItem(importedCredentials); + + // Assert - verify secure note is detected + var secureNoteItem = items.FirstOrDefault(i => i.Name == "securenote1"); + Assert.That(secureNoteItem, Is.Not.Null, "Should find securenote1"); + Assert.That(secureNoteItem!.ItemType, Is.EqualTo(ItemType.Note), "Secure note should have Note item type"); + } + + /// + /// Test case for LastPass credit card detection and parsing. + /// + /// Async task. + [Test] + public async Task LastPassCreditCardDetectionAndParsing() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv"); + + // Act + var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify credit card is detected + var creditCardCredential = importedCredentials.FirstOrDefault(c => c.ServiceName == "Paymentcard1"); + Assert.That(creditCardCredential, Is.Not.Null, "Should find Paymentcard1"); + Assert.That(creditCardCredential!.ItemType, Is.EqualTo(ImportedItemType.Creditcard), "Should be Creditcard type"); + Assert.That(creditCardCredential.Creditcard, Is.Not.Null, "Should have Creditcard data"); + Assert.That(creditCardCredential.Creditcard!.CardholderName, Is.EqualTo("Cardname")); + Assert.That(creditCardCredential.Creditcard.Number, Is.EqualTo("123456781234")); + Assert.That(creditCardCredential.Creditcard.Cvv, Is.EqualTo("1234")); + + // Convert to item and verify fields + var items = BaseImporter.ConvertToItem([creditCardCredential]); + var creditCardItem = items[0]; + Assert.That(creditCardItem.ItemType, Is.EqualTo(ItemType.CreditCard)); + + var cardNumber = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardNumber); + Assert.That(cardNumber?.Value, Is.EqualTo("123456781234")); + + var cardholderName = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardCardholderName); + Assert.That(cardholderName?.Value, Is.EqualTo("Cardname")); + } + + /// + /// Test case for LastPass folder (grouping) import. + /// + /// Async task. + [Test] + public async Task LastPassFolderImport() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv"); + + // Act + var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify folder path is extracted + var credentialWithFolder = importedCredentials.FirstOrDefault(c => !string.IsNullOrEmpty(c.FolderPath)); + Assert.That(credentialWithFolder, Is.Not.Null, "Should have at least one credential with folder"); + Assert.That(credentialWithFolder!.FolderPath, Is.EqualTo("examplefolder")); + } + + /// + /// Test case for KeePassXC group (folder) import. + /// + /// Async task. + [Test] + public async Task KeePassXcGroupImport() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.keepassxc.csv"); + + // Act + var importedCredentials = await KeePassXcImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify folder path is extracted (KeePassXC uses Group column which contains folder hierarchy) + var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials); + Assert.That(folderNames, Has.Count.GreaterThanOrEqualTo(0), "Should collect any folders present"); + } + + /// + /// Test case for folder assignment during ConvertToItem. + /// + [Test] + public void ConvertToItemWithFolderMapping() + { + // Arrange + var credentials = new List + { + new() + { + ServiceName = "Test Service", + FolderPath = "Work/Projects", + Username = "user1", + Password = "pass1", + }, + new() + { + ServiceName = "Test Service 2", + FolderPath = "Personal", + Username = "user2", + Password = "pass2", + }, + new() + { + ServiceName = "No Folder", + Username = "user3", + Password = "pass3", + }, + }; + + var folderMapping = new Dictionary + { + { "Projects", Guid.NewGuid() }, // Deepest folder from "Work/Projects" + { "Personal", Guid.NewGuid() }, + }; + + // Act + var items = BaseImporter.ConvertToItem(credentials, folderMapping); + + // Assert + Assert.That(items[0].FolderId, Is.EqualTo(folderMapping["Projects"]), "Should assign Projects folder"); + Assert.That(items[1].FolderId, Is.EqualTo(folderMapping["Personal"]), "Should assign Personal folder"); + Assert.That(items[2].FolderId, Is.Null, "Should have no folder"); + } + + /// + /// Test case for ProtonPass type and vault (folder) import. + /// + /// Async task. + [Test] + public async Task ProtonPassTypeAndVaultImport() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.protonpass.csv"); + + // Act + var importedCredentials = await ProtonPassImporter.ImportFromCsvAsync(fileContent); + + // Assert - verify vault (folder) is extracted + var credentialsWithVault = importedCredentials.Where(c => !string.IsNullOrEmpty(c.FolderPath)).ToList(); + Assert.That(credentialsWithVault.Count, Is.GreaterThan(0), "Should have credentials with vault/folder"); + + // Verify type detection + var loginCredential = importedCredentials.FirstOrDefault(c => c.ItemType == ImportedItemType.Login); + Assert.That(loginCredential, Is.Not.Null, "Should have at least one Login type"); + } + + /// + /// Test case for Dashlane category (folder) import. + /// + /// Async task. + [Test] + public async Task DashlaneCategoryImport() + { + // Arrange + var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.dashlane.csv"); + + // Act + var importedCredentials = await DashlaneImporter.ImportFromCsvAsync(fileContent); + + // Assert - check if any credentials have folder path from category + // Note: Dashlane test data may or may not have categories + var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials); + Assert.That(folderNames, Is.Not.Null, "Should return a set (even if empty)"); + } + /// /// Helper method to add a field value to an item. /// diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/BaseImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/BaseImporter.cs index cb4433192..b0cf7ade9 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/BaseImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/BaseImporter.cs @@ -142,8 +142,9 @@ public static class BaseImporter /// Converts a list of imported credentials to a list of AliasVault Items. /// /// The list of imported credentials. + /// Optional dictionary mapping folder names to folder IDs for folder import. /// The list of AliasVault Items. - public static List ConvertToItem(List importedCredentials) + public static List ConvertToItem(List importedCredentials, Dictionary? folderNameToId = null) { var items = new List(); @@ -154,27 +155,43 @@ public static class BaseImporter var createdAt = importedCredential.CreatedAt ?? currentDateTime; var updatedAt = importedCredential.UpdatedAt ?? currentDateTime; - // Determine if this is an Alias item type (has alias identity data) - var hasAliasData = importedCredential.Alias != null && - (!string.IsNullOrEmpty(importedCredential.Alias.FirstName) || - !string.IsNullOrEmpty(importedCredential.Alias.LastName) || - !string.IsNullOrEmpty(importedCredential.Alias.Gender) || - importedCredential.Alias.BirthDate.HasValue); + // Determine the item type (uses ItemType from importer if set) + var itemType = DetermineItemType(importedCredential); var item = new Item { Id = Guid.NewGuid(), Name = importedCredential.ServiceName ?? string.Empty, - ItemType = hasAliasData ? "Alias" : "Login", + ItemType = itemType, CreatedAt = createdAt, UpdatedAt = updatedAt, }; - // Add field values for non-empty fields - AddFieldValueIfNotEmpty(item, FieldKey.LoginUrl, importedCredential.ServiceUrl, createdAt, updatedAt); - AddFieldValueIfNotEmpty(item, FieldKey.LoginUsername, importedCredential.Username, createdAt, updatedAt); - AddFieldValueIfNotEmpty(item, FieldKey.LoginPassword, importedCredential.Password, createdAt, updatedAt); - AddFieldValueIfNotEmpty(item, FieldKey.LoginEmail, importedCredential.Email, createdAt, updatedAt); + // Handle folder assignment if folder mapping is provided + if (folderNameToId != null && !string.IsNullOrWhiteSpace(importedCredential.FolderPath)) + { + var folderName = ExtractDeepestFolderName(importedCredential.FolderPath); + if (!string.IsNullOrWhiteSpace(folderName) && folderNameToId.TryGetValue(folderName, out var folderId)) + { + item.FolderId = folderId; + } + } + + // Handle credit card type - parse structured notes and add card fields + if (itemType == ItemType.CreditCard) + { + AddCreditCardFields(item, importedCredential, createdAt, updatedAt); + } + else + { + // Add standard field values for non-empty fields (Login, Alias, Note types) + AddFieldValueIfNotEmpty(item, FieldKey.LoginUrl, importedCredential.ServiceUrl, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.LoginUsername, importedCredential.Username, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.LoginPassword, importedCredential.Password, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.LoginEmail, importedCredential.Email, createdAt, updatedAt); + } + + // Add notes for all item types AddFieldValueIfNotEmpty(item, FieldKey.NotesContent, importedCredential.Notes, createdAt, updatedAt); // Add alias fields if present @@ -220,6 +237,123 @@ public static class BaseImporter return items; } + /// + /// Determines the item type based on the imported credential. + /// Uses ItemType if set by the importer, otherwise checks for alias data. + /// + /// The imported credential to analyze. + /// The item type constant from . + private static string DetermineItemType(ImportedCredential credential) + { + // If the importer explicitly set a type, use it + if (credential.ItemType.HasValue) + { + // If Login type was set but has alias data, upgrade to Alias + if (credential.ItemType == ImportedItemType.Login && HasAliasData(credential)) + { + return ItemType.Alias; + } + + return credential.ItemType.Value switch + { + ImportedItemType.Login => ItemType.Login, + ImportedItemType.Note => ItemType.Note, + ImportedItemType.Creditcard => ItemType.CreditCard, + ImportedItemType.Alias => ItemType.Alias, + _ => ItemType.Login, + }; + } + + // Fallback: check for alias data + if (HasAliasData(credential)) + { + return ItemType.Alias; + } + + // Default to Login + return ItemType.Login; + } + + /// + /// Checks if the credential has alias identity data. + /// + private static bool HasAliasData(ImportedCredential credential) + { + return credential.Alias != null && + (!string.IsNullOrEmpty(credential.Alias.FirstName) || + !string.IsNullOrEmpty(credential.Alias.LastName) || + !string.IsNullOrEmpty(credential.Alias.Gender) || + credential.Alias.BirthDate.HasValue); + } + + /// + /// Extracts the deepest (most specific) folder name from a potentially hierarchical path. + /// For example: "Root/Business/Banking" returns "Banking". + /// + /// The folder path, potentially with hierarchy separators. + /// The deepest folder name. + public static string? ExtractDeepestFolderName(string? folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + return null; + } + + // Handle common hierarchy separators: / and \ + var separators = new[] { '/', '\\' }; + var parts = folderPath.Split(separators, StringSplitOptions.RemoveEmptyEntries); + + // Return the last (deepest) part, or null if no valid folder name + if (parts.Length == 0) + { + return null; + } + + var result = parts[^1].Trim(); + return string.IsNullOrEmpty(result) ? null : result; + } + + /// + /// Collects unique folder names from imported credentials for folder creation. + /// + /// The list of imported credentials. + /// A set of unique folder names (deepest level only). + public static HashSet CollectUniqueFolderNames(List credentials) + { + var folderNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var credential in credentials) + { + var folderName = ExtractDeepestFolderName(credential.FolderPath); + if (!string.IsNullOrWhiteSpace(folderName)) + { + folderNames.Add(folderName); + } + } + + return folderNames; + } + + /// + /// Adds credit card fields to an item from the ImportedCreditcard model. + /// Each importer is responsible for populating the Creditcard property. + /// + private static void AddCreditCardFields(Item item, ImportedCredential credential, DateTime createdAt, DateTime updatedAt) + { + if (credential.Creditcard == null) + { + return; + } + + var card = credential.Creditcard; + AddFieldValueIfNotEmpty(item, FieldKey.CardCardholderName, card.CardholderName, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.CardNumber, card.Number, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.CardCvv, card.Cvv, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.CardPin, card.Pin, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.CardExpiryMonth, card.ExpiryMonth, createdAt, updatedAt); + AddFieldValueIfNotEmpty(item, FieldKey.CardExpiryYear, card.ExpiryYear, createdAt, updatedAt); + } + /// /// Adds a field value to an item if the value is not empty. /// diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/BitwardenImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/BitwardenImporter.cs index 2d54bfef9..4758b3a60 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/BitwardenImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/BitwardenImporter.cs @@ -37,7 +37,9 @@ public static class BitwardenImporter Username = record.Username, Password = record.Password, TwoFactorSecret = record.OTPAuth, - Notes = record.Notes + Notes = record.Notes, + FolderPath = string.IsNullOrWhiteSpace(record.Folder) ? null : record.Folder, + ItemType = MapBitwardenType(record.Type), }; credentials.Add(credential); @@ -45,4 +47,25 @@ public static class BitwardenImporter return credentials; } + + /// + /// Maps Bitwarden type values to ImportedItemType. + /// Bitwarden types: login, note, card, identity. + /// + private static ImportedItemType? MapBitwardenType(string? bitwardenType) + { + if (string.IsNullOrWhiteSpace(bitwardenType)) + { + return null; + } + + return bitwardenType.ToLowerInvariant() switch + { + "login" => ImportedItemType.Login, + "note" or "securenote" => ImportedItemType.Note, + "card" => ImportedItemType.Creditcard, + "identity" => ImportedItemType.Alias, + _ => ImportedItemType.Login, + }; + } } \ No newline at end of file diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/DashlaneImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/DashlaneImporter.cs index ecaf13266..d67de23c6 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/DashlaneImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/DashlaneImporter.cs @@ -34,7 +34,8 @@ public static class DashlaneImporter Username = record.Username, Password = record.Password, TwoFactorSecret = record.OTPUrl, - Notes = BuildNotes(record) + Notes = BuildNotes(record), + FolderPath = string.IsNullOrWhiteSpace(record.Category) ? null : record.Category, }; credentials.Add(credential); @@ -62,11 +63,6 @@ public static class DashlaneImporter notes.Add($"Alternative username 2: {record.Username3}"); } - if (!string.IsNullOrEmpty(record.Category)) - { - notes.Add($"Category: {record.Category}"); - } - return notes.Count > 0 ? string.Join(Environment.NewLine, notes) : null; } } \ No newline at end of file diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/KeePassXcImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/KeePassXcImporter.cs index 704221352..04b47a9e4 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/KeePassXcImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/KeePassXcImporter.cs @@ -34,7 +34,8 @@ public static class KeePassXcImporter Username = record.Username, Password = record.Password, TwoFactorSecret = record.TOTP, - Notes = record.Notes + Notes = record.Notes, + FolderPath = string.IsNullOrWhiteSpace(record.Group) ? null : record.Group, }; credentials.Add(credential); diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/LastPassImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/LastPassImporter.cs index f7822b1f3..1605d6a34 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/LastPassImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/LastPassImporter.cs @@ -1,4 +1,4 @@ -//----------------------------------------------------------------------- +//----------------------------------------------------------------------- // // Copyright (c) aliasvault. All rights reserved. // Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. @@ -33,14 +33,42 @@ public static class LastPassImporter continue; } + // Normalize URL - LastPass uses "http://sn" for secure notes and "http://" for entries without URLs + var normalizedUrl = string.IsNullOrWhiteSpace(record.URL) || record.URL == "http://" || record.URL == "http://sn" + ? null + : record.URL; + + // Determine item type and parse structured data inline + ImportedItemType? itemType = null; + ImportedCreditcard? creditcard = null; + string? notes = record.Extra; + + // Check for credit card (structured data in Extra field) + if (!string.IsNullOrEmpty(record.Extra) && record.Extra.Contains("NoteType:Credit Card")) + { + itemType = ImportedItemType.Creditcard; + creditcard = ParseCreditcardFromNotes(record.Extra); + notes = ExtractNotesFromCreditcard(record.Extra); + } + // Check for secure note: URL is "http://sn" and no username/password + else if (record.URL == "http://sn" && + string.IsNullOrEmpty(record.Username) && + string.IsNullOrEmpty(record.Password)) + { + itemType = ImportedItemType.Note; + } + var credential = new ImportedCredential { ServiceName = record.Title, - ServiceUrl = NormalizeUrl(record.URL), + ServiceUrl = normalizedUrl, Username = record.Username, Password = record.Password, TwoFactorSecret = record.TwoFactorSecret, - Notes = record.Extra + Notes = notes, + FolderPath = string.IsNullOrWhiteSpace(record.Grouping) ? null : record.Grouping, + ItemType = itemType, + Creditcard = creditcard, }; credentials.Add(credential); @@ -50,18 +78,101 @@ public static class LastPassImporter } /// - /// Normalizes URL values from LastPass CSV format. - /// LastPass uses "http://sn" for secure notes and "http://" for entries without URLs. + /// Parses credit card data from LastPass structured notes format. /// - /// The URL from the CSV record. - /// The normalized URL or null if it's a special LastPass placeholder. - private static string? NormalizeUrl(string? url) + private static ImportedCreditcard ParseCreditcardFromNotes(string notes) { - if (string.IsNullOrWhiteSpace(url) || url == "http://" || url == "http://sn") + var creditcard = new ImportedCreditcard(); + var lines = notes.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) { - return null; + var colonIndex = line.IndexOf(':'); + if (colonIndex <= 0) + { + continue; + } + + var key = line[..colonIndex].Trim(); + var value = line[(colonIndex + 1)..].Trim(); + + if (string.IsNullOrEmpty(value)) + { + continue; + } + + switch (key) + { + case "Name on Card": + creditcard.CardholderName = value; + break; + case "Number": + creditcard.Number = value; + break; + case "Security Code": + creditcard.Cvv = value; + break; + case "Expiration Date": + // LastPass format: "May,2028" + var parts = value.Split(','); + if (parts.Length == 2) + { + creditcard.ExpiryMonth = parts[0].Trim().ToLowerInvariant() switch + { + "january" => "01", + "february" => "02", + "march" => "03", + "april" => "04", + "may" => "05", + "june" => "06", + "july" => "07", + "august" => "08", + "september" => "09", + "october" => "10", + "november" => "11", + "december" => "12", + _ => null, + }; + creditcard.ExpiryYear = parts[1].Trim(); + } + + break; + } } - return url; + return creditcard; + } + + /// + /// Extracts the actual notes from a LastPass credit card structured note. + /// The notes section is after the "Notes:" line. + /// + private static string? ExtractNotesFromCreditcard(string structuredNotes) + { + var lines = structuredNotes.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + var foundNotesSection = false; + var noteLines = new List(); + + foreach (var line in lines) + { + if (line.StartsWith("Notes:")) + { + foundNotesSection = true; + var noteValue = line["Notes:".Length..].Trim(); + if (!string.IsNullOrEmpty(noteValue)) + { + noteLines.Add(noteValue); + } + + continue; + } + + if (foundNotesSection) + { + noteLines.Add(line); + } + } + + return noteLines.Count > 0 ? string.Join(Environment.NewLine, noteLines) : null; } } diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs index fd78bc554..b57238122 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/OnePasswordImporter.cs @@ -34,7 +34,8 @@ public static class OnePasswordImporter Username = record.Username, Password = record.Password, TwoFactorSecret = record.OTPAuth, - Notes = record.Notes + Notes = record.Notes, + FolderPath = string.IsNullOrWhiteSpace(record.Tags) ? null : record.Tags, }; credentials.Add(credential); diff --git a/apps/server/Utilities/AliasVault.ImportExport/Importers/ProtonPassImporter.cs b/apps/server/Utilities/AliasVault.ImportExport/Importers/ProtonPassImporter.cs index 7d5a1dd50..98f93a779 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Importers/ProtonPassImporter.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Importers/ProtonPassImporter.cs @@ -36,6 +36,8 @@ public static class ProtonPassImporter Password = record.Password, Notes = record.Note, TwoFactorSecret = record.Totp, + FolderPath = string.IsNullOrWhiteSpace(record.Vault) ? null : record.Vault, + ItemType = MapProtonPassType(record.Type), }; credentials.Add(credential); @@ -43,4 +45,25 @@ public static class ProtonPassImporter return credentials; } + + /// + /// Maps ProtonPass type values to ImportedItemType. + /// ProtonPass types: login, note, alias, creditCard. + /// + private static ImportedItemType? MapProtonPassType(string? protonPassType) + { + if (string.IsNullOrWhiteSpace(protonPassType)) + { + return null; + } + + return protonPassType.ToLowerInvariant() switch + { + "login" => ImportedItemType.Login, + "note" => ImportedItemType.Note, + "alias" => ImportedItemType.Login, // ProtonPass alias is email alias, not identity alias + "creditcard" => ImportedItemType.Creditcard, + _ => ImportedItemType.Login, + }; + } } diff --git a/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCredential.cs b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCredential.cs index efc915290..a06fa1262 100644 --- a/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCredential.cs +++ b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCredential.cs @@ -68,4 +68,22 @@ public class ImportedCredential /// Gets or sets the alias information. /// public ImportedAlias? Alias { get; set; } + + /// + /// Gets or sets the folder path from the source (e.g., "Business" or "Personal/Work"). + /// For multi-level paths, the deepest folder will be used during import. + /// + public string? FolderPath { get; set; } + + /// + /// Gets or sets the item type. Each importer is responsible for setting this based on the source data. + /// If null, defaults to Login or Alias (if alias data is present). + /// + public ImportedItemType? ItemType { get; set; } + + /// + /// Gets or sets credit card information if the item is a credit card type. + /// Each importer should populate this from its own format. + /// + public ImportedCreditcard? Creditcard { get; set; } } diff --git a/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCreditcard.cs b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCreditcard.cs new file mode 100644 index 000000000..3dd8f2421 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedCreditcard.cs @@ -0,0 +1,45 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.ImportExport.Models; + +/// +/// Represents credit card information in an intermediary format that is imported from various sources. +/// Each importer is responsible for populating this from its own format. +/// +public class ImportedCreditcard +{ + /// + /// Gets or sets the cardholder name. + /// + public string? CardholderName { get; set; } + + /// + /// Gets or sets the card number. + /// + public string? Number { get; set; } + + /// + /// Gets or sets the expiry month (01-12). + /// + public string? ExpiryMonth { get; set; } + + /// + /// Gets or sets the expiry year (e.g., "2028"). + /// + public string? ExpiryYear { get; set; } + + /// + /// Gets or sets the CVV/security code. + /// + public string? Cvv { get; set; } + + /// + /// Gets or sets the PIN. + /// + public string? Pin { get; set; } +} diff --git a/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedItemType.cs b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedItemType.cs new file mode 100644 index 000000000..474f3c0a1 --- /dev/null +++ b/apps/server/Utilities/AliasVault.ImportExport/Models/ImportedItemType.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) aliasvault. All rights reserved. +// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. +// +//----------------------------------------------------------------------- + +namespace AliasVault.ImportExport.Models; + +/// +/// Represents the type of item being imported from a password manager. +/// Each importer is responsible for mapping its source types to these values. +/// +public enum ImportedItemType +{ + /// + /// Standard login credentials (username/password). + /// + Login, + + /// + /// Secure note without login credentials. + /// + Note, + + /// + /// Credit card information. + /// + Creditcard, + + /// + /// Identity/alias information (name, birthdate, etc). + /// + Alias, +} From 5dbff6cf1d9e7f9749bc0a0d959743daacf2b48d Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 24 Jan 2026 23:13:37 +0100 Subject: [PATCH 2/8] Update tests (#1481) --- .../Tests/Client/Shard3/ImportTests.cs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard3/ImportTests.cs b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard3/ImportTests.cs index e52a3a0eb..1fdf429e2 100644 --- a/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard3/ImportTests.cs +++ b/apps/server/Tests/AliasVault.E2ETests/Tests/Client/Shard3/ImportTests.cs @@ -59,12 +59,29 @@ public class ImportTests : ClientPlaywrightTest await NavigateUsingBlazorRouter("items"); await WaitForUrlAsync("items", "Find all of your items"); - // Verify that expected items from the Bitwarden CSV are present. + // Verify root-level items (items without a folder) are present. var pageContent = await Page.TextContentAsync("body"); Assert.Multiple(() => { - Assert.That(pageContent, Does.Contain("TutaNota"), "TutaNota item not imported"); - Assert.That(pageContent, Does.Contain("Aliasvault.net"), "Aliasvault.net item not imported"); + Assert.That(pageContent, Does.Contain("Test"), "Test item not imported at root level"); + Assert.That(pageContent, Does.Not.Contain("TutaNota"), "TutaNota should be in Business folder, not at root"); + Assert.That(pageContent, Does.Not.Contain("Aliasvault.net"), "Aliasvault.net should be in Business folder, not at root"); + }); + + // Verify the Business folder was created. + Assert.That(pageContent, Does.Contain("Business"), "Business folder not created"); + + // Navigate to the Business folder by clicking on it. + await Page.ClickAsync("text=Business"); + await Page.WaitForSelectorAsync("text=Item for business folder"); + + // Verify items in the Business folder are present. + var folderPageContent = await Page.TextContentAsync("body"); + Assert.Multiple(() => + { + Assert.That(folderPageContent, Does.Contain("Item for business folder"), "Item for business folder not imported"); + Assert.That(folderPageContent, Does.Contain("TutaNota"), "TutaNota item not imported in Business folder"); + Assert.That(folderPageContent, Does.Contain("Aliasvault.net"), "Aliasvault.net item not imported in Business folder"); }); } } From 9734589175ce83cdf9502f7ae643f0b6140d83f5 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 24 Jan 2026 23:13:47 +0100 Subject: [PATCH 3/8] Update folder pill UI for light mode --- .../Main/Components/Folders/FolderPill.razor | 2 +- apps/server/AliasVault.Client/wwwroot/css/tailwind.css | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/AliasVault.Client/Main/Components/Folders/FolderPill.razor b/apps/server/AliasVault.Client/Main/Components/Folders/FolderPill.razor index 2446f9061..f3e21e2fa 100644 --- a/apps/server/AliasVault.Client/Main/Components/Folders/FolderPill.razor +++ b/apps/server/AliasVault.Client/Main/Components/Folders/FolderPill.razor @@ -1,7 +1,7 @@ @using AliasVault.Client.Main.Models @* FolderPill component - displays a folder as a compact clickable pill *@ -
@@ -684,15 +681,6 @@ return (int)CurrentStep * 100 / (Enum.GetValues(typeof(ImportStep)).Length - 1); } - private int GetFaviconProgressPercentage() - { - if (TotalFaviconsToExtract == 0) { - return 0; - } - - return (FaviconExtractionProgress * 100) / TotalFaviconsToExtract; - } - private void CancelFaviconExtraction() { FaviconExtractionCancellation?.Cancel(); From ee5cd0b6d9a08d716aab1130e98a1f94d50057d8 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 25 Jan 2026 18:36:33 +0100 Subject: [PATCH 7/8] Remove deleted items button on browser extension root for better OOBE (#1481) --- .../popup/pages/items/ItemsList.tsx | 55 ------------------- 1 file changed, 55 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 89f51ec04..3895a0b68 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -657,32 +657,6 @@ const ItemsList: React.FC = () => { {t('items.welcomeDescription')}

- {/* Show Recently Deleted even when vault is empty */} - {recentlyDeletedCount > 0 && ( - - )} ) : filteredItems.length === 0 && folders.length === 0 ? (
@@ -829,35 +803,6 @@ const ItemsList: React.FC = () => { )}
)} - - {/* Recently Deleted link (only show at root level when not searching and not filtering) */} - {!currentFolderId && !searchTerm && filterType === 'all' && ( - - )} )} From de5926dc6e8259336ebd48ec4b2b8cd89e071eee Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 25 Jan 2026 18:54:14 +0100 Subject: [PATCH 8/8] Tweak OOBE when all items are in folders on all clients (#1481) --- .../popup/pages/items/ItemsList.tsx | 86 ++++-- .../src/i18n/locales/en.json | 1 + .../app/(tabs)/items/folder/[id].tsx | 278 ++++++++++++++++-- apps/mobile-app/app/(tabs)/items/index.tsx | 127 ++++---- apps/mobile-app/i18n/locales/en.json | 1 + .../Main/Pages/Items/Home.razor | 39 ++- .../Resources/Pages/Main/Items/Home.en.resx | 4 + 7 files changed, 437 insertions(+), 99 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 3895a0b68..81aaf8dfc 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -478,6 +478,12 @@ const ItemsList: React.FC = () => { const folders = getFoldersWithCounts(); + /** + * Check if all items are in folders (no items at root level but items exist in folders). + * This is used to show a helpful message when the user has imported credentials that were all in folders. + */ + const hasItemsInFoldersOnly = items.length > 0 && items.every((item: Item) => item.FolderId !== null); + if (isLoading) { return (
@@ -490,28 +496,35 @@ const ItemsList: React.FC = () => {
- +

+ {getFilterTitle()} + + ({filteredItems.length}) + +

+ + + + + )} {/* Edit and Delete buttons when inside a folder */} {currentFolderId && (
@@ -536,7 +549,7 @@ const ItemsList: React.FC = () => {
)} - {showFilterMenu && ( + {showFilterMenu && !(hasItemsInFoldersOnly && !currentFolderId) && ( <>
{

- ) : filteredItems.length === 0 && folders.length === 0 ? ( + ) : filteredItems.length === 0 && folders.length === 0 && !hasItemsInFoldersOnly ? (
{/* Show filter/search-specific messages only when actively filtering or searching */} {(filterType !== 'all' || searchTerm) && ( @@ -720,6 +733,35 @@ const ItemsList: React.FC = () => {

)}
+ ) : hasItemsInFoldersOnly && filteredItems.length === 0 && !currentFolderId && !searchTerm ? ( + /* Show message when all items are in folders and we're at root level */ + <> + {/* Folders as inline pills */} +
+ {folders.map(folder => ( + handleFolderClick(folder.id, folder.name)} + /> + ))} + +
+
+

{t('items.allItemsInFolders')}

+
+ ) : ( <> {/* Folders as inline pills (only show at root level when not searching) */} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 6a158403a..ba72e066f 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -194,6 +194,7 @@ "clearSearch": "Clear search", "clearFilter": "Clear filter", "emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and tap the folder icon in the name field.", + "allItemsInFolders": "All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.", "deleteFolder": "Delete Folder", "deleteFolderKeepItems": "Delete folder only", "deleteFolderKeepItemsDescription": "Items will be moved back to the main list.", diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index 51db1562d..2bbb14609 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -9,8 +9,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import type { Folder } from '@/utils/db/repositories/FolderRepository'; -import type { Item } from '@/utils/dist/core/models/vault'; -import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; +import type { Item, ItemType } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; @@ -31,6 +31,37 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; import { useDialog } from '@/context/DialogContext'; +/** + * Filter types for the items list. + */ +type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType; + +/** + * Check if a filter is an item type filter. + */ +const isItemTypeFilter = (filter: FilterType): filter is ItemType => { + return Object.values(ItemTypes).includes(filter as ItemType); +}; + +/** + * Item type filter option configuration. + */ +type ItemTypeOption = { + type: ItemType; + titleKey: string; + iconName: keyof typeof MaterialIcons.glyphMap; +}; + +/** + * Available item type filter options with icons. + */ +const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [ + { type: ItemTypes.Login, titleKey: 'itemTypes.login.title', iconName: 'key' }, + { type: ItemTypes.Alias, titleKey: 'itemTypes.alias.title', iconName: 'person' }, + { type: ItemTypes.CreditCard, titleKey: 'itemTypes.creditCard.title', iconName: 'credit-card' }, + { type: ItemTypes.Note, titleKey: 'itemTypes.note.title', iconName: 'description' }, +]; + /** * Folder view screen - displays items within a specific folder. * Simplified view with search scoped to this folder only. @@ -47,12 +78,15 @@ export default function FolderViewScreen(): React.ReactNode { const [itemsList, setItemsList] = useState([]); const [folder, setFolder] = useState(null); - const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200); + // No minimum loading delay for folder view since data is already in memory + const [isLoadingItems, setIsLoadingItems] = useState(false); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); const { executeVaultMutation } = useVaultMutate(); - // Search state (scoped to this folder) + // Search and filter state (scoped to this folder) const [searchQuery, setSearchQuery] = useState(''); + const [filterType, setFilterType] = useState('all'); + const [showFilterMenu, setShowFilterMenu] = useState(false); // Folder modals const [showEditFolderModal, setShowEditFolderModal] = useState(false); @@ -66,16 +100,55 @@ export default function FolderViewScreen(): React.ReactNode { const isDatabaseAvailable = dbContext.dbAvailable; /** - * Filter items by search query (within this folder only). + * Get the title based on the active filter. + * Shows "Items" for 'all' filter since folder name is already in the navigation header. + */ + const getFilterTitle = useCallback((): string => { + switch (filterType) { + case 'passkeys': + return t('items.filters.passkeys'); + case 'attachments': + return t('common.attachments'); + case 'all': + return t('items.title'); + default: + if (isItemTypeFilter(filterType)) { + const itemTypeOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === filterType); + if (itemTypeOption) { + return t(itemTypeOption.titleKey); + } + } + return t('items.title'); + } + }, [filterType, t]); + + /** + * Filter items by search query and type (within this folder only). */ const filteredItems = useMemo(() => { - const searchLower = searchQuery.toLowerCase().trim(); - - if (!searchLower) { - return itemsList; - } - return itemsList.filter(item => { + // Apply type filter + let passesTypeFilter = true; + + if (filterType === 'passkeys') { + passesTypeFilter = item.HasPasskey === true; + } else if (filterType === 'attachments') { + passesTypeFilter = item.HasAttachment === true; + } else if (isItemTypeFilter(filterType)) { + passesTypeFilter = item.ItemType === filterType; + } + + if (!passesTypeFilter) { + return false; + } + + // Apply search filter + const searchLower = searchQuery.toLowerCase().trim(); + + if (!searchLower) { + return true; + } + const searchableFields = [ item.Name?.toLowerCase() || '', getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '', @@ -90,7 +163,7 @@ export default function FolderViewScreen(): React.ReactNode { searchableFields.some(field => field.includes(word)) ); }); - }, [itemsList, searchQuery]); + }, [itemsList, searchQuery, filterType]); /** * Load items in this folder and folder details. @@ -368,14 +441,61 @@ export default function FolderViewScreen(): React.ReactNode { color: colors.textMuted, fontSize: 20, }, - // Item count styles - itemCountContainer: { - marginBottom: 12, + // Filter button styles + filterButton: { + alignItems: 'center', + flexDirection: 'row', + marginBottom: 16, + gap: 8, }, - itemCountText: { + filterButtonText: { + color: colors.text, + fontSize: 22, + fontWeight: 'bold', + lineHeight: 28, + }, + filterCount: { color: colors.textMuted, + fontSize: 16, + lineHeight: 22, + }, + // Filter menu styles + filterMenu: { + backgroundColor: colors.accentBackground, + borderColor: colors.accentBorder, + borderRadius: 8, + borderWidth: 1, + marginBottom: 8, + overflow: 'hidden', + }, + filterMenuItem: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + filterMenuItemWithIcon: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + }, + filterMenuItemIcon: { + width: 18, + }, + filterMenuItemActive: { + backgroundColor: colors.primary + '20', + }, + filterMenuItemText: { + color: colors.text, fontSize: 14, }, + filterMenuItemTextActive: { + color: colors.primary, + fontWeight: '600', + }, + filterMenuSeparator: { + backgroundColor: colors.accentBorder, + height: 1, + marginVertical: 4, + }, // Empty state styles emptyText: { color: colors.textMuted, @@ -420,11 +540,135 @@ export default function FolderViewScreen(): React.ReactNode { }); /** - * Render the list header with search. + * Render the filter menu. + */ + const renderFilterMenu = (): React.ReactNode => { + if (!showFilterMenu) { + return null; + } + + return ( + + {/* All items filter */} + { + setFilterType('all'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.all')} + + + + + + {/* Item type filters */} + {ITEM_TYPE_OPTIONS.map((option) => ( + { + setFilterType(option.type); + setShowFilterMenu(false); + }} + > + + + {t(option.titleKey)} + + + ))} + + + + {/* Passkeys filter */} + { + setFilterType('passkeys'); + setShowFilterMenu(false); + }} + > + + {t('items.filters.passkeys')} + + + + {/* Attachments filter */} + { + setFilterType('attachments'); + setShowFilterMenu(false); + }} + > + + {t('common.attachments')} + + + + ); + }; + + /** + * Render the list header with filter and search. */ const renderListHeader = (): React.ReactNode => { return ( + {/* Filter button */} + setShowFilterMenu(!showFilterMenu)} + > + + {getFilterTitle()} + + + ({filteredItems.length}) + + + + + {/* Filter menu */} + {renderFilterMenu()} + {/* Search input */} { + return itemsList.length > 0 && itemsList.every((item: Item) => item.FolderId !== null); + }, [itemsList]); + /** * Filter items by folder, type, and search query. */ @@ -251,6 +259,8 @@ export default function ItemsScreen(): React.ReactNode { const tabPressSub = emitter.addListener('tabPress', (routeName: string) => { if (routeName === 'items' && isTabFocused) { + // Reset search and scroll to top when tapping the tab again + setSearchQuery(''); flatListRef.current?.scrollToOffset({ offset: 0, animated: true }); } }); @@ -358,6 +368,17 @@ export default function ItemsScreen(): React.ReactNode { */ headerTitle: (): React.ReactNode => { if (Platform.OS === 'android') { + // When all items are in folders, show simple title without dropdown + if (hasItemsInFoldersOnly) { + return ( + + ); + } return ( {t('items.title')}; }, }); - }, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu]); + }, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu, hasItemsInFoldersOnly]); /** * Delete an item (move to trash). @@ -789,7 +810,7 @@ export default function ItemsScreen(): React.ReactNode { * Render the Android filter menu as an absolute overlay. */ const renderAndroidFilterOverlay = (): React.ReactNode => { - if (Platform.OS !== 'android' || !showFilterMenu) { + if (Platform.OS !== 'android' || !showFilterMenu || hasItemsInFoldersOnly) { return null; } @@ -927,60 +948,38 @@ export default function ItemsScreen(): React.ReactNode { {/* Large header with logo (iOS only) */} {Platform.OS === 'ios' && ( - setShowFilterMenu(!showFilterMenu)} - > - - - {getFilterTitle()} - - - ({filteredItems.length}) - - - - )} - - {/* Filter menu (iOS only - Android uses absolute overlay) */} - {Platform.OS === 'ios' && renderFilterMenu()} - - {/* Folder pills */} - {foldersWithCounts.length > 0 && ( - - {foldersWithCounts.map((folder) => ( - handleFolderClick(folder.id)} + hasItemsInFoldersOnly ? ( + /* When all items are in folders, show simple title without dropdown */ + + + + {t('items.title')} + + + ) : ( + /* Normal filter dropdown when there are items at root */ + setShowFilterMenu(!showFilterMenu)} + > + + + {getFilterTitle()} + + + ({filteredItems.length}) + + - ))} - setShowFolderModal(true)} - > - - {t('items.folders.newFolder')} - + ) )} - {/* New folder button when no folders exist */} - {foldersWithCounts.length === 0 && !searchQuery && ( - - setShowFolderModal(true)} - > - - {t('items.folders.newFolder')} - - - )} + {/* Filter menu (iOS only - Android uses absolute overlay, only when not all items in folders) */} + {Platform.OS === 'ios' && !hasItemsInFoldersOnly && renderFilterMenu()} {/* Search input */} @@ -1013,6 +1012,26 @@ export default function ItemsScreen(): React.ReactNode { )} + + {/* Folder pills (shown below search when not searching) */} + {!searchQuery && ( + + {foldersWithCounts.map((folder) => ( + handleFolderClick(folder.id)} + /> + ))} + setShowFolderModal(true)} + > + + {t('items.folders.newFolder')} + + + )} ); }; @@ -1047,6 +1066,10 @@ export default function ItemsScreen(): React.ReactNode { if (isItemTypeFilter(filterType)) { return t('items.noItemsOfTypeFound', { type: getFilterTitle() }); } + // All items are in folders - show helpful message + if (hasItemsInFoldersOnly) { + return t('items.allItemsInFolders'); + } // No search, no filter - truly empty vault return t('items.noItemsFound'); }; diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 4348779ce..ae18dd054 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -419,6 +419,7 @@ "noMatchingItemsSearch": "No items matching \"{{search}}\"", "noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"", "noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.", + "allItemsInFolders": "All your items are organized in folders. Tap a folder above to view your credentials, or use the search to find specific items.", "noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.", "noAttachmentsFound": "No items with attachments found", "noItemsOfTypeFound": "No {{type}} items found", diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor index 5c02837e2..6366bbec6 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/Home.razor @@ -19,15 +19,24 @@ Description="@Localizer["PageDescription"]">
- + } + else + { +

+ @GetFilterTitle()

- - - - + } @if (IsInFolder) {