From 6dfedf7da490c47b02a392c67616fc58a14e6059 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 13 Dec 2025 18:51:12 +0100 Subject: [PATCH] Tweak ItemAddEdit UX and email preview (#1404) --- .../Credentials/Details/EmailBlock.tsx | 45 -------- .../components/Credentials/Details/index.tsx | 2 - .../popup/components/EmailPreview.tsx | 4 +- .../components/Forms/EmailDomainField.tsx | 106 ++++++++++++++---- .../pages/credentials/CredentialDetails.tsx | 6 - .../popup/pages/credentials/ItemAddEdit.tsx | 35 ++++-- .../popup/pages/credentials/ItemDetails.tsx | 6 +- .../src/i18n/locales/en.json | 1 + 8 files changed, 116 insertions(+), 89 deletions(-) delete mode 100644 apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/EmailBlock.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/EmailBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/EmailBlock.tsx deleted file mode 100644 index 9c05e6d61..000000000 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/EmailBlock.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; - -import { EmailPreview } from '@/entrypoints/popup/components/EmailPreview'; -import { useDb } from '@/entrypoints/popup/context/DbContext'; - -type EmailBlockProps = { - email: string; -} - -/** - * Render the email block. - */ -const EmailBlock: React.FC = ({ email }) => { - const dbContext = useDb(); - - /** - * Check if the email domain is supported. - */ - const isEmailDomainSupported = async (email: string): Promise => { - const domain = email.split('@')[1]?.toLowerCase(); - if (!domain) { - return false; - } - - const vaultMetadata = await dbContext.getVaultMetadata(); - const publicDomains = vaultMetadata?.publicEmailDomains ?? []; - const privateDomains = vaultMetadata?.privateEmailDomains ?? []; - - return [...publicDomains, ...privateDomains].some(supportedDomain => - domain === supportedDomain.toLowerCase() - ); - }; - - if (!isEmailDomainSupported(email)) { - return null; - } - - return ( - <> - {} - - ); -}; - -export default EmailBlock; \ No newline at end of file diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx index 54a55417c..508840de5 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/index.tsx @@ -1,6 +1,5 @@ import AliasBlock from './AliasBlock'; import AttachmentBlock from './AttachmentBlock'; -import EmailBlock from './EmailBlock'; import FieldBlock from './FieldBlock'; import HeaderBlock from './HeaderBlock'; import LoginCredentialsBlock from './LoginCredentialsBlock'; @@ -11,7 +10,6 @@ import TotpBlock from './TotpBlock'; export { HeaderBlock, - EmailBlock, TotpBlock, LoginCredentialsBlock, AliasBlock, diff --git a/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx b/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx index 746ee5284..faaa18ba6 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx @@ -56,7 +56,7 @@ export const EmailPreview: React.FC = ({ email }) => { */ const isPublicDomain = async (emailAddress: string): Promise => { // Get metadata from storage - const publicEmailDomains = await storage.getItem('session:publicEmailDomains') as string[] ?? []; + const publicEmailDomains = await storage.getItem('local:publicEmailDomains') as string[] ?? []; return publicEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(`@${domain.toLowerCase()}`)); }; @@ -65,7 +65,7 @@ export const EmailPreview: React.FC = ({ email }) => { */ const isPrivateDomain = async (emailAddress: string): Promise => { // Get metadata from storage - const privateEmailDomains = await storage.getItem('session:privateEmailDomains') as string[] ?? []; + const privateEmailDomains = await storage.getItem('local:privateEmailDomains') as string[] ?? []; return privateEmailDomains.some(domain => emailAddress.toLowerCase().endsWith(`@${domain.toLowerCase()}`)); }; diff --git a/apps/browser-extension/src/entrypoints/popup/components/Forms/EmailDomainField.tsx b/apps/browser-extension/src/entrypoints/popup/components/Forms/EmailDomainField.tsx index 9c1f6200c..a6d572359 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Forms/EmailDomainField.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Forms/EmailDomainField.tsx @@ -87,11 +87,15 @@ const EmailDomainField: React.FC = ({ // Initialize state from value prop useEffect(() => { if (!value) { - // Set default domain - if (showPrivateDomains && privateEmailDomains[0]) { - setSelectedDomain(privateEmailDomains[0]); - } else if (PUBLIC_EMAIL_DOMAINS[0]) { - setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]); + // Value is empty - clear local part but preserve selected domain + setLocalPart(''); + // Only set default domain if none is selected yet (initial load) + if (!selectedDomain) { + if (showPrivateDomains && privateEmailDomains[0]) { + setSelectedDomain(privateEmailDomains[0]); + } else if (PUBLIC_EMAIL_DOMAINS[0]) { + setSelectedDomain(PUBLIC_EMAIL_DOMAINS[0]); + } } return; } @@ -101,10 +105,11 @@ const EmailDomainField: React.FC = ({ setLocalPart(local); setSelectedDomain(domain); - // Check if it's a custom domain (including hidden private domains as known domains) + // Check if it's a known domain (public, private, or hidden private) const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) || privateEmailDomains.includes(domain) || hiddenPrivateEmailDomains.includes(domain); + // Switch to domain chooser mode if domain is recognized setIsCustomDomain(!isKnownDomain); } else { setLocalPart(value); @@ -122,6 +127,31 @@ const EmailDomainField: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [value, privateEmailDomains, hiddenPrivateEmailDomains, showPrivateDomains]); + /* + * Re-check domain mode when private domains finish loading. + * This handles the case where value was set before domains were loaded. + */ + useEffect(() => { + if (!value || !value.includes('@')) { + return; + } + + const domain = value.split('@')[1]; + if (!domain) { + return; + } + + // Check if the domain is now recognized after private domains loaded + const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) || + privateEmailDomains.includes(domain) || + hiddenPrivateEmailDomains.includes(domain); + + // If domain is recognized and we're in custom mode, switch to domain chooser + if (isKnownDomain && isCustomDomain) { + setIsCustomDomain(false); + } + }, [privateEmailDomains, hiddenPrivateEmailDomains, value, isCustomDomain]); + // Handle local part changes const handleLocalPartChange = useCallback((e: React.ChangeEvent) => { const newLocalPart = e.target.value; @@ -234,10 +264,42 @@ const EmailDomainField: React.FC = ({ return (
- +
+ {/* Tab-style label switcher for defaultToFreeText mode */} + {defaultToFreeText ? ( +
+ + / + + {required && *} +
+ ) : ( + + )} +
{onRemove && (
- {/* Toggle custom domain button */} -
- -
+ {/* Toggle custom domain button - only show for non-defaultToFreeText mode */} + {!defaultToFreeText && ( +
+ +
+ )} {/* Error message */} {error && ( diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx index 772b81e65..65307e585 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/CredentialDetails.tsx @@ -4,7 +4,6 @@ import { useNavigate, useParams } from 'react-router-dom'; import { HeaderBlock, - EmailBlock, TotpBlock, LoginCredentialsBlock, AliasBlock, @@ -106,11 +105,6 @@ const CredentialDetails: React.FC = (): React.ReactElement => {
- {credential.Alias?.Email && ( - - )} 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 45d481310..94f8c334b 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -126,6 +126,9 @@ const ItemAddEdit: React.FC = () => { // Track manually added optional fields (fields that are not shown by default but user added) const [manuallyAddedFields, setManuallyAddedFields] = useState>(new Set()); + // Track fields that had values initially (edit mode) - these stay visible even if value is cleared + const [initiallyVisibleFields, setInitiallyVisibleFields] = useState>(new Set()); + // TOTP codes state const [totpCodes, setTotpCodes] = useState([]); const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState([]); @@ -163,7 +166,7 @@ const ItemAddEdit: React.FC = () => { /** * Check if a field should be shown for the current item type. - * Returns true if field is shown by default OR was manually added by user. + * Returns true if field is shown by default, was manually added, or had initial value (edit mode). */ const shouldShowField = useCallback((field: { FieldKey: string }) => { if (!item) { @@ -173,8 +176,8 @@ const ItemAddEdit: React.FC = () => { if (manuallyAddedFields.has(field.FieldKey)) { return true; } - // Check if has existing value (edit mode) - if (fieldValues[field.FieldKey]) { + // Check if field was initially visible (had value when loaded in edit mode) + if (initiallyVisibleFields.has(field.FieldKey)) { return true; } const systemField = applicableSystemFields.find(f => f.FieldKey === field.FieldKey); @@ -182,7 +185,7 @@ const ItemAddEdit: React.FC = () => { return true; // Custom fields are always shown } return isFieldShownByDefault(systemField, item.ItemType); - }, [item, applicableSystemFields, manuallyAddedFields, fieldValues]); + }, [item, applicableSystemFields, manuallyAddedFields, initiallyVisibleFields]); /** * Primary fields (like URL) that should be shown in the name block. @@ -460,9 +463,12 @@ const ItemAddEdit: React.FC = () => { // Initialize field values from existing fields const initialValues: Record = {}; const existingCustomFields: CustomFieldDefinition[] = []; + const fieldsWithValues = new Set(); result.Fields.forEach(field => { initialValues[field.FieldKey] = field.Value; + // Track fields that have values so they stay visible even if cleared + fieldsWithValues.add(field.FieldKey); // If field key starts with "custom_", it's a custom field if (field.FieldKey.startsWith('custom_')) { @@ -478,6 +484,7 @@ const ItemAddEdit: React.FC = () => { setFieldValues(initialValues); setCustomFields(existingCustomFields); + setInitiallyVisibleFields(fieldsWithValues); // Load TOTP codes for this item const itemTotpCodes = dbContext.sqliteClient.getTotpCodesForItem(id); @@ -793,17 +800,27 @@ const ItemAddEdit: React.FC = () => { return; } - // In create mode, clear all field values when changing type - if (!isEditMode) { - setFieldValues({}); - setCustomFields([]); + // When switching FROM Alias type to another type, clear alias and login fields (except URL) + if (!isEditMode && item.ItemType === ItemTypes.Alias && newType !== ItemTypes.Alias) { + 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.')) { + newValues[key] = value; + } + }); + return newValues; + }); } // Check field visibility based on model config for the new type const newTypeFields = getSystemFieldsForItemType(newType); // Check if alias fields should be shown by default for the new type (for auto-generation) - const newAliasField = newTypeFields.find(f => f.FieldKey === 'alias.email'); + const newAliasField = newTypeFields.find(f => f.Category === FieldCategories.Alias); const aliasShownByDefault = newAliasField ? isFieldShownByDefault(newAliasField, newType) : false; if (aliasShownByDefault && !isEditMode) { aliasGeneratedRef.current = false; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx index a0d31802d..624c26b35 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx @@ -6,8 +6,7 @@ import { TotpBlock, AttachmentBlock, FieldBlock, - PasskeyBlock, - EmailBlock + PasskeyBlock } from '@/entrypoints/popup/components/Credentials/Details'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -20,6 +19,7 @@ import { PopoutUtility } from '@/entrypoints/popup/utils/PopoutUtility'; import type { Item } from '@/utils/dist/core/models/vault'; import { ItemTypes } from '@/utils/dist/core/models/vault'; import { groupFieldsByCategory } from '@/utils/dist/core/models/vault'; +import { EmailPreview } from '../../components/EmailPreview'; /** * Item details page with dynamic field rendering. @@ -165,7 +165,7 @@ const ItemDetails: React.FC = (): React.ReactElement => { const emailField = item.Fields.find(f => f.FieldKey === 'login.email'); const emailValue = emailField?.Value; const email = Array.isArray(emailValue) ? emailValue[0] : emailValue; - return email ? : null; + return email ? : null; })()} {/* TOTP codes - only for Login and Alias types, shown at top */} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index 687b76922..a5d6cc0d4 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -236,6 +236,7 @@ "generateRandomAlias": "Generate Random Alias", "clearAliasFields": "Clear Alias Fields", "alias": "Alias", + "email": "Email", "addEmail": "+ Email", "firstName": "First Name", "lastName": "Last Name",