From ea6fc08fc023ea2b0a46cf84369496dad35da3fb Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 26 Jan 2026 22:47:31 +0100 Subject: [PATCH] Add random prefix email generation for login type to mobile app (#1449) --- apps/mobile-app/app/(tabs)/items/add-edit.tsx | 47 ++++++- .../components/form/EmailDomainField.tsx | 120 ++++++++++-------- apps/mobile-app/i18n/locales/en.json | 1 + 3 files changed, 112 insertions(+), 56 deletions(-) diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index 58d8aa97b..863e91b5b 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -10,7 +10,7 @@ import { StyleSheet, View, Keyboard, Platform, ScrollView, KeyboardAvoidingView, import Toast from 'react-native-toast-message'; import type { Folder } from '@/utils/db/repositories/FolderRepository'; -import { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; +import { CreateIdentityGenerator, CreateUsernameEmailGenerator, UsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; import type { Attachment, Item, ItemField, TotpCode, ItemType, FieldType, PasswordSettings } from '@/utils/dist/core/models/vault'; import { ItemTypes, getSystemFieldsForItemType, getOptionalFieldsForItemType, isFieldShownByDefault, getSystemField, fieldAppliesToType, FieldCategories, FieldTypes } from '@/utils/dist/core/models/vault'; import type { FaviconExtractModel } from '@/utils/dist/core/models/webapi'; @@ -387,6 +387,48 @@ export default function AddEditItemScreen(): React.ReactNode { } }, [fieldValues, generateRandomIdentity, handleFieldChange]); + /** + * Generate an identity-based email alias (for Alias type email field). + * Uses the current alias field values (first name, last name, birthdate) to derive the email prefix, + * so the email stays consistent with the filled-in persona fields. + */ + const handleGenerateAliasEmail = useCallback(async () => { + const firstName = (fieldValues['alias.first_name'] as string) || ''; + const lastName = (fieldValues['alias.last_name'] as string) || ''; + const gender = (fieldValues['alias.gender'] as string) || Gender.Other; + const birthdate = (fieldValues['alias.birthdate'] as string) || ''; + + const generator = new UsernameEmailGenerator(); + const prefix = generator.generateEmailPrefix({ + firstName, + lastName, + gender: gender as Gender, + birthDate: birthdate ? new Date(birthdate) : new Date(), + emailPrefix: '', + nickName: '' + }); + + const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); + const email = defaultEmailDomain ? `${prefix}@${defaultEmailDomain}` : prefix; + + handleFieldChange('login.email', email); + }, [fieldValues, dbContext.sqliteClient, handleFieldChange]); + + /** + * Generate a random-string email alias (for Login type email field). + * Uses random characters instead of identity-based prefixes since Login type + * has no persona fields to base the email on. + */ + const handleGenerateRandomEmail = useCallback(async () => { + const generator = new UsernameEmailGenerator(); + const prefix = generator.generateRandomEmailPrefix(); + + const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); + const email = defaultEmailDomain ? `${prefix}@${defaultEmailDomain}` : prefix; + + handleFieldChange('login.email', email); + }, [dbContext.sqliteClient, handleFieldChange]); + /** * Prevent accidental dismissal when there are unsaved changes. * Shows custom dialog on Android, stores pending action for later execution. @@ -1111,6 +1153,7 @@ export default function AddEditItemScreen(): React.ReactNode { onRemove={onRemove} testID={testID} defaultEmailMode={defaultEmailMode} + onGenerateAlias={aliasFieldsShownByDefault ? handleGenerateAliasEmail : handleGenerateRandomEmail} /> ); } @@ -1163,7 +1206,7 @@ export default function AddEditItemScreen(): React.ReactNode { /> ); } - }, [fieldValues, handleFieldChange, isPasswordVisible, isEditMode, aliasFieldsShownByDefault, generateRandomUsername, t, getFieldTestId, item?.ItemType, passwordSettings]); + }, [fieldValues, handleFieldChange, isPasswordVisible, isEditMode, aliasFieldsShownByDefault, generateRandomUsername, handleGenerateAliasEmail, handleGenerateRandomEmail, t, getFieldTestId, item?.ItemType, passwordSettings]); const styles = StyleSheet.create({ container: { diff --git a/apps/mobile-app/components/form/EmailDomainField.tsx b/apps/mobile-app/components/form/EmailDomainField.tsx index bd0635e23..92209e660 100644 --- a/apps/mobile-app/components/form/EmailDomainField.tsx +++ b/apps/mobile-app/components/form/EmailDomainField.tsx @@ -7,7 +7,6 @@ import { useColors } from '@/hooks/useColorScheme'; import { ThemedText } from '@/components/themed/ThemedText'; import { useDb } from '@/context/DbContext'; -import { CreateIdentityGenerator, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; type EmailDomainFieldProps = { value: string; @@ -21,6 +20,8 @@ type EmailDomainFieldProps = { testID?: string; /** Optional: default to email mode (free text) instead of alias mode (domain chooser). Defaults to false. */ defaultEmailMode?: boolean; + /** Optional callback to generate an email alias. When provided, shows a regenerate button and is called when switching to alias mode. */ + onGenerateAlias?: () => void; } // Hardcoded public email domains (same as in browser extension) @@ -49,12 +50,13 @@ export const EmailDomainField: React.FC = ({ label, onRemove, testID, - defaultEmailMode = false + defaultEmailMode = false, + onGenerateAlias }) => { const { t } = useTranslation(); const colors = useColors(); const dbContext = useDb(); - + // Initialize mode immediately based on value (before domains load) // This prevents flicker by setting the correct mode right away const getInitialMode = useCallback((val: string): boolean => { @@ -84,16 +86,16 @@ export const EmailDomainField: React.FC = ({ return PUBLIC_EMAIL_DOMAINS[0] || ''; }); const [isModalVisible, setIsModalVisible] = useState(false); - + // Use refs to store domains - this prevents re-renders when they load const privateEmailDomainsRef = useRef([]); const hiddenPrivateEmailDomainsRef = useRef([]); const hasDomainsLoadedRef = useRef(false); - + // State for domains (only used for UI display in modal, not for mode detection) const [privateEmailDomains, setPrivateEmailDomains] = useState([]); const [hiddenPrivateEmailDomains, setHiddenPrivateEmailDomains] = useState([]); - + // Track value changes to avoid unnecessary re-initialization const lastValueRef = useRef(value); const hasInitializedFromValue = useRef(false); @@ -110,16 +112,16 @@ export const EmailDomainField: React.FC = ({ const metadata = await dbContext.getVaultMetadata(); const privateDomains = metadata?.privateEmailDomains ?? []; const hiddenDomains = metadata?.hiddenPrivateEmailDomains ?? []; - + // Update refs immediately (no re-render triggered) privateEmailDomainsRef.current = privateDomains; hiddenPrivateEmailDomainsRef.current = hiddenDomains; hasDomainsLoadedRef.current = true; - + // Update state for modal display (triggers re-render, but only affects modal) setPrivateEmailDomains(privateDomains); setHiddenPrivateEmailDomains(hiddenDomains); - + // Check if we need to update mode now that domains are loaded // Only update if value has an @ and we're in custom mode if (value && value.includes('@')) { @@ -128,7 +130,7 @@ export const EmailDomainField: React.FC = ({ const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) || privateDomains.includes(domain) || hiddenDomains.includes(domain); - + // Only update mode if domain is now recognized AND we're in custom mode // Use functional update to avoid stale closure and prevent unnecessary updates setIsCustomDomain(prev => { @@ -174,7 +176,7 @@ export const EmailDomainField: React.FC = ({ if (value.includes('@')) { const [local, domain] = value.split('@'); - + // Only update if values actually changed (prevents unnecessary re-renders) if (local !== localPart) { setLocalPart(local); @@ -182,18 +184,18 @@ export const EmailDomainField: React.FC = ({ if (domain !== selectedDomain) { setSelectedDomain(domain); } - + // Check mode using refs (no re-render) - works even before domains load const isKnownDomain = PUBLIC_EMAIL_DOMAINS.includes(domain) || privateEmailDomainsRef.current.includes(domain) || hiddenPrivateEmailDomainsRef.current.includes(domain); - + // Only update mode if it needs to change (prevents flicker) setIsCustomDomain(prev => { const newMode = !isKnownDomain; return newMode !== prev ? newMode : prev; }); - + hasInitializedFromValue.current = true; } else { if (value !== localPart) { @@ -259,58 +261,42 @@ export const EmailDomainField: React.FC = ({ setIsModalVisible(false); }, [localPart, onChange]); - /** - * Generate a random email prefix using identity generator. - */ - const generateRandomEmailPrefix = useCallback(async (): Promise => { - try { - const identityLanguage = await dbContext.sqliteClient!.getEffectiveIdentityLanguage(); - const identityGenerator = CreateIdentityGenerator(identityLanguage); - - const genderPreference = await dbContext.sqliteClient!.getDefaultIdentityGender(); - const ageRange = await dbContext.sqliteClient!.getDefaultIdentityAgeRange(); - const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); - - const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); - return identity.emailPrefix; - } catch (error) { - console.error('Error generating random email prefix:', error); - // Fallback to a simple random string if generation fails - return `user${Math.random().toString(36).substring(2, 9)}`; - } - }, [dbContext]); - // Toggle between custom domain and domain chooser - const toggleCustomDomain = useCallback(async () => { + const toggleCustomDomain = useCallback(() => { const newIsCustom = !isCustomDomain; setIsCustomDomain(newIsCustom); if (newIsCustom) { - // Switching to custom domain mode - // If we have a domain-based value, extract just the local part - if (value && value.includes('@')) { - const [local] = value.split('@'); - onChange(local); - setLocalPart(local); - } + // Switching to custom domain mode (free text / normal email). + // Clear the value so the user starts fresh with a regular email address. + onChange(''); + setLocalPart(''); } else { - // Switching to domain chooser mode - generate a random email prefix + // Switching to domain chooser mode + setIsCustomDomain(false); + + if (onGenerateAlias) { + // Delegate to the parent callback which sets the full email value (prefix@domain) + onGenerateAlias(); + return; + } + + // No generate callback - just switch modes and preserve current local part const defaultDomain = showPrivateDomains && privateEmailDomains[0] ? privateEmailDomains[0] : PUBLIC_EMAIL_DOMAINS[0]; setSelectedDomain(defaultDomain); - // Generate a random email prefix instead of reusing the old one - const randomPrefix = await generateRandomEmailPrefix(); - setLocalPart(randomPrefix); - onChange(`${randomPrefix}@${defaultDomain}`); + if (localPart && localPart.trim()) { + onChange(`${localPart}@${defaultDomain}`); + } else if (value && !value.includes('@')) { + onChange(`${value}@${defaultDomain}`); + setLocalPart(value); + } } - }, [isCustomDomain, value, showPrivateDomains, privateEmailDomains, onChange, generateRandomEmailPrefix]); + }, [isCustomDomain, value, localPart, showPrivateDomains, privateEmailDomains, onChange, onGenerateAlias]); const styles = StyleSheet.create({ - container: { - marginBottom: 16, - }, domainAt: { color: colors.textMuted, fontSize: 16, @@ -329,6 +315,22 @@ export const EmailDomainField: React.FC = ({ paddingHorizontal: 12, paddingVertical: 11, }, + domainButtonNoRoundRight: { + borderBottomRightRadius: 0, + borderTopRightRadius: 0, + }, + generateButton: { + alignItems: 'center', + backgroundColor: colors.accentBackground, + borderBottomRightRadius: 8, + borderColor: error ? colors.errorBorder : colors.accentBorder, + borderLeftWidth: 0, + borderTopRightRadius: 8, + borderWidth: 1, + justifyContent: 'center', + paddingHorizontal: 10, + paddingVertical: 11, + }, domainButtonText: { color: colors.text, fontSize: 16, @@ -482,7 +484,7 @@ export const EmailDomainField: React.FC = ({ }); return ( - + = ({ {!isCustomDomain && ( setIsModalVisible(true)} > @ @@ -544,6 +546,16 @@ export const EmailDomainField: React.FC = ({ )} + + {!isCustomDomain && onGenerateAlias && ( + + + + )} {error && {error}} diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index ae18dd054..8ac235582 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -22,6 +22,7 @@ "disabled": "Disabled", "twoFactorAuthentication": "Two-factor authentication", "add": "Add", + "generate": "Generate", "attachments": "Attachments", "deleteItemConfirmTitle": "Delete Item", "deleteItemConfirmDescription": "Are you sure you want to delete this item?",