From 254901cfcff9f10f6a4b9ef6d01eebd5ef17adda Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sun, 21 Dec 2025 15:44:46 +0100 Subject: [PATCH] Tweak removable section UI in web app (#1404) --- .../popup/pages/credentials/ItemAddEdit.tsx | 46 +++- .../Forms/EditPasswordFormRow.razor | 44 +++- .../Components/Forms/RemovableSection.razor | 55 +++++ .../Main/Components/TotpCodes/TotpCodes.razor | 31 ++- .../Main/Pages/Items/AddEdit.razor | 213 +++++++++++++----- 5 files changed, 318 insertions(+), 71 deletions(-) create mode 100644 apps/server/AliasVault.Client/Main/Components/Forms/RemovableSection.razor diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx index 86a0d69a8..fa511c862 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -30,7 +30,7 @@ import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; import { SKIP_FORM_RESTORE_KEY } from '@/utils/Constants'; import type { Item, ItemField, ItemType, FieldType, Attachment, TotpCode } from '@/utils/dist/core/models/vault'; -import { FieldCategories, FieldTypes, ItemTypes, getSystemFieldsForItemType, getOptionalFieldsForItemType, isFieldShownByDefault } from '@/utils/dist/core/models/vault'; +import { FieldCategories, FieldTypes, ItemTypes, getSystemFieldsForItemType, getOptionalFieldsForItemType, isFieldShownByDefault, getSystemField, fieldAppliesToType } from '@/utils/dist/core/models/vault'; import { FaviconService } from '@/utils/FaviconService'; import { browser } from '#imports'; @@ -758,26 +758,58 @@ const ItemAddEdit: React.FC = () => { /** * Handle item type change from dropdown. + * Clears field values that don't apply to the new item type. */ const handleTypeChange = useCallback((newType: ItemType) => { if (!item) { return; } - // When switching FROM Alias type to another type, clear alias and login fields (except URL) - if (!isEditMode && item.ItemType === ItemTypes.Alias && newType !== ItemTypes.Alias) { + const oldType = item.ItemType; + + // Clear field values that don't apply to the new type + if (!isEditMode && oldType !== newType) { setFieldValues(prev => { const newValues: Record = {}; - // Only preserve non-alias and non-login fields, plus login.url Object.entries(prev).forEach(([key, value]) => { - if (key === 'login.url') { - newValues[key] = value; - } else if (!key.startsWith('alias.') && !key.startsWith('login.')) { + // Check if this field applies to the new type + const systemField = getSystemField(key); + if (systemField) { + // Keep the field only if it applies to the new type + if (fieldAppliesToType(systemField, newType)) { + newValues[key] = value; + } + } else { + // Custom fields are always kept newValues[key] = value; } }); return newValues; }); + + // Clear manually added fields that don't apply to new type + setManuallyAddedFields(prev => { + const newSet = new Set(); + prev.forEach(fieldKey => { + const systemField = getSystemField(fieldKey); + if (!systemField || fieldAppliesToType(systemField, newType)) { + newSet.add(fieldKey); + } + }); + return newSet; + }); + + // Clear initially visible fields that don't apply to new type + setInitiallyVisibleFields(prev => { + const newSet = new Set(); + prev.forEach(fieldKey => { + const systemField = getSystemField(fieldKey); + if (!systemField || fieldAppliesToType(systemField, newType)) { + newSet.add(fieldKey); + } + }); + return newSet; + }); } // Reset alias generated flag, so alias fields will be filled (again) if they are shown by the new type diff --git a/apps/server/AliasVault.Client/Main/Components/Forms/EditPasswordFormRow.razor b/apps/server/AliasVault.Client/Main/Components/Forms/EditPasswordFormRow.razor index 28a0dd368..263310269 100644 --- a/apps/server/AliasVault.Client/Main/Components/Forms/EditPasswordFormRow.razor +++ b/apps/server/AliasVault.Client/Main/Components/Forms/EditPasswordFormRow.razor @@ -7,7 +7,7 @@
- - - + @if (ShowGenerateButtons) + { + + + }
@@ -80,6 +83,13 @@ [Parameter] public bool ShowPassword { get; set; } = false; + /// + /// Controls whether the generate and settings buttons are shown. + /// Set to false for fields like CVV or card number that shouldn't be generated. + /// + [Parameter] + public bool ShowGenerateButtons { get; set; } = true; + /// /// Whether the password settings popup is visible. /// @@ -120,6 +130,16 @@ } } + /// + /// Gets the CSS classes for the visibility toggle button. + /// When generate buttons are hidden, this button needs rounded-r-lg to be the last button. + /// + private string GetVisibilityButtonClasses() + { + var baseClasses = "px-3 text-gray-500 dark:text-white bg-gray-200 hover:bg-gray-300 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium text-sm dark:bg-gray-600 dark:hover:bg-gray-700 dark:focus:ring-gray-800"; + return ShowGenerateButtons ? baseClasses : $"{baseClasses} rounded-r-lg"; + } + /// /// Toggles the password plain text visibility. /// diff --git a/apps/server/AliasVault.Client/Main/Components/Forms/RemovableSection.razor b/apps/server/AliasVault.Client/Main/Components/Forms/RemovableSection.razor new file mode 100644 index 000000000..d06025ed6 --- /dev/null +++ b/apps/server/AliasVault.Client/Main/Components/Forms/RemovableSection.razor @@ -0,0 +1,55 @@ +@using Microsoft.Extensions.Localization + +@inject IStringLocalizerFactory LocalizerFactory + +
+ @if (!string.IsNullOrEmpty(Title)) + { +

@Title

+ } +
+ @ChildContent +
+ @if (CanRemove) + { + + } +
+ +@code { + private IStringLocalizer SharedLocalizer => LocalizerFactory.Create("SharedResources", "AliasVault.Client"); + + /// + /// Gets or sets the section title. + /// + [Parameter] + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets whether the section can be removed. + /// + [Parameter] + public bool CanRemove { get; set; } + + /// + /// Gets or sets the child content to render inside the section. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets the callback when the remove button is clicked. + /// + [Parameter] + public EventCallback OnRemove { get; set; } + + private async Task HandleRemove() + { + await OnRemove.InvokeAsync(); + } +} diff --git a/apps/server/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor b/apps/server/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor index 9dc5fb059..ef7b3e3d3 100644 --- a/apps/server/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor +++ b/apps/server/AliasVault.Client/Main/Components/TotpCodes/TotpCodes.razor @@ -6,7 +6,7 @@ @using TotpGenerator @using Microsoft.Extensions.Localization -
+

@Localizer["TwoFactorAuthenticationTitle"]

@@ -20,6 +20,15 @@
}
+ @if (CanRemove) + { + + } @if ((TotpCodeList.Count == 0 || TotpCodeList.All(t => t.IsDeleted)) && !IsAddFormVisible) { @@ -105,6 +114,18 @@ [Parameter] public EventCallback> TotpCodesChanged { get; set; } + /// + /// Gets or sets whether the section can be removed. + /// + [Parameter] + public bool CanRemove { get; set; } + + /// + /// Gets or sets the callback when the remove button is clicked. + /// + [Parameter] + public EventCallback OnRemove { get; set; } + private bool IsAddFormVisible { get; set; } = false; private TotpCodeEdit NewTotpCode { get; set; } = new(); private List OriginalTotpCodeIds { get; set; } = []; @@ -206,4 +227,12 @@ await TotpCodesChanged.InvokeAsync(TotpCodeList); StateHasChanged(); } + + /// + /// Handles the remove section button click. + /// + private async Task HandleRemove() + { + await OnRemove.InvokeAsync(); + } } diff --git a/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor b/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor index 556d80272..18df37104 100644 --- a/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor +++ b/apps/server/AliasVault.Client/Main/Pages/Items/AddEdit.razor @@ -6,6 +6,7 @@ @inject AliasVault.Client.Services.QuickCreateStateService QuickCreateStateService @using AliasVault.Client.Services.JsInterop.Models @using AliasVault.Client.Main.Components.Items +@using AliasVault.Client.Main.Components.Forms @using Microsoft.Extensions.Localization @using AliasClientDb @using AliasClientDb.Models @@ -65,7 +66,7 @@ else @if (Show2FA && HasLoginFields()) {
- +
} @@ -73,22 +74,11 @@ else @if (ShouldShowField(FieldKey.NotesContent)) {
-
-
-
- -
+ +
+
- @if (CanRemoveField(FieldKey.NotesContent)) - { - - } -
+
} @@ -96,16 +86,13 @@ else @if (ShowAttachments) {
-
-

@Localizer["AttachmentsSectionHeader"]

-
-
- -
+ +
+
-
+
}
@@ -300,7 +287,7 @@ else
- +
@@ -309,7 +296,7 @@ else
- +
@if (ShouldShowField(FieldKey.CardPin)) { @@ -379,8 +366,8 @@ else HasLoginFields="@HasLoginFields()" OnAddSystemField="AddOptionalField" OnAddCustomField="AddCustomField" - OnAdd2FA="() => Show2FA = true" - OnAddAttachments="() => ShowAttachments = true" /> + OnAdd2FA="Add2FASection" + OnAddAttachments="AddAttachmentsSection" />
@@ -405,6 +392,8 @@ else private bool ShowTypeDropdown { get; set; } = false; private bool Show2FA { get; set; } = false; private bool ShowAttachments { get; set; } = false; + private bool Show2FAAddedManually { get; set; } = false; + private bool ShowAttachmentsAddedManually { get; set; } = false; private ItemEdit Obj { get; set; } = new(); private IJSObjectReference? Module; @@ -560,12 +549,20 @@ else } /// - /// Determines if a field can be removed (was manually added). + /// Determines if a field can be removed (is an optional field). /// private bool CanRemoveField(string fieldKey) { - // A field can be removed if it was manually added via the + menu - return ManuallyAddedFields.Contains(fieldKey); + // A field can be removed if it's an optional field for the current item type + var fieldDef = SystemFieldRegistry.GetSystemField(fieldKey); + if (fieldDef == null) + { + return false; + } + + // Check if it's an optional field (not shown by default) + var config = fieldDef.ApplicableToTypes.GetValueOrDefault(Obj.ItemType); + return config != null && !config.ShowByDefault; } /// @@ -604,15 +601,128 @@ else } /// - /// Removes an optional field. + /// Removes an optional field (hides it and clears the value). /// private void RemoveOptionalField(string fieldKey) { ManuallyAddedFields.Remove(fieldKey); + InitiallyVisibleFields.Remove(fieldKey); Obj.SetFieldValue(fieldKey, string.Empty); StateHasChanged(); } + /// + /// Adds the 2FA section. + /// + private void Add2FASection() + { + Show2FA = true; + Show2FAAddedManually = true; + + // Restore any soft-deleted TOTP codes (undo previous removal) + foreach (var totpCode in Obj.TotpCodes) + { + if (totpCode.IsDeleted) + { + totpCode.IsDeleted = false; + totpCode.UpdatedAt = DateTime.UtcNow; + } + } + + StateHasChanged(); + } + + /// + /// Removes the 2FA section and marks existing TOTP codes for deletion. + /// + private void Remove2FASection() + { + Show2FA = false; + Show2FAAddedManually = false; + + // Mark all TOTP codes for deletion (soft delete for existing, remove new ones) + foreach (var totpCode in Obj.TotpCodes.ToList()) + { + if (totpCode.Id != Guid.Empty) + { + // Existing code - mark for soft delete + totpCode.IsDeleted = true; + totpCode.UpdatedAt = DateTime.UtcNow; + } + else + { + // New code - just remove from list + Obj.TotpCodes.Remove(totpCode); + } + } + + StateHasChanged(); + } + + /// + /// Checks if the 2FA section can be removed (always true when visible). + /// + private bool CanRemove2FASection() + { + return true; + } + + /// + /// Adds the attachments section. + /// + private void AddAttachmentsSection() + { + ShowAttachments = true; + ShowAttachmentsAddedManually = true; + + // Restore any soft-deleted attachments (undo previous removal) + foreach (var attachment in Obj.Attachments) + { + if (attachment.IsDeleted) + { + attachment.IsDeleted = false; + attachment.UpdatedAt = DateTime.UtcNow; + } + } + + StateHasChanged(); + } + + /// + /// Removes the attachments section and marks existing attachments for deletion. + /// + private void RemoveAttachmentsSection() + { + ShowAttachments = false; + ShowAttachmentsAddedManually = false; + + // Mark all attachments for deletion (soft delete for existing, remove new ones) + foreach (var attachment in Obj.Attachments.ToList()) + { + if (attachment.Id != Guid.Empty) + { + // Existing attachment - mark for soft delete + attachment.IsDeleted = true; + attachment.UpdatedAt = DateTime.UtcNow; + } + else + { + // New attachment - just remove from list + Obj.Attachments.Remove(attachment); + } + } + + StateHasChanged(); + } + + /// + /// Checks if the attachments section can be removed (always true when visible). + /// + private bool CanRemoveAttachmentsSection() + { + return true; + } + /// /// Adds a custom field. /// @@ -652,6 +762,7 @@ else /// /// Handles item type change. + /// Clears field values that don't apply to the new item type. /// private async Task HandleItemTypeChange(string newType) { @@ -663,25 +774,13 @@ else var oldType = Obj.ItemType; Obj.ItemType = newType; - // Clear fields that don't apply to the new type - if (oldType == ItemTypes.Alias && newType != ItemTypes.Alias) + // Clear all system fields that don't apply to the new type + foreach (var fieldDef in SystemFieldRegistry.Fields.Values) { - // Clear alias fields when switching away from Alias - Obj.AliasFirstName = string.Empty; - Obj.AliasLastName = string.Empty; - Obj.AliasGender = string.Empty; - Obj.AliasBirthDate = string.Empty; - } - - if (oldType == ItemTypes.CreditCard && newType != ItemTypes.CreditCard) - { - // Clear card fields when switching away from CreditCard - Obj.CardNumber = string.Empty; - Obj.CardCardholderName = string.Empty; - Obj.CardExpiryMonth = string.Empty; - Obj.CardExpiryYear = string.Empty; - Obj.CardCvv = string.Empty; - Obj.CardPin = string.Empty; + if (!SystemFieldRegistry.FieldAppliesToType(fieldDef, newType)) + { + Obj.SetFieldValue(fieldDef.FieldKey, string.Empty); + } } // Clear manually added fields that don't apply to new type @@ -696,6 +795,18 @@ else ManuallyAddedFields.Remove(fk); } + // Clear initially visible fields that don't apply to new type + var initialFieldsToRemove = InitiallyVisibleFields + .Where(fk => { + var def = SystemFieldRegistry.GetSystemField(fk); + return def != null && !SystemFieldRegistry.FieldAppliesToType(def, newType); + }) + .ToList(); + foreach (var fk in initialFieldsToRemove) + { + InitiallyVisibleFields.Remove(fk); + } + // Generate alias for Alias type in create mode if (newType == ItemTypes.Alias && !EditMode && !HasAliasValues()) {