mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 16:32:20 -04:00
Tweak ItemAddEdit UX and email preview (#1404)
This commit is contained in:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`));
|
||||
};
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -236,6 +236,7 @@
|
||||
"generateRandomAlias": "Generate Random Alias",
|
||||
"clearAliasFields": "Clear Alias Fields",
|
||||
"alias": "Alias",
|
||||
"email": "Email",
|
||||
"addEmail": "+ Email",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
|
||||
Reference in New Issue
Block a user