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 30d44b674..6ea0673b3 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -15,12 +15,68 @@ import { useHeaderButtons } from '@/entrypoints/popup/context/HeaderButtonsConte import { useLoading } from '@/entrypoints/popup/context/LoadingContext'; import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate'; +import { IdentityHelperUtils, CreateIdentityGenerator, convertAgeRangeToBirthdateOptions } from '@/utils/dist/shared/identity-generator'; import type { Item, ItemField, ItemType, FieldType } from '@/utils/dist/shared/models/vault'; import { getSystemFieldsForItemType } from '@/utils/dist/shared/models/vault'; +import { CreatePasswordGenerator } from '@/utils/dist/shared/password-generator'; // Valid item types from the shared model const VALID_ITEM_TYPES: ItemType[] = ['Login', 'CreditCard', 'Identity', 'Note']; +// Default item type for new items +const DEFAULT_ITEM_TYPE: ItemType = 'Login'; + +/** + * Item type option configuration. + */ +type ItemTypeOption = { + type: ItemType; + titleKey: string; + iconSvg: React.ReactNode; +}; + +/** + * Available item type options with icons. + */ +const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [ + { + type: 'Login', + titleKey: 'itemTypes.login.title', + iconSvg: ( + + + + ) + }, + { + type: 'CreditCard', + titleKey: 'itemTypes.creditCard.title', + iconSvg: ( + + + + ) + }, + { + type: 'Identity', + titleKey: 'itemTypes.identity.title', + iconSvg: ( + + + + ) + }, + { + type: 'Note', + titleKey: 'itemTypes.note.title', + iconSvg: ( + + + + ) + } +]; + /** * Temporary custom field definition (before persisting to database) */ @@ -69,6 +125,18 @@ const ItemAddEdit: React.FC = () => { // Folder selection state const [folders, setFolders] = useState>([]); + // Type selector dropdown state (for create mode) + const [showTypeDropdown, setShowTypeDropdown] = useState(false); + + // Alias fields visibility state (for Login type - hidden by default, shown when user adds it) + const [showAliasFields, setShowAliasFields] = useState(false); + + // Notes field visibility state (hidden by default, shown when user adds it) + const [showNotes, setShowNotes] = useState(false); + + // Add menu dropdown state (unified + button) + const [showAddMenu, setShowAddMenu] = useState(false); + /** * Get all applicable system fields for the current item type. * These are sorted by DefaultDisplayOrder. @@ -80,13 +148,36 @@ const ItemAddEdit: React.FC = () => { return getSystemFieldsForItemType(item.ItemType); }, [item]); + /** + * Fields that should be shown inline with service name (like login.url). + * These are Login category fields that start with "login." but aren't username/password. + */ + const serviceInlineFields = useMemo(() => { + return applicableSystemFields.filter(field => + field.FieldKey === 'login.url' + ); + }, [applicableSystemFields]); + + /** + * The notes field (login.notes) - handled separately for collapsible UI. + */ + const notesField = useMemo(() => { + return applicableSystemFields.find(field => field.FieldKey === 'login.notes'); + }, [applicableSystemFields]); + /** * Group system fields by category for organized rendering. + * Excludes service inline fields (login.url) and notes field. */ const groupedSystemFields = useMemo(() => { const groups: Record = {}; applicableSystemFields.forEach(field => { + // Skip fields handled separately + if (field.FieldKey === 'login.url' || field.FieldKey === 'login.notes') { + return; + } + const category = field.Category || 'Other'; if (!groups[category]) { groups[category] = []; @@ -104,18 +195,16 @@ const ItemAddEdit: React.FC = () => { if (!dbContext?.sqliteClient || !id || !isEditMode) { /* * Create mode - initialize with defaults - * Validate item type parameter + * Use provided type parameter or default to 'Login' */ - if (!itemTypeParam || !VALID_ITEM_TYPES.includes(itemTypeParam)) { - /* Redirect to type selector if no valid type specified */ - navigate('/items/select-type'); - return; - } + const effectiveType: ItemType = (itemTypeParam && VALID_ITEM_TYPES.includes(itemTypeParam)) + ? itemTypeParam + : DEFAULT_ITEM_TYPE; setItem({ Id: crypto.randomUUID().toUpperCase(), Name: itemNameParam || '', - ItemType: itemTypeParam, + ItemType: effectiveType, FolderId: null, Fields: [], CreatedAt: new Date().toISOString(), @@ -337,6 +426,198 @@ const ItemAddEdit: React.FC = () => { )); }, []); + /** + * Handle item type change from dropdown. + */ + const handleTypeChange = useCallback((newType: ItemType) => { + if (!item || isEditMode) { + return; + } + + // Clear field values when changing type (except name) + setFieldValues({}); + setCustomFields([]); + setShowAliasFields(false); + setShowNotes(false); + + setItem({ + ...item, + ItemType: newType, + Fields: [] + }); + + setShowTypeDropdown(false); + }, [item, isEditMode]); + + /** + * Initialize generators for random alias generation. + */ + const initializeGenerators = useCallback(async () => { + // Get effective identity language (smart default based on UI language if no explicit override) + const identityLanguage = await dbContext.sqliteClient!.getEffectiveIdentityLanguage(); + + // Initialize identity generator based on language + const identityGenerator = CreateIdentityGenerator(identityLanguage); + + // Initialize password generator with settings from vault + const passwordSettings = dbContext.sqliteClient!.getPasswordSettings(); + const passwordGenerator = CreatePasswordGenerator(passwordSettings); + + return { identityGenerator, passwordGenerator }; + }, [dbContext.sqliteClient]); + + /** + * Generate random alias and populate alias fields. + * This shows the alias fields and fills them with random values. + */ + const handleGenerateAlias = useCallback(async () => { + if (!dbContext?.sqliteClient) { + return; + } + + try { + const { identityGenerator, passwordGenerator } = await initializeGenerators(); + + // Get gender preference from database + const genderPreference = dbContext.sqliteClient.getDefaultIdentityGender(); + + // Get age range preference and convert to birthdate options + const ageRange = dbContext.sqliteClient.getDefaultIdentityAgeRange(); + const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); + + // Generate identity with gender preference and birthdate options + const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); + const password = passwordGenerator.generateRandomPassword(); + + const defaultEmailDomain = await dbContext.sqliteClient.getDefaultEmailDomain(); + const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; + + // Set field values for alias fields + setFieldValues(prev => ({ + ...prev, + 'alias.email': email, + 'alias.first_name': identity.firstName, + 'alias.last_name': identity.lastName, + 'alias.nickname': identity.nickName, + 'alias.gender': identity.gender, + 'alias.birthdate': IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()), + // Also set username and password if they're empty + 'login.username': prev['login.username'] || identity.nickName, + 'login.password': prev['login.password'] || password + })); + + // Show alias fields section + setShowAliasFields(true); + } catch (error) { + console.error('Error generating random alias:', error); + } + }, [dbContext.sqliteClient, initializeGenerators]); + + /** + * Clear all alias field values but keep them visible. + */ + const handleClearAliasFields = useCallback(() => { + setFieldValues(prev => ({ + ...prev, + 'alias.email': '', + 'alias.first_name': '', + 'alias.last_name': '', + 'alias.nickname': '', + 'alias.gender': '', + 'alias.birthdate': '' + })); + }, []); + + /** + * Remove alias section - clears values and hides the section. + */ + const handleRemoveAliasSection = useCallback(() => { + handleClearAliasFields(); + setShowAliasFields(false); + }, [handleClearAliasFields]); + + /** + * Remove notes section - clears value and hides the section. + */ + const handleRemoveNotesSection = useCallback(() => { + setFieldValues(prev => ({ + ...prev, + 'login.notes': '' + })); + setShowNotes(false); + }, []); + + /** + * Get the selected item type option for display. + */ + const selectedTypeOption = useMemo(() => { + return ITEM_TYPE_OPTIONS.find(opt => opt.type === item?.ItemType); + }, [item?.ItemType]); + + /** + * Handle adding notes section from menu. + */ + const handleAddNotesFromMenu = useCallback((): void => { + setShowNotes(true); + setShowAddMenu(false); + }, []); + + /** + * Handle adding custom field from menu. + */ + const handleAddCustomFieldFromMenu = useCallback((): void => { + setShowAddCustomFieldModal(true); + setShowAddMenu(false); + }, []); + + /** + * Add menu options - shows available optional sections (Notes and Custom Fields only). + * Alias has its own dedicated button since it's a core feature. + */ + const addMenuOptions = useMemo(() => { + const options: Array<{ + key: string; + label: string; + icon: React.ReactNode; + action: () => void; + }> = []; + + // Notes option (when not shown and no value) + if (notesField && !showNotes && !fieldValues['login.notes'] && !isEditMode) { + options.push({ + key: 'notes', + label: t('credentials.notes'), + icon: ( + + + + ), + action: handleAddNotesFromMenu + }); + } + + // Custom field option (always available) + options.push({ + key: 'custom', + label: t('itemTypes.addCustomField'), + icon: ( + + + + ), + action: handleAddCustomFieldFromMenu + }); + + return options; + }, [showNotes, notesField, fieldValues, isEditMode, t, handleAddNotesFromMenu, handleAddCustomFieldFromMenu]); + + /** + * Whether to show the dedicated "Add alias" button (for Login type in create mode when alias not shown). + */ + const showAddAliasButton = useMemo(() => { + return item?.ItemType === 'Login' && !showAliasFields && !isEditMode; + }, [item?.ItemType, showAliasFields, isEditMode]); + // Set header buttons useEffect(() => { const headerButtonsJSX = isEditMode ? ( @@ -462,55 +743,86 @@ const ItemAddEdit: React.FC = () => { } return ( -
- {/* Item Name */} -
- setItem({ ...item, Name: value })} - type="text" - placeholder={t('credentials.serviceName')} - required - /> -
+
+ {/* Item Type Selector (create mode only) */} + {!isEditMode && ( +
+ - {/* Folder Selection */} -
- - -
+ {/* Type Dropdown Menu */} + {showTypeDropdown && ( + <> +
setShowTypeDropdown(false)} + /> +
+ {ITEM_TYPE_OPTIONS.map((option) => ( + + ))} +
+ + )} +
+ )} - {/* Render fields grouped by category */} - {Object.keys(groupedSystemFields).map(category => ( -
-

- {category === 'Login' && t('credentials.loginCredentials')} - {category === 'Alias' && t('credentials.alias')} - {category === 'Card' && t('credentials.cardInformation')} - {category === 'Identity' && t('credentials.identityInformation')} - {category !== 'Login' && category !== 'Alias' && category !== 'Card' && category !== 'Identity' && category} -

- - {groupedSystemFields[category].map(field => ( + {/* Service Section - Name and URL */} +
+

{t('credentials.service')}

+
+ setItem({ ...item, Name: value })} + type="text" + placeholder={t('credentials.serviceName')} + required + /> + {/* Service inline fields (login.url) - shown without header */} + {serviceInlineFields.map(field => (
{renderFieldInput( field.FieldKey, @@ -522,48 +834,219 @@ const ItemAddEdit: React.FC = () => {
))}
- ))} +
+ + {/* Render fields grouped by category */} + {Object.keys(groupedSystemFields).map(category => { + // Special handling for Alias category in Login type (create mode only) + const isAliasInLoginCreate = category === 'Alias' && item.ItemType === 'Login' && !isEditMode; + + // If alias in login create mode and not shown, skip rendering (will be available via + menu) + if (isAliasInLoginCreate && !showAliasFields) { + return null; + } + + return ( +
+

+ + {category === 'Login' && t('credentials.loginCredentials')} + {category === 'Alias' && t('credentials.alias')} + {category === 'Card' && t('credentials.cardInformation')} + {category === 'Identity' && t('credentials.identityInformation')} + {category !== 'Login' && category !== 'Alias' && category !== 'Card' && category !== 'Identity' && category} + + + {/* Show action buttons for Alias section in Login create mode */} + {isAliasInLoginCreate && showAliasFields && ( +
+ {/* Regenerate button */} + + {/* Remove button */} + +
+ )} +

+
+ {groupedSystemFields[category].map(field => ( +
+ {renderFieldInput( + field.FieldKey, + field.Label, + field.FieldType, + field.IsHidden, + field.IsMultiValue + )} +
+ ))} +
+
+ ); + })} {/* Custom Fields Section */} {customFields.length > 0 && ( -
-

+
+

{t('common.customFields')}

+
+ {customFields.map(field => ( +
+ handleUpdateCustomFieldLabel(field.tempId, newLabel)} + onDelete={() => handleDeleteCustomField(field.tempId)} + /> - {customFields.map(field => ( -
- handleUpdateCustomFieldLabel(field.tempId, newLabel)} - onDelete={() => handleDeleteCustomField(field.tempId)} - /> - - {/* Field input */} - {renderFieldInput( - field.tempId, - '', - field.fieldType, - field.isHidden, - false - )} -
- ))} + {/* Field input */} + {renderFieldInput( + field.tempId, + '', + field.fieldType, + field.isHidden, + false + )} +
+ ))} +
)} - {/* Add Custom Field Button */} - + {/* Notes Section - Hidden by default in create mode, with remove button */} + {notesField && (showNotes || isEditMode || fieldValues['login.notes']) && ( +
+

+ {t('credentials.notes')} + {/* Remove button for notes in create mode */} + {!isEditMode && ( + + )} +

+
+ {renderFieldInput( + notesField.FieldKey, + notesField.Label, + notesField.FieldType, + notesField.IsHidden, + notesField.IsMultiValue + )} +
+
+ )} + + {/* Dedicated "Add Alias" button - highlighted as core feature */} + {showAddAliasButton && ( + + )} + + {/* Generic + button with dropdown menu for Notes and Custom Fields */} +
+ + + {/* Add Menu Dropdown */} + {showAddMenu && ( + <> +
setShowAddMenu(false)} + /> +
+ {addMenuOptions.map((option) => ( + + ))} +
+ + )} +
+ + {/* Folder Selection - Compact at bottom */} + {folders.length > 0 && ( +
+ + + + +
+ )} {/* Action Buttons */} -
+