Tweak ItemAddEdit UX and email preview (#1404)

This commit is contained in:
Leendert de Borst
2025-12-13 18:51:12 +01:00
parent dc2a72adf7
commit 6dfedf7da4
8 changed files with 116 additions and 89 deletions

View File

@@ -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<EmailBlockProps> = ({ email }) => {
const dbContext = useDb();
/**
* Check if the email domain is supported.
*/
const isEmailDomainSupported = async (email: string): Promise<boolean> => {
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 (
<>
{<EmailPreview email={email} />}
</>
);
};
export default EmailBlock;

View File

@@ -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,

View File

@@ -56,7 +56,7 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
*/
const isPublicDomain = async (emailAddress: string): Promise<boolean> => {
// 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<EmailPreviewProps> = ({ email }) => {
*/
const isPrivateDomain = async (emailAddress: string): Promise<boolean> => {
// 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()}`));
};

View File

@@ -87,11 +87,15 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
// 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<EmailDomainFieldProps> = ({
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<EmailDomainFieldProps> = ({
// 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<HTMLInputElement>) => {
const newLocalPart = e.target.value;
@@ -234,10 +264,42 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor={id} className="block font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="flex items-center">
{/* Tab-style label switcher for defaultToFreeText mode */}
{defaultToFreeText ? (
<div className="flex items-center">
<button
type="button"
onClick={isCustomDomain ? undefined : toggleCustomDomain}
className={`font-medium transition-colors ${
isCustomDomain
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 cursor-pointer'
}`}
>
{t('credentials.email')}
</button>
<span className="mx-1.5 text-gray-400 dark:text-gray-500">/</span>
<button
type="button"
onClick={!isCustomDomain ? undefined : handleGenerateAliasClick}
className={`font-medium transition-colors ${
!isCustomDomain
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 cursor-pointer'
}`}
>
{t('credentials.alias')}
</button>
{required && <span className="text-red-500 ml-1">*</span>}
</div>
) : (
<label htmlFor={id} className="block font-medium text-gray-700 dark:text-gray-300">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
</div>
{onRemove && (
<button
type="button"
@@ -345,18 +407,18 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
)}
</div>
{/* Toggle custom domain button */}
<div>
<button
type="button"
onClick={isCustomDomain && defaultToFreeText ? handleGenerateAliasClick : toggleCustomDomain}
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
{isCustomDomain
? (defaultToFreeText ? t('credentials.generateAliasEmail') : t('credentials.useDomainChooser'))
: (defaultToFreeText ? t('credentials.enterNormalEmail') : t('credentials.enterCustomDomain'))}
</button>
</div>
{/* Toggle custom domain button - only show for non-defaultToFreeText mode */}
{!defaultToFreeText && (
<div>
<button
type="button"
onClick={toggleCustomDomain}
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
{isCustomDomain ? t('credentials.useDomainChooser') : t('credentials.enterCustomDomain')}
</button>
</div>
)}
{/* Error message */}
{error && (

View File

@@ -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 => {
<div className="flex justify-between items-center">
<HeaderBlock credential={credential} />
</div>
{credential.Alias?.Email && (
<EmailBlock
email={credential.Alias.Email}
/>
)}
<TotpBlock credentialId={credential.Id} />
<LoginCredentialsBlock credential={credential} />
<AliasBlock credential={credential} />

View File

@@ -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<Set<string>>(new Set());
// Track fields that had values initially (edit mode) - these stay visible even if value is cleared
const [initiallyVisibleFields, setInitiallyVisibleFields] = useState<Set<string>>(new Set());
// TOTP codes state
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState<string[]>([]);
@@ -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<string, string | string[]> = {};
const existingCustomFields: CustomFieldDefinition[] = [];
const fieldsWithValues = new Set<string>();
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<string, string | string[]> = {};
// 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;

View File

@@ -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 ? <EmailBlock email={email} /> : null;
return email ? <EmailPreview email={email} /> : null;
})()}
{/* TOTP codes - only for Login and Alias types, shown at top */}

View File

@@ -236,6 +236,7 @@
"generateRandomAlias": "Generate Random Alias",
"clearAliasFields": "Clear Alias Fields",
"alias": "Alias",
"email": "Email",
"addEmail": "+ Email",
"firstName": "First Name",
"lastName": "Last Name",