diff --git a/apps/mobile-app/app/(tabs)/_layout.tsx b/apps/mobile-app/app/(tabs)/_layout.tsx index 4a08481c8..3cb01b33c 100644 --- a/apps/mobile-app/app/(tabs)/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/_layout.tsx @@ -99,11 +99,11 @@ export default function TabLayout() : React.ReactNode { }), }}> , }} diff --git a/apps/mobile-app/app/(tabs)/emails/[id].tsx b/apps/mobile-app/app/(tabs)/emails/[id].tsx index 501c8f4d8..be8c77c9a 100644 --- a/apps/mobile-app/app/(tabs)/emails/[id].tsx +++ b/apps/mobile-app/app/(tabs)/emails/[id].tsx @@ -168,11 +168,11 @@ export default function EmailDetailsScreen() : React.ReactNode { }; /** - * Handle the open credential button press. + * Handle the open item button press. */ - const handleOpenCredential = () : void => { + const handleOpenItem = () : void => { if (associatedCredential) { - router.push(`/(tabs)/credentials/${associatedCredential.Id}`); + router.push(`/(tabs)/items/${associatedCredential.Id}`); } }; @@ -238,12 +238,12 @@ export default function EmailDetailsScreen() : React.ReactNode { metadataContainer: { padding: 2, }, - metadataCredential: { + metadataItem: { alignItems: 'center', alignSelf: 'center', flexDirection: 'row', }, - metadataCredentialIcon: { + metadataItemIcon: { marginRight: 4, }, metadataHeading: { @@ -408,10 +408,10 @@ export default function EmailDetailsScreen() : React.ReactNode { {associatedCredential && ( - + {associatedCredential.ServiceName} diff --git a/apps/mobile-app/app/(tabs)/credentials/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx similarity index 61% rename from apps/mobile-app/app/(tabs)/credentials/[id].tsx rename to apps/mobile-app/app/(tabs)/items/[id].tsx index 4247d0de2..2a4a3b520 100644 --- a/apps/mobile-app/app/(tabs)/credentials/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -4,18 +4,19 @@ import { useCallback, useEffect, useState } from 'react'; import { ActivityIndicator, View, Text, StyleSheet, Linking, Platform } from 'react-native' import Toast from 'react-native-toast-message'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { FieldTypes, getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { useColors } from '@/hooks/useColorScheme'; -import { CredentialIcon } from '@/components/credentials/CredentialIcon'; -import { AliasDetails } from '@/components/credentials/details/AliasDetails'; -import { AttachmentSection } from '@/components/credentials/details/AttachmentSection'; -import { EmailPreview } from '@/components/credentials/details/EmailPreview'; -import { LoginCredentials } from '@/components/credentials/details/LoginCredentials'; -import { NotesSection } from '@/components/credentials/details/NotesSection'; -import { TotpSection } from '@/components/credentials/details/TotpSection'; +import { AliasDetails } from '@/components/items/details/AliasDetails'; +import { AttachmentSection } from '@/components/items/details/AttachmentSection'; +import { EmailPreview } from '@/components/items/details/EmailPreview'; +import { LoginFields } from '@/components/items/details/LoginFields'; +import { NotesSection } from '@/components/items/details/NotesSection'; +import { TotpSection } from '@/components/items/details/TotpSection'; +import { ItemIcon } from '@/components/items/ItemIcon'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedScrollView } from '@/components/themed/ThemedScrollView'; import { ThemedText } from '@/components/themed/ThemedText'; @@ -24,11 +25,11 @@ import { RobustPressable } from '@/components/ui/RobustPressable'; import { useDb } from '@/context/DbContext'; /** - * Credential details screen. + * Item details screen. */ -export default function CredentialDetailsScreen() : React.ReactNode { +export default function ItemDetailsScreen() : React.ReactNode { const { id } = useLocalSearchParams(); - const [credential, setCredential] = useState(null); + const [item, setItem] = useState(null); const [isLoading, setIsLoading] = useState(true); const dbContext = useDb(); const navigation = useNavigation(); @@ -39,7 +40,7 @@ export default function CredentialDetailsScreen() : React.ReactNode { * Handle the edit button press. */ const handleEdit = useCallback(() : void => { - router.push(`/(tabs)/credentials/add-edit?id=${id}`); + router.push(`/(tabs)/items/add-edit?id=${id}`); }, [id, router]); // Set header buttons @@ -63,38 +64,38 @@ export default function CredentialDetailsScreen() : React.ReactNode { ), }); - }, [navigation, credential, handleEdit, colors.primary]); + }, [navigation, item, handleEdit, colors.primary]); useEffect(() => { /** - * Load the credential. + * Load the item. */ - const loadCredential = async () : Promise => { + const loadItem = async () : Promise => { if (!dbContext.dbAvailable || !id) { return; } try { - const cred = await dbContext.sqliteClient!.getCredentialById(id as string); - setCredential(cred); + const result = await dbContext.sqliteClient!.getItemById(id as string); + setItem(result); } catch (err) { - console.error('Error loading credential:', err); + console.error('Error loading item:', err); } finally { setIsLoading(false); } }; - loadCredential(); + loadItem(); - // Add listener for credential changes - const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => { + // Add listener for item changes + const itemChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => { if (changedId === id) { - await loadCredential(); + await loadItem(); } }); return () : void => { - credentialChangedSub.remove(); + itemChangedSub.remove(); Toast.hide(); }; }, [id, dbContext.dbAvailable, dbContext.sqliteClient]); @@ -107,42 +108,51 @@ export default function CredentialDetailsScreen() : React.ReactNode { ); } - if (!credential) { + if (!item) { return null; } + // Extract URL fields for display + const urlFields = item.Fields.filter(field => field.FieldType === FieldTypes.URL && field.Value); + const firstUrl = urlFields.length > 0 + ? (Array.isArray(urlFields[0].Value) ? urlFields[0].Value[0] : urlFields[0].Value) + : null; + + // Get email for EmailPreview + const email = getFieldValue(item, FieldKey.LoginEmail); + return ( - + - {credential.ServiceName} + {item.Name} - {credential.ServiceUrl && ( - /^https?:\/\//i.test(credential.ServiceUrl) ? ( + {firstUrl && ( + /^https?:\/\//i.test(firstUrl) ? ( Linking.openURL(credential.ServiceUrl!)} + onPress={() => Linking.openURL(firstUrl)} > - {credential.ServiceUrl} + {firstUrl} ) : ( - {credential.ServiceUrl} + {firstUrl} ) )} - - - - - - + + + + + + ); diff --git a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx b/apps/mobile-app/app/(tabs)/items/_layout.tsx similarity index 69% rename from apps/mobile-app/app/(tabs)/credentials/_layout.tsx rename to apps/mobile-app/app/(tabs)/items/_layout.tsx index bac40cd2b..7005a62f1 100644 --- a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/items/_layout.tsx @@ -5,10 +5,10 @@ import { Platform } from 'react-native'; import { defaultHeaderOptions } from '@/components/themed/ThemedHeader'; /** - * Credentials layout. - * @returns {React.ReactNode} The credentials layout component + * Items layout. + * @returns {React.ReactNode} The items layout component */ -export default function CredentialsLayout(): React.ReactNode { +export default function ItemsLayout(): React.ReactNode { const { t } = useTranslation(); return ( @@ -16,7 +16,7 @@ export default function CredentialsLayout(): React.ReactNode { diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit-page.tsx b/apps/mobile-app/app/(tabs)/items/add-edit-page.tsx similarity index 100% rename from apps/mobile-app/app/(tabs)/credentials/add-edit-page.tsx rename to apps/mobile-app/app/(tabs)/items/add-edit-page.tsx diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx similarity index 64% rename from apps/mobile-app/app/(tabs)/credentials/add-edit.tsx rename to apps/mobile-app/app/(tabs)/items/add-edit.tsx index 006f76648..c80fa48fd 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -1,32 +1,30 @@ import { Buffer } from 'buffer'; import MaterialIcons from '@expo/vector-icons/MaterialIcons'; -import { yupResolver } from '@hookform/resolvers/yup'; import { usePreventRemove } from '@react-navigation/native'; import * as Haptics from 'expo-haptics'; import { Stack, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; -import { useState, useEffect, useRef, useCallback } from 'react'; -import { Resolver, useForm } from 'react-hook-form'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Alert, Keyboard, Platform, ScrollView, KeyboardAvoidingView } from 'react-native'; import Toast from 'react-native-toast-message'; import { CreateIdentityGenerator, CreateUsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; -import type { Attachment, Credential, TotpCode } from '@/utils/dist/core/models/vault'; +import type { Attachment, Item, ItemField, TotpCode, ItemType } from '@/utils/dist/core/models/vault'; +import { ItemTypes, getSystemFieldsForItemType, FieldKey } from '@/utils/dist/core/models/vault'; import type { FaviconExtractModel } from '@/utils/dist/core/models/webapi'; import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/core/password-generator'; import emitter from '@/utils/EventEmitter'; import { extractServiceNameFromUrl } from '@/utils/UrlUtility'; -import { createCredentialSchema } from '@/utils/ValidationSchema'; import { useColors } from '@/hooks/useColorScheme'; import { useVaultMutate } from '@/hooks/useVaultMutate'; -import { AttachmentUploader } from '@/components/credentials/details/AttachmentUploader'; -import { TotpEditor } from '@/components/credentials/details/TotpEditor'; import { AdvancedPasswordField } from '@/components/form/AdvancedPasswordField'; import { EmailDomainField } from '@/components/form/EmailDomainField'; -import { ValidatedFormField, ValidatedFormFieldRef } from '@/components/form/ValidatedFormField'; +import { FormField, FormFieldRef } from '@/components/form/FormField'; +import { AttachmentUploader } from '@/components/items/details/AttachmentUploader'; +import { TotpEditor } from '@/components/items/details/TotpEditor'; import LoadingOverlay from '@/components/LoadingOverlay'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedText } from '@/components/themed/ThemedText'; @@ -36,33 +34,42 @@ import { useAuth } from '@/context/AuthContext'; import { useDb } from '@/context/DbContext'; import { useWebApi } from '@/context/WebApiContext'; -type CredentialMode = 'random' | 'manual'; +type ItemMode = 'random' | 'manual'; + +// Default item type for mobile app - currently only Login/Alias type is supported +const DEFAULT_ITEM_TYPE: ItemType = ItemTypes.Alias; /** - * Add or edit a credential screen. + * Add or edit an item screen. */ -export default function AddEditCredentialScreen() : React.ReactNode { +export default function AddEditItemScreen() : React.ReactNode { const { id, serviceUrl } = useLocalSearchParams<{ id: string, serviceUrl?: string }>(); const router = useRouter(); const colors = useColors(); const dbContext = useDb(); const authContext = useAuth(); - const [mode, setMode] = useState('random'); + const [mode, setMode] = useState('random'); const { executeVaultMutation, syncStatus } = useVaultMutate(); const navigation = useNavigation(); const webApi = useWebApi(); const [isPasswordVisible, setIsPasswordVisible] = useState(false); - const serviceNameRef = useRef(null); + const itemNameRef = useRef(null); const [isSyncing, setIsSyncing] = useState(false); const [isSaveDisabled, setIsSaveDisabled] = useState(false); const [attachments, setAttachments] = useState([]); const [originalAttachmentIds, setOriginalAttachmentIds] = useState([]); const [totpCodes, setTotpCodes] = useState([]); const [originalTotpCodeIds, setOriginalTotpCodeIds] = useState([]); - const [passkeyMarkedForDeletion, setPasskeyMarkedForDeletion] = useState(false); + const [passkeyIdsMarkedForDeletion, setPasskeyIdsMarkedForDeletion] = useState([]); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const { t } = useTranslation(); + // Item state + const [item, setItem] = useState(null); + + // Form state for dynamic fields - key is FieldKey, value is the field value + const [fieldValues, setFieldValues] = useState>({}); + // Track last generated values to avoid overwriting manual entries const [lastGeneratedValues, setLastGeneratedValues] = useState<{ username: string | null; @@ -70,31 +77,32 @@ export default function AddEditCredentialScreen() : React.ReactNode { email: string | null; }>({ username: null, password: null, email: null }); - const { control, handleSubmit, setValue, watch, formState } = useForm({ - resolver: yupResolver(createCredentialSchema(t)) as Resolver, - defaultValues: { - Id: "", - Username: "", - Password: "", - ServiceName: "", - ServiceUrl: "https://", - Notes: "", - Alias: { - FirstName: "", - LastName: "", - NickName: "", - BirthDate: "", - Gender: undefined, - Email: "" - } - } - }); - /** * If we received an ID, we're in edit mode. */ const isEditMode = id !== undefined && id.length > 0; + /** + * Get all applicable system fields for the current item type. + */ + const applicableSystemFields = useMemo(() => { + if (!item) { + return []; + } + return getSystemFieldsForItemType(item.ItemType); + }, [item]); + + /** + * Handle field value change. + */ + const handleFieldChange = useCallback((fieldKey: string, value: string | string[]) => { + setFieldValues(prev => ({ + ...prev, + [fieldKey]: value + })); + setHasUnsavedChanges(true); + }, []); + /** * Generate a random identity. */ @@ -106,18 +114,9 @@ export default function AddEditCredentialScreen() : React.ReactNode { const ageRange = await dbContext.sqliteClient!.getDefaultIdentityAgeRange(); const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); - // Generate identity with gender preference and birthdate options return identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); }, [dbContext.sqliteClient]); - /** - * Track form changes to warn user before dismissing with unsaved changes. - */ - useEffect(() => { - // Update unsaved changes state based on form dirty state - setHasUnsavedChanges(formState.isDirty); - }, [formState.isDirty]); - /** * Prevent accidental dismissal when there are unsaved changes. */ @@ -129,17 +128,13 @@ export default function AddEditCredentialScreen() : React.ReactNode { { text: t('common.cancel'), style: 'cancel', - /** - * Cancel button handler. - */ + /** Cancel button handler. */ onPress: () : void => {} }, { text: t('credentials.unsavedChanges.discard'), style: 'destructive', - /** - * Discard button handler. - */ + /** Discard button handler. */ onPress: () : void => { setHasUnsavedChanges(false); navigation.dispatch(data.action); @@ -150,51 +145,62 @@ export default function AddEditCredentialScreen() : React.ReactNode { }); /** - * Load an existing credential from the database in edit mode. + * Load an existing item from the database in edit mode. */ - const loadExistingCredential = useCallback(async () : Promise => { + const loadExistingItem = useCallback(async () : Promise => { try { - const existingCredential = await dbContext.sqliteClient!.getCredentialById(id); - if (existingCredential) { - existingCredential.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDate(existingCredential.Alias.BirthDate); - Object.entries(existingCredential).forEach(([key, value]) => { - setValue(key as keyof Credential, value); + const existingItem = await dbContext.sqliteClient!.getItemById(id); + if (existingItem) { + setItem(existingItem); + + // Initialize field values from existing fields + const initialValues: Record = {}; + existingItem.Fields.forEach((field) => { + initialValues[field.FieldKey] = field.Value; }); - if (existingCredential.Alias?.FirstName || existingCredential.Alias?.LastName) { + + // Normalize birthdate if present + if (initialValues[FieldKey.AliasBirthdate]) { + initialValues[FieldKey.AliasBirthdate] = IdentityHelperUtils.normalizeBirthDate( + initialValues[FieldKey.AliasBirthdate] as string + ); + } + + setFieldValues(initialValues); + + // Check if alias fields have values to set mode + const hasAliasFields = initialValues[FieldKey.AliasFirstName] || initialValues[FieldKey.AliasLastName]; + if (hasAliasFields) { setMode('manual'); } - // Load attachments for this credential - const credentialAttachments = await dbContext.sqliteClient!.getAttachmentsForCredential(id); - setAttachments(credentialAttachments); + // Load attachments for this item + const itemAttachments = await dbContext.sqliteClient!.getAttachmentsForItem(id); + setAttachments(itemAttachments); + setOriginalAttachmentIds(itemAttachments.map(a => a.Id)); - // Load TOTP codes for this credential - const credentialTotpCodes = await dbContext.sqliteClient!.getTotpCodesForCredential(id); - setTotpCodes(credentialTotpCodes); - setOriginalTotpCodeIds(credentialTotpCodes.map(tc => tc.Id)); - setOriginalAttachmentIds(credentialAttachments.map(a => a.Id)); + // Load TOTP codes for this item + const itemTotpCodes = await dbContext.sqliteClient!.getTotpCodesForItem(id); + setTotpCodes(itemTotpCodes); + setOriginalTotpCodeIds(itemTotpCodes.map(tc => tc.Id)); } } catch (err) { - console.error('Error loading credential:', err); + console.error('Error loading item:', err); Toast.show({ type: 'error', text1: t('credentials.errors.loadFailed'), text2: t('auth.errors.enterPassword') }); } - }, [id, dbContext.sqliteClient, setValue, t]); + }, [id, dbContext.sqliteClient, t]); /** - * On mount, load an existing credential if we're in edit mode, or extract the service name from the service URL - * if we're in add mode and the service URL is provided (by native autofill component). + * On mount, load an existing item if we're in edit mode, or initialize new item. */ useEffect(() => { - /** - * Initialize the component by loading settings and handling initial state. - */ + /** Initialize the component by loading settings and handling initial state. */ const initializeComponent = async (): Promise => { if (authContext.isOffline) { - // Show toast and close the modal setTimeout(() => { Toast.show({ type: 'error', @@ -207,35 +213,54 @@ export default function AddEditCredentialScreen() : React.ReactNode { } if (isEditMode) { - loadExistingCredential(); - } else if (serviceUrl) { - const decodedUrl = decodeURIComponent(serviceUrl); - const serviceName = extractServiceNameFromUrl(decodedUrl); - setValue('ServiceUrl', decodedUrl); - setValue('ServiceName', serviceName); - } + loadExistingItem(); + } else { + // Create mode - initialize new item + let serviceName = ''; + let itemUrl = ''; - // On create mode, focus the service name field after a short delay to ensure the component is mounted - if (!isEditMode) { + if (serviceUrl) { + const decodedUrl = decodeURIComponent(serviceUrl); + serviceName = extractServiceNameFromUrl(decodedUrl); + itemUrl = decodedUrl; + } + + const newItem: Item = { + Id: crypto.randomUUID().toUpperCase(), + Name: serviceName, + ItemType: DEFAULT_ITEM_TYPE, + FolderId: null, + Fields: [], + CreatedAt: new Date().toISOString(), + UpdatedAt: new Date().toISOString() + }; + + setItem(newItem); + + // Set URL in field values if provided + if (itemUrl) { + setFieldValues(prev => ({ + ...prev, + [FieldKey.LoginUrl]: itemUrl + })); + } + + // Focus the item name field after a short delay setTimeout(() => { - serviceNameRef.current?.focus(); + itemNameRef.current?.focus(); }, 100); } }; initializeComponent(); - }, [id, isEditMode, serviceUrl, loadExistingCredential, setValue, authContext.isOffline, router, t, dbContext.sqliteClient]); + }, [id, isEditMode, serviceUrl, loadExistingItem, authContext.isOffline, router, t]); /** * Initialize the password generator with settings from user's vault. - * @returns {PasswordGenerator} */ const initializePasswordGenerator = useCallback(async () : Promise => { - // Initialize password generator with settings from vault const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings(); - const passwordGenerator = CreatePasswordGenerator(passwordSettings); - - return passwordGenerator; + return CreatePasswordGenerator(passwordSettings); }, [dbContext.sqliteClient]); /** @@ -243,73 +268,82 @@ export default function AddEditCredentialScreen() : React.ReactNode { */ const generateRandomAlias = useCallback(async (): Promise => { const passwordGenerator = await initializePasswordGenerator(); - - // Generate identity with gender preference and birthdate options const identity = await generateRandomIdentity(); - const password = passwordGenerator.generateRandomPassword(); const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; // Check current values - const currentUsername = watch('Username') ?? ''; - const currentPassword = watch('Password') ?? ''; - const currentEmail = watch('Alias.Email') ?? ''; + const currentUsername = (fieldValues[FieldKey.LoginUsername] as string) ?? ''; + const currentPassword = (fieldValues[FieldKey.LoginPassword] as string) ?? ''; + const currentEmail = (fieldValues[FieldKey.LoginEmail] as string) ?? ''; + + const newValues: Record = { ...fieldValues }; // Only overwrite email if it's empty or matches the last generated value if (!currentEmail || currentEmail === lastGeneratedValues.email) { - setValue('Alias.Email', email); + newValues[FieldKey.LoginEmail] = email; } - setValue('Alias.FirstName', identity.firstName); - setValue('Alias.LastName', identity.lastName); - setValue('Alias.NickName', identity.nickName); - setValue('Alias.Gender', identity.gender); - setValue('Alias.BirthDate', IdentityHelperUtils.normalizeBirthDate(identity.birthDate.toISOString())); + + // Always update alias identity fields + newValues[FieldKey.AliasFirstName] = identity.firstName; + newValues[FieldKey.AliasLastName] = identity.lastName; + newValues[FieldKey.AliasGender] = identity.gender; + newValues[FieldKey.AliasBirthdate] = IdentityHelperUtils.normalizeBirthDate(identity.birthDate.toISOString()); // Only overwrite username if it's empty or matches the last generated value if (!currentUsername || currentUsername === lastGeneratedValues.username) { - setValue('Username', identity.nickName); + newValues[FieldKey.LoginUsername] = identity.nickName; } // Only overwrite password if it's empty or matches the last generated value if (!currentPassword || currentPassword === lastGeneratedValues.password) { - setValue('Password', password); - // Make password visible when newly generated + newValues[FieldKey.LoginPassword] = password; setIsPasswordVisible(true); } + setFieldValues(newValues); + setHasUnsavedChanges(true); + // Update tracking with new generated values setLastGeneratedValues({ username: identity.nickName, password: password, email: email }); - }, [watch, setValue, setIsPasswordVisible, initializePasswordGenerator, generateRandomIdentity, dbContext.sqliteClient, lastGeneratedValues, setLastGeneratedValues]); + }, [fieldValues, initializePasswordGenerator, generateRandomIdentity, dbContext.sqliteClient, lastGeneratedValues]); /** * Clear all alias fields. */ const clearAliasFields = useCallback(() => { - setValue('Alias.FirstName', ''); - setValue('Alias.LastName', ''); - setValue('Alias.NickName', ''); - setValue('Alias.Gender', ''); - setValue('Alias.BirthDate', ''); - }, [setValue]); + setFieldValues(prev => ({ + ...prev, + [FieldKey.AliasFirstName]: '', + [FieldKey.AliasLastName]: '', + [FieldKey.AliasGender]: '', + [FieldKey.AliasBirthdate]: '', + })); + setHasUnsavedChanges(true); + }, []); /** * Check if any alias fields have values. */ - const hasAliasValues = watch('Alias.FirstName') || watch('Alias.LastName') || watch('Alias.NickName') || watch('Alias.Gender') || watch('Alias.BirthDate'); + const hasAliasValues = useMemo(() => { + return !!( + fieldValues[FieldKey.AliasFirstName] || + fieldValues[FieldKey.AliasLastName] || + fieldValues[FieldKey.AliasGender] || + fieldValues[FieldKey.AliasBirthdate] + ); + }, [fieldValues]); /** * Handle the generate random alias button press. */ const handleGenerateRandomAlias = useCallback(async (): Promise => { - // Trigger haptic feedback when pull-to-refresh is activated - if (Platform.OS === 'ios') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } else if (Platform.OS === 'android') { + if (Platform.OS === 'ios' || Platform.OS === 'android') { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } @@ -321,114 +355,107 @@ export default function AddEditCredentialScreen() : React.ReactNode { }, [generateRandomAlias, clearAliasFields, hasAliasValues]); /** - * Submit the form for either creating or updating a credential. - * @param {Credential} data - The form data. + * Submit the form for either creating or updating an item. */ - const onSubmit = useCallback(async (data: Credential) : Promise => { - // Prevent multiple submissions - if (isSaveDisabled) { + const onSubmit = useCallback(async () : Promise => { + if (isSaveDisabled || !item) { return; } - // Disable save button to prevent multiple submissions setIsSaveDisabled(true); - Keyboard.dismiss(); - setIsSyncing(true); - // Assemble the credential to save - const credentialToSave: Credential = { - Id: isEditMode ? id : '', - Username: data.Username, - Password: data.Password, - ServiceName: data.ServiceName, - ServiceUrl: (data.ServiceUrl === 'http://' || data.ServiceUrl === 'https://') ? '' : data.ServiceUrl, - Notes: data.Notes, - Alias: { - FirstName: data.Alias.FirstName, - LastName: data.Alias.LastName, - NickName: data.Alias.NickName, - BirthDate: data.Alias.BirthDate, - Gender: data.Alias.Gender, - Email: data.Alias.Email - } - } - - // If we're creating a new credential and mode is random, generate random values here + // If we're creating a new item and mode is random, generate random values first if (!isEditMode && mode === 'random') { - // Generate random values now and then read them from the form fields to manually assign to the credentialToSave object await generateRandomAlias(); - credentialToSave.Username = watch('Username'); - credentialToSave.Password = watch('Password'); - credentialToSave.ServiceName = watch('ServiceName'); - const serviceUrl = watch('ServiceUrl'); - credentialToSave.ServiceUrl = (serviceUrl === 'http://' || serviceUrl === 'https://') ? '' : serviceUrl; - credentialToSave.Notes = watch('Notes'); - credentialToSave.Alias.FirstName = watch('Alias.FirstName'); - credentialToSave.Alias.LastName = watch('Alias.LastName'); - credentialToSave.Alias.NickName = watch('Alias.NickName'); - credentialToSave.Alias.BirthDate = watch('Alias.BirthDate'); - credentialToSave.Alias.Gender = watch('Alias.Gender'); - credentialToSave.Alias.Email = watch('Alias.Email'); } - // Convert user birthdate entry format (yyyy-mm-dd) into valid ISO 8601 format for database storage - credentialToSave.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDate(credentialToSave.Alias.BirthDate); + // Build the fields array from fieldValues + const fields: ItemField[] = []; - // Extract favicon from service URL if the credential has one - if (credentialToSave.ServiceUrl) { + applicableSystemFields.forEach(systemField => { + const value = fieldValues[systemField.FieldKey]; + + // Only include fields with non-empty values + if (value && (Array.isArray(value) ? value.length > 0 : value.toString().trim() !== '')) { + fields.push({ + FieldKey: systemField.FieldKey, + Label: systemField.FieldKey, + FieldType: systemField.FieldType, + Value: value, + IsHidden: systemField.IsHidden, + DisplayOrder: systemField.DefaultDisplayOrder, + IsCustomField: false, + EnableHistory: systemField.EnableHistory + }); + } + }); + + // Normalize birthdate if present + const birthdateField = fields.find(f => f.FieldKey === FieldKey.AliasBirthdate); + if (birthdateField && typeof birthdateField.Value === 'string') { + birthdateField.Value = IdentityHelperUtils.normalizeBirthDate(birthdateField.Value); + } + + // Build the item to save + let itemToSave: Item = { + ...item, + Id: isEditMode ? id : crypto.randomUUID().toUpperCase(), + Name: item.Name || t('items.untitled'), + Fields: fields, + UpdatedAt: new Date().toISOString() + }; + + // Extract favicon from URL if present + const urlValue = fieldValues[FieldKey.LoginUrl]; + const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue; + if (urlString && urlString !== 'https://' && urlString !== 'http://') { try { const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000) ); - const faviconPromise = webApi.get('Favicon/Extract?url=' + credentialToSave.ServiceUrl); + const faviconPromise = webApi.get('Favicon/Extract?url=' + urlString); const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as FaviconExtractModel; if (faviconResponse?.image) { const decodedImage = Uint8Array.from(Buffer.from(faviconResponse.image as string, 'base64')); - credentialToSave.Logo = decodedImage; + itemToSave.Logo = decodedImage; } } catch { - // Favicon extraction failed or timed out, this is not a critical error so we can ignore it. + // Favicon extraction failed or timed out - not critical } } await executeVaultMutation(async () => { if (isEditMode) { - await dbContext.sqliteClient!.updateCredentialById(credentialToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes); + await dbContext.sqliteClient!.updateItem(itemToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes); // Delete passkeys if marked for deletion - if (passkeyMarkedForDeletion) { - await dbContext.sqliteClient!.deletePasskeysByCredentialId(credentialToSave.Id); + if (passkeyIdsMarkedForDeletion.length > 0) { + for (const passkeyId of passkeyIdsMarkedForDeletion) { + await dbContext.sqliteClient!.deletePasskeyById(passkeyId); + } } } else { - const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave, attachments, totpCodes); - credentialToSave.Id = credentialId; + await dbContext.sqliteClient!.createItem(itemToSave, attachments, totpCodes); } - // Emit an event to notify list and detail views to refresh - emitter.emit('credentialChanged', credentialToSave.Id); + // Emit event to notify list and detail views to refresh + emitter.emit('credentialChanged', itemToSave.Id); }, { - /** - * Handle successful vault mutation. - */ + /** Handle successful vault mutation. */ onSuccess: () => { - // Reset unsaved changes flag to allow dismissal without confirmation setHasUnsavedChanges(false); - // If this was created from autofill (serviceUrl param), show confirmation screen if (serviceUrl && !isEditMode) { - router.replace('/credentials/autofill-credential-created'); + router.replace('/items/autofill-item-created'); } else { setIsSyncing(false); setIsSaveDisabled(false); - - // First close the modal router.dismiss(); - // Then navigate after a short delay to ensure the modal has closed setTimeout(() => { if (isEditMode) { Toast.show({ @@ -436,23 +463,18 @@ export default function AddEditCredentialScreen() : React.ReactNode { text1: t('credentials.toasts.credentialUpdated'), position: 'bottom' }); - - // Do not navigate away, the original screen will update itself after modal is closed. } else { Toast.show({ type: 'success', text1: t('credentials.toasts.credentialCreated'), position: 'bottom' }); - - router.push(`/credentials/${credentialToSave.Id}`); + router.push(`/items/${itemToSave.Id}`); } }, 100); } }, - /** - * Handle error during vault mutation. - */ + /** Handle error during vault mutation. */ onError: (error) => { Toast.show({ type: 'error', @@ -460,42 +482,37 @@ export default function AddEditCredentialScreen() : React.ReactNode { text2: error.message, position: 'bottom' }); - console.error('Error saving credential:', error.message); - + console.error('Error saving item:', error.message); setIsSyncing(false); setIsSaveDisabled(false); } }); - }, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, watch, setIsSaveDisabled, setIsSyncing, isSaveDisabled, t, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyMarkedForDeletion]); + }, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi, isSaveDisabled, item, fieldValues, applicableSystemFields, t, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyIdsMarkedForDeletion]); /** - * Generate a random username based on current identity fields, or completely random if fields are empty. + * Generate a random username based on current identity fields. */ const generateRandomUsername = useCallback(async () : Promise => { try { - const firstName = watch('Alias.FirstName') ?? ''; - const lastName = watch('Alias.LastName') ?? ''; - const nickName = watch('Alias.NickName') ?? ''; - const birthDate = watch('Alias.BirthDate') ?? ''; + const firstName = (fieldValues[FieldKey.AliasFirstName] as string) ?? ''; + const lastName = (fieldValues[FieldKey.AliasLastName] as string) ?? ''; + const birthDate = (fieldValues[FieldKey.AliasBirthdate] as string) ?? ''; let username: string; - // If alias fields are empty, generate a completely random username - if (!firstName && !lastName && !nickName && !birthDate) { + if (!firstName && !lastName && !birthDate) { const randomIdentity = await generateRandomIdentity(); username = randomIdentity.nickName; } else { - // Generate username based on current identity fields const usernameEmailGenerator = CreateUsernameEmailGenerator(); let gender = Gender.Other; try { - gender = watch('Alias.Gender') as Gender; + gender = (fieldValues[FieldKey.AliasGender] as string) as Gender; } catch { - // Gender parsing failed, default to other. + // Gender parsing failed, default to other } - // Parse birthDate, fallback to current date if invalid let parsedBirthDate = new Date(birthDate); if (!birthDate || isNaN(parsedBirthDate.getTime())) { parsedBirthDate = new Date(); @@ -504,22 +521,21 @@ export default function AddEditCredentialScreen() : React.ReactNode { const identity: Identity = { firstName, lastName, - nickName, + nickName: '', gender, birthDate: parsedBirthDate, - emailPrefix: watch('Alias.Email') ?? '', + emailPrefix: (fieldValues[FieldKey.LoginEmail] as string) ?? '', }; username = usernameEmailGenerator.generateUsername(identity); } - setValue('Username', username); - // Update the tracking for username + handleFieldChange(FieldKey.LoginUsername, username); setLastGeneratedValues(prev => ({ ...prev, username })); } catch (error) { console.error('Error generating random username:', error); } - }, [setValue, watch, setLastGeneratedValues, generateRandomIdentity]); + }, [fieldValues, generateRandomIdentity, handleFieldChange]); /** * Handle the delete button press. @@ -542,20 +558,16 @@ export default function AddEditCredentialScreen() : React.ReactNode { { text: t('common.delete'), style: "destructive", - /** - * Delete the credential. - */ + /** Delete the item. */ onPress: async () : Promise => { setIsSyncing(true); await executeVaultMutation(async () => { - await dbContext.sqliteClient!.deleteCredentialById(id); + await dbContext.sqliteClient!.trashItem(id); }); - // Emit an event to notify list views to refresh emitter.emit('credentialChanged', id); - // Show success toast setTimeout(() => { Toast.show({ type: 'success', @@ -565,12 +577,6 @@ export default function AddEditCredentialScreen() : React.ReactNode { }, 200); setIsSyncing(false); - - /* - * Navigate back to the root of the navigation stack. - * On Android, we need to go back twice since we're two levels deep. - * On iOS, this will dismiss the modal. - */ router.back(); router.back(); } @@ -586,7 +592,6 @@ export default function AddEditCredentialScreen() : React.ReactNode { if (Platform.OS !== 'ios') { return 0; } - const iosVersion = parseInt(Platform.Version as string, 10); return iosVersion >= 26 ? 72 : 52; }; @@ -643,9 +648,6 @@ export default function AddEditCredentialScreen() : React.ReactNode { headerRightButtonDisabled: { opacity: 0.5, }, - keyboardContainer: { - flex: 1, - }, modeButton: { alignItems: 'center', borderRadius: 6, @@ -702,9 +704,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { { text: t('credentials.unsavedChanges.discard'), style: 'destructive', - /** - * Discard button handler. - */ + /** Discard button handler. */ onPress: () : void => { setHasUnsavedChanges(false); router.back(); @@ -720,13 +720,8 @@ export default function AddEditCredentialScreen() : React.ReactNode { // Set header buttons useEffect(() => { navigation.setOptions({ - /** - * Header left button (iOS only). - */ ...(Platform.OS === 'ios' && { - /** - * Header left button. - */ + /** Header left button. */ headerLeft: () : React.ReactNode => ( ), }), - /** - * Header right button. - */ + /** Header right button. */ headerRight: () => ( @@ -753,7 +746,16 @@ export default function AddEditCredentialScreen() : React.ReactNode { ), }); - }, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton, styles.headerRightButtonDisabled, isSaveDisabled, t, handleCancel]); + }, [navigation, mode, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerLeftButtonText, styles.headerRightButton, styles.headerRightButtonDisabled, isSaveDisabled, t, handleCancel]); + + // Check for passkeys (in edit mode) + const hasPasskey = useMemo(() => { + return item?.HasPasskey ?? false; + }, [item]); + + if (!item) { + return null; + } return ( <> @@ -806,16 +808,19 @@ export default function AddEditCredentialScreen() : React.ReactNode { {t('credentials.service')} - { + setItem(prev => prev ? { ...prev, Name: value } : prev); + setHasUnsavedChanges(true); + }} label={t('credentials.serviceName')} required /> - handleFieldChange(FieldKey.LoginUrl, value)} label={t('credentials.serviceUrl')} /> @@ -824,19 +829,19 @@ export default function AddEditCredentialScreen() : React.ReactNode { {t('credentials.loginCredentials')} - {watch('HasPasskey') ? ( + {hasPasskey ? ( <> {/* When passkey exists: username, passkey, email, password */} - handleFieldChange(FieldKey.LoginUsername, value)} label={t('credentials.username')} buttons={[{ icon: "refresh", onPress: generateRandomUsername }]} /> - {!passkeyMarkedForDeletion && ( + {!passkeyIdsMarkedForDeletion.length && ( setPasskeyMarkedForDeletion(true)} + onPress={() => setPasskeyIdsMarkedForDeletion(['passkey'])} style={{ padding: 6, borderRadius: 4, @@ -873,26 +878,6 @@ export default function AddEditCredentialScreen() : React.ReactNode { /> - {watch('PasskeyRpId') && ( - - - {t('passkeys.site')}:{' '} - - {watch('PasskeyRpId')} - - - - )} - {watch('PasskeyDisplayName') && ( - - - {t('passkeys.displayName')}:{' '} - - {watch('PasskeyDisplayName')} - - - - )} {t('passkeys.helpText')} @@ -900,7 +885,7 @@ export default function AddEditCredentialScreen() : React.ReactNode { )} - {passkeyMarkedForDeletion && ( + {passkeyIdsMarkedForDeletion.length > 0 && ( setPasskeyMarkedForDeletion(false)} + onPress={() => setPasskeyIdsMarkedForDeletion([])} style={{ padding: 4 }} > )} setValue('Alias.Email', newValue)} + value={(fieldValues[FieldKey.LoginEmail] as string) ?? ''} + onChange={(newValue) => handleFieldChange(FieldKey.LoginEmail, newValue)} label={t('credentials.email')} /> handleFieldChange(FieldKey.LoginPassword, value)} label={t('credentials.password')} showPassword={isPasswordVisible} onShowPasswordChange={setIsPasswordVisible} @@ -958,13 +943,13 @@ export default function AddEditCredentialScreen() : React.ReactNode { <> {/* When no passkey: email, username, password */} setValue('Alias.Email', newValue)} + value={(fieldValues[FieldKey.LoginEmail] as string) ?? ''} + onChange={(newValue) => handleFieldChange(FieldKey.LoginEmail, newValue)} label={t('credentials.email')} /> - handleFieldChange(FieldKey.LoginUsername, value)} label={t('credentials.username')} buttons={[{ icon: "refresh", @@ -972,8 +957,8 @@ export default function AddEditCredentialScreen() : React.ReactNode { }]} /> handleFieldChange(FieldKey.LoginPassword, value)} label={t('credentials.password')} showPassword={isPasswordVisible} onShowPasswordChange={setIsPasswordVisible} @@ -1001,29 +986,24 @@ export default function AddEditCredentialScreen() : React.ReactNode { {hasAliasValues ? t('credentials.clearAliasFields') : t('credentials.generateRandomAlias')} - handleFieldChange(FieldKey.AliasFirstName, value)} label={t('credentials.firstName')} /> - handleFieldChange(FieldKey.AliasLastName, value)} label={t('credentials.lastName')} /> - - handleFieldChange(FieldKey.AliasGender, value)} label={t('credentials.gender')} /> - handleFieldChange(FieldKey.AliasBirthdate, value)} label={t('credentials.birthDate')} placeholder={t('credentials.birthDatePlaceholder')} /> @@ -1032,9 +1012,9 @@ export default function AddEditCredentialScreen() : React.ReactNode { {t('credentials.metadata')} - handleFieldChange(FieldKey.NotesContent, value)} label={t('credentials.notes')} multiline={true} numberOfLines={4} @@ -1075,4 +1055,4 @@ export default function AddEditCredentialScreen() : React.ReactNode { ); -} \ No newline at end of file +} diff --git a/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx b/apps/mobile-app/app/(tabs)/items/autofill-item-created.tsx similarity index 100% rename from apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx rename to apps/mobile-app/app/(tabs)/items/autofill-item-created.tsx diff --git a/apps/mobile-app/app/(tabs)/credentials/email/[id].tsx b/apps/mobile-app/app/(tabs)/items/email/[id].tsx similarity index 100% rename from apps/mobile-app/app/(tabs)/credentials/email/[id].tsx rename to apps/mobile-app/app/(tabs)/items/email/[id].tsx diff --git a/apps/mobile-app/app/(tabs)/credentials/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx similarity index 76% rename from apps/mobile-app/app/(tabs)/credentials/index.tsx rename to apps/mobile-app/app/(tabs)/items/index.tsx index d1ad8b3b2..9302f4299 100644 --- a/apps/mobile-app/app/(tabs)/credentials/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -8,7 +8,8 @@ import { StyleSheet, Text, FlatList, TouchableOpacity, TextInput, RefreshControl import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; @@ -20,8 +21,8 @@ import { useVaultSync } from '@/hooks/useVaultSync'; type FilterType = 'all' | 'passkeys' | 'aliases' | 'userpass' | 'attachments'; import Logo from '@/assets/images/logo.svg'; -import { CredentialCard } from '@/components/credentials/CredentialCard'; -import { ServiceUrlNotice } from '@/components/credentials/ServiceUrlNotice'; +import { ItemCard } from '@/components/items/ItemCard'; +import { ServiceUrlNotice } from '@/components/items/ServiceUrlNotice'; import LoadingOverlay from '@/components/LoadingOverlay'; import { ThemedContainer } from '@/components/themed/ThemedContainer'; import { ThemedText } from '@/components/themed/ThemedText'; @@ -34,9 +35,9 @@ import { useApp } from '@/context/AppContext'; import { useDb } from '@/context/DbContext'; /** - * Credentials screen. + * Items screen. */ -export default function CredentialsScreen() : React.ReactNode { +export default function ItemsScreen() : React.ReactNode { const [searchQuery, setSearchQuery] = useState(''); const { syncVault } = useVaultSync(); const colors = useColors(); @@ -47,8 +48,8 @@ export default function CredentialsScreen() : React.ReactNode { const [isTabFocused, setIsTabFocused] = useState(false); const router = useRouter(); const { serviceUrl: serviceUrlParam } = useLocalSearchParams<{ serviceUrl?: string }>(); - const [credentialsList, setCredentialsList] = useState([]); - const [isLoadingCredentials, setIsLoadingCredentials] = useMinDurationLoading(false, 200); + const [itemsList, setItemsList] = useState([]); + const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200); const [refreshing, setRefreshing] = useMinDurationLoading(false, 200); const [serviceUrl, setServiceUrl] = useState(null); const insets = useSafeAreaInsets(); @@ -64,23 +65,23 @@ export default function CredentialsScreen() : React.ReactNode { const isDatabaseAvailable = dbContext.dbAvailable; /** - * Load credentials. + * Load items (credentials). */ - const loadCredentials = useCallback(async () : Promise => { + const loadItems = useCallback(async (): Promise => { try { - const credentials = await dbContext.sqliteClient!.getAllCredentials(); - setCredentialsList(credentials); - setIsLoadingCredentials(false); + const items = await dbContext.sqliteClient!.getAllItems(); + setItemsList(items); + setIsLoadingItems(false); } catch (err) { - // Error loading credentials, show error toast + // Error loading items, show error toast Toast.show({ type: 'error', - text1: t('credentials.errorLoadingCredentials'), + text1: t('items.errorLoadingItems'), text2: err instanceof Error ? err.message : 'Unknown error', }); - setIsLoadingCredentials(false); + setIsLoadingItems(false); } - }, [dbContext.sqliteClient, setIsLoadingCredentials, t]); + }, [dbContext.sqliteClient, setIsLoadingItems, t]); useEffect(() => { const unsubscribeFocus = navigation.addListener('focus', () => { @@ -100,18 +101,18 @@ export default function CredentialsScreen() : React.ReactNode { } }); - // Add listener for credential changes - const credentialChangedSub = emitter.addListener('credentialChanged', async () => { - await loadCredentials(); + // Add listener for item/credential changes + const itemChangedSub = emitter.addListener('credentialChanged', async () => { + await loadItems(); }); - return () : void => { + return (): void => { tabPressSub.remove(); - credentialChangedSub.remove(); + itemChangedSub.remove(); unsubscribeFocus(); unsubscribeBlur(); }; - }, [isTabFocused, loadCredentials, navigation, setRefreshing]); + }, [isTabFocused, loadItems, navigation, setRefreshing]); const onRefresh = useCallback(async () => { // Trigger haptic feedback when pull-to-refresh is activated @@ -122,13 +123,13 @@ export default function CredentialsScreen() : React.ReactNode { } setRefreshing(true); - setIsLoadingCredentials(true); + setIsLoadingItems(true); // Check if we are in offline mode, if so, we don't need to refresh the credentials const isOffline = authContext.isOffline; if (isOffline) { setRefreshing(false); - setIsLoadingCredentials(false); + setIsLoadingItems(false); return; } @@ -140,13 +141,13 @@ export default function CredentialsScreen() : React.ReactNode { */ onSuccess: async (hasNewVault) => { // Calculate remaining time needed to reach minimum duration - await loadCredentials(); - setIsLoadingCredentials(false); + await loadItems(); + setIsLoadingItems(false); setRefreshing(false); setTimeout(() => { Toast.show({ type: 'success', - text1: hasNewVault ? t('credentials.vaultSyncedSuccessfully') : t('credentials.vaultUpToDate'), + text1: hasNewVault ? t('items.vaultSyncedSuccessfully') : t('items.vaultUpToDate'), position: 'top', visibilityTime: 1200, }); @@ -157,12 +158,12 @@ export default function CredentialsScreen() : React.ReactNode { */ onOffline: () => { setRefreshing(false); - setIsLoadingCredentials(false); + setIsLoadingItems(false); authContext.setOfflineMode(true); setTimeout(() => { Toast.show({ type: 'error', - text1: t('credentials.offlineMessage'), + text1: t('items.offlineMessage'), position: 'bottom', }); }, 200); @@ -173,7 +174,7 @@ export default function CredentialsScreen() : React.ReactNode { onError: async (error) => { console.error('Error syncing vault:', error); setRefreshing(false); - setIsLoadingCredentials(false); + setIsLoadingItems(false); /** * Authentication errors are handled in useVaultSync @@ -193,29 +194,29 @@ export default function CredentialsScreen() : React.ReactNode { }, }); } catch (err) { - console.error('Error refreshing credentials:', err); + console.error('Error refreshing items:', err); setRefreshing(false); - setIsLoadingCredentials(false); + setIsLoadingItems(false); // Authentication errors are already handled in useVaultSync if (!(err instanceof VaultAuthenticationError)) { Toast.show({ type: 'error', - text1: t('credentials.vaultSyncFailed'), + text1: t('items.vaultSyncFailed'), text2: err instanceof Error ? err.message : 'Unknown error', }); } } - }, [syncVault, loadCredentials, setIsLoadingCredentials, setRefreshing, authContext, router, t]); + }, [syncVault, loadItems, setIsLoadingItems, setRefreshing, authContext, router, t]); useEffect(() => { if (!isAuthenticated || !isDatabaseAvailable) { return; } - setIsLoadingCredentials(true); - loadCredentials(); - }, [isAuthenticated, isDatabaseAvailable, loadCredentials, setIsLoadingCredentials]); + setIsLoadingItems(true); + loadItems(); + }, [isAuthenticated, isDatabaseAvailable, loadItems, setIsLoadingItems]); /** * Get the title based on the active filter @@ -223,49 +224,57 @@ export default function CredentialsScreen() : React.ReactNode { const getFilterTitle = useCallback(() : string => { switch (filterType) { case 'passkeys': - return t('credentials.filters.passkeys'); + return t('items.filters.passkeys'); case 'aliases': - return t('credentials.filters.aliases'); + return t('items.filters.aliases'); case 'userpass': - return t('credentials.filters.userpass'); + return t('items.filters.userpass'); case 'attachments': - return t('credentials.filters.attachments'); + return t('items.filters.attachments'); default: - return t('credentials.title'); + return t('items.title'); } }, [filterType, t]); - const filteredCredentials = credentialsList.filter(credential => { + const filteredItems = itemsList.filter(item => { // First apply type filter let passesTypeFilter = true; if (filterType === 'passkeys') { - passesTypeFilter = credential.HasPasskey === true; + passesTypeFilter = item.HasPasskey === true; } else if (filterType === 'aliases') { // Check for non-empty alias fields (excluding email which is used everywhere) + const firstName = getFieldValue(item, FieldKey.AliasFirstName); + const lastName = getFieldValue(item, FieldKey.AliasLastName); + const gender = getFieldValue(item, FieldKey.AliasGender); + const birthDate = getFieldValue(item, FieldKey.AliasBirthdate); passesTypeFilter = !!( - (credential.Alias?.FirstName && credential.Alias.FirstName.trim()) || - (credential.Alias?.LastName && credential.Alias.LastName.trim()) || - (credential.Alias?.NickName && credential.Alias.NickName.trim()) || - (credential.Alias?.Gender && credential.Alias.Gender.trim()) || - (credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true) + (firstName && firstName.trim()) || + (lastName && lastName.trim()) || + (gender && gender.trim()) || + (birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01')) ); } else if (filterType === 'userpass') { - // Show only credentials that have username/password AND do NOT have alias fields AND do NOT have passkey + // Show only items that have username/password AND do NOT have alias fields AND do NOT have passkey + const firstName = getFieldValue(item, FieldKey.AliasFirstName); + const lastName = getFieldValue(item, FieldKey.AliasLastName); + const gender = getFieldValue(item, FieldKey.AliasGender); + const birthDate = getFieldValue(item, FieldKey.AliasBirthdate); const hasAliasFields = !!( - (credential.Alias?.FirstName && credential.Alias.FirstName.trim()) || - (credential.Alias?.LastName && credential.Alias.LastName.trim()) || - (credential.Alias?.NickName && credential.Alias.NickName.trim()) || - (credential.Alias?.Gender && credential.Alias.Gender.trim()) || - (credential.Alias?.BirthDate && credential.Alias.BirthDate.trim() && credential.Alias.BirthDate.trim().startsWith('0001-01-01') !== true) + (firstName && firstName.trim()) || + (lastName && lastName.trim()) || + (gender && gender.trim()) || + (birthDate && birthDate.trim() && !birthDate.trim().startsWith('0001-01-01')) ); + const username = getFieldValue(item, FieldKey.LoginUsername); + const password = getFieldValue(item, FieldKey.LoginPassword); const hasUsernameOrPassword = !!( - (credential.Username && credential.Username.trim()) || - (credential.Password && credential.Password.trim()) + (username && username.trim()) || + (password && password.trim()) ); - passesTypeFilter = hasUsernameOrPassword && !credential.HasPasskey && !hasAliasFields; + passesTypeFilter = hasUsernameOrPassword && !item.HasPasskey && !hasAliasFields; } else if (filterType === 'attachments') { - passesTypeFilter = credential.HasAttachment === true; + passesTypeFilter = item.HasAttachment === true; } if (!passesTypeFilter) { @@ -280,19 +289,19 @@ export default function CredentialsScreen() : React.ReactNode { } /** - * We filter credentials by searching in the following fields: - * - Service name + * We filter items by searching in the following fields: + * - Item name * - Username - * - Alias email - * - Service URL + * - Email + * - URL * - Notes */ const searchableFields = [ - credential.ServiceName?.toLowerCase() || '', - credential.Username?.toLowerCase() || '', - credential.Alias?.Email?.toLowerCase() || '', - credential.ServiceUrl?.toLowerCase() || '', - credential.Notes?.toLowerCase() || '', + item.Name?.toLowerCase() || '', + getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '', + getFieldValue(item, FieldKey.LoginEmail)?.toLowerCase() || '', + getFieldValue(item, FieldKey.LoginUrl)?.toLowerCase() || '', + getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '', ]; // Split search term into words for AND search @@ -431,7 +440,7 @@ export default function CredentialsScreen() : React.ReactNode { if (Platform.OS === 'android') { return ( ); } - return {t('credentials.title')}; + return {t('items.title')}; }, }); - }, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredCredentials.length]); + }, [navigation, t, filterType, showFilterMenu, getFilterTitle, filteredItems.length]); /** - * Delete a credential. + * Delete an item (move to trash). */ - const onCredentialDelete = useCallback(async (credentialId: string) : Promise => { + const onItemDelete = useCallback(async (itemId: string): Promise => { setIsSyncing(true); await executeVaultMutation(async () => { - await dbContext.sqliteClient!.deleteCredentialById(credentialId); + await dbContext.sqliteClient!.trashItem(itemId); setIsSyncing(false); }); // Refresh list after deletion with a small delay to ensure feedback is visible. await new Promise(resolve => setTimeout(resolve, 250)); - await loadCredentials(); - }, [dbContext.sqliteClient, executeVaultMutation, loadCredentials]); + await loadItems(); + }, [dbContext.sqliteClient, executeVaultMutation, loadItems]); // Handle deep link parameters useFocusEffect( @@ -483,7 +492,7 @@ export default function CredentialsScreen() : React.ReactNode { )} { - router.push('/(tabs)/credentials/add-edit'); + router.push('/(tabs)/items/add-edit'); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }} > @@ -500,8 +509,8 @@ export default function CredentialsScreen() : React.ReactNode { item?.Id ?? `skeleton-${index}`} + data={isLoadingItems ? Array(4).fill(null) : filteredItems} + keyExtractor={(itm, index) => itm?.Id ?? `skeleton-${index}`} keyboardShouldPersistTaps='handled' onScroll={Animated.event( [{ nativeEvent: { contentOffset: { y: scrollY } } }], @@ -526,7 +535,7 @@ export default function CredentialsScreen() : React.ReactNode { {getFilterTitle()} - ({filteredCredentials.length}) + ({filteredItems.length}) - {t('credentials.filters.all')} + {t('items.filters.all')} - {t('credentials.filters.passkeys')} + {t('items.filters.passkeys')} - {t('credentials.filters.aliases')} + {t('items.filters.aliases')} - {t('credentials.filters.userpass')} + {t('items.filters.userpass')} - {t('credentials.filters.attachments')} + {t('items.filters.attachments')} @@ -639,7 +648,7 @@ export default function CredentialsScreen() : React.ReactNode { /> } - renderItem={({ item }) => - isLoadingCredentials ? ( + renderItem={({ item: itm }) => + isLoadingItems ? ( ) : ( - + ) } ListEmptyComponent={ - !isLoadingCredentials ? ( + !isLoadingItems ? ( {searchQuery - ? t('credentials.noMatchingCredentials') + ? t('items.noMatchingItems') : filterType === 'passkeys' - ? t('credentials.noPasskeysFound') + ? t('items.noPasskeysFound') : filterType === 'attachments' - ? t('credentials.noAttachmentsFound') - : t('credentials.noCredentialsFound') + ? t('items.noAttachmentsFound') + : t('items.noItemsFound') } ) : null } /> - {isLoading && } + {isLoading && } ); } \ No newline at end of file diff --git a/apps/mobile-app/app/index.tsx b/apps/mobile-app/app/index.tsx index 51bfac337..25480ca35 100644 --- a/apps/mobile-app/app/index.tsx +++ b/apps/mobile-app/app/index.tsx @@ -1,10 +1,10 @@ import { Redirect } from 'expo-router'; /** - * App index which is the entry point of the app and redirects to the credentials screen. + * App index which is the entry point of the app and redirects to the items screen. * If user is not logged in, they will automatically be redirected to the login screen instead * by global navigation handlers. */ export default function AppIndex() : React.ReactNode { - return ; + return ; } \ No newline at end of file diff --git a/apps/mobile-app/app/login.tsx b/apps/mobile-app/app/login.tsx index dcfc0a204..b26327c1c 100644 --- a/apps/mobile-app/app/login.tsx +++ b/apps/mobile-app/app/login.tsx @@ -235,7 +235,7 @@ export default function LoginScreen() : React.ReactNode { setPasswordHashBase64(null); setInitiateLoginResponse(null); setLoginStatus(null); - router.replace('/(tabs)/credentials'); + router.replace('/(tabs)/items'); setIsLoading(false); }; diff --git a/apps/mobile-app/app/open/[...path].tsx b/apps/mobile-app/app/open/[...path].tsx index a9818ab46..711a97f11 100644 --- a/apps/mobile-app/app/open/[...path].tsx +++ b/apps/mobile-app/app/open/[...path].tsx @@ -29,8 +29,8 @@ export default function ActionHandler() : null { const pathArray = Array.isArray(pathSegments) ? pathSegments : pathSegments ? [pathSegments] : []; if (pathArray.length === 0) { - // No action specified, go to credentials - router.replace('/(tabs)/credentials'); + // No action specified, go to items + router.replace('/(tabs)/items'); setHasNavigated(true); return; } @@ -56,9 +56,9 @@ export default function ActionHandler() : null { } default: - // Unknown action, log and go to credentials + // Unknown action, log and go to items console.warn('[ActionHandler] Unknown action:', action); - router.replace('/(tabs)/credentials'); + router.replace('/(tabs)/items'); setHasNavigated(true); break; } diff --git a/apps/mobile-app/app/upgrade.tsx b/apps/mobile-app/app/upgrade.tsx index c32923feb..7256891ca 100644 --- a/apps/mobile-app/app/upgrade.tsx +++ b/apps/mobile-app/app/upgrade.tsx @@ -230,24 +230,24 @@ export default function UpgradeScreen() : React.ReactNode { */ onStatus: (message) => setUpgradeStatus(message), /** - * Handle successful vault sync and navigate to credentials. + * Handle successful vault sync and navigate to items. */ onSuccess: () => { - router.replace('/(tabs)/credentials'); + router.replace('/(tabs)/items'); }, /** - * Handle sync error and still navigate to credentials. + * Handle sync error and still navigate to items. */ onError: (error) => { console.error('Sync error after upgrade:', error); - // Still navigate to credentials even if sync fails - router.replace('/(tabs)/credentials'); + // Still navigate to items even if sync fails + router.replace('/(tabs)/items'); } }); } catch (error) { console.error('Error during post-upgrade flow:', error); - // Navigate to credentials anyway - router.replace('/(tabs)/credentials'); + // Navigate to items anyway + router.replace('/(tabs)/items'); } }; diff --git a/apps/mobile-app/components/form/AdvancedPasswordField.tsx b/apps/mobile-app/components/form/AdvancedPasswordField.tsx index b416726d1..74b9b2f43 100644 --- a/apps/mobile-app/components/form/AdvancedPasswordField.tsx +++ b/apps/mobile-app/components/form/AdvancedPasswordField.tsx @@ -1,7 +1,6 @@ import { MaterialIcons } from '@expo/vector-icons'; import Slider from '@react-native-community/slider'; import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState, useCallback, useEffect } from 'react'; -import { Controller, Control, FieldValues, Path } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { View, TextInput, TextInputProps, StyleSheet, TouchableOpacity, Platform, Modal, ScrollView, Switch } from 'react-native'; @@ -18,20 +17,20 @@ export type AdvancedPasswordFieldRef = { selectAll: () => void; }; -type AdvancedPasswordFieldProps = Omit & { +type AdvancedPasswordFieldProps = Omit & { label: string; - name: Path; - control: Control; + value: string; + onChangeText: (value: string) => void; required?: boolean; showPassword?: boolean; onShowPasswordChange?: (show: boolean) => void; isNewCredential?: boolean; } -const AdvancedPasswordFieldComponent = forwardRef>(({ +const AdvancedPasswordFieldComponent = forwardRef(({ label, - name, - control, + value, + onChangeText, required, showPassword: controlledShowPassword, onShowPasswordChange, @@ -45,12 +44,10 @@ const AdvancedPasswordFieldComponent = forwardRef(null); const [previewPassword, setPreviewPassword] = useState(''); - const [sliderValue, setSliderValue] = useState(16); // Default until loaded from DB - const fieldOnChangeRef = useRef<((value: string) => void) | null>(null); + const [sliderValue, setSliderValue] = useState(16); const lastGeneratedLength = useRef(0); const isSliding = useRef(false); const hasSetInitialLength = useRef(false); - const currentPasswordRef = useRef(''); const dbContext = useDb(); const showPassword = controlledShowPassword ?? internalShowPassword; @@ -69,7 +66,6 @@ const AdvancedPasswordFieldComponent = forwardRef { + if (!hasSetInitialLength.current) { + if (!isNewCredential && value && value.length > 0) { + setSliderValue(value.length); + hasSetInitialLength.current = true; + } else if (isNewCredential) { + hasSetInitialLength.current = true; + } + } + }, [value, isNewCredential]); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), selectAll: () => { const input = inputRef.current; - if (input && input.props.value) { - input.setSelection(0, String(input.props.value).length); + if (input && value) { + input.setSelection(0, value.length); } } - }), []); + }), [value]); const generatePassword = useCallback((settings: PasswordSettings): string => { try { @@ -102,52 +109,49 @@ const AdvancedPasswordFieldComponent = forwardRef { - if (fieldOnChangeRef.current && currentSettings) { + if (currentSettings) { const password = generatePassword(currentSettings); if (password) { - fieldOnChangeRef.current(password); + onChangeText(password); setShowPasswordState(true); } } - }, [currentSettings, generatePassword, setShowPasswordState]); + }, [currentSettings, generatePassword, onChangeText, setShowPasswordState]); - const handleSliderChange = useCallback((value: number) => { - const roundedLength = Math.round(value); + const handleSliderChange = useCallback((sliderVal: number) => { + const roundedLength = Math.round(sliderVal); setSliderValue(roundedLength); - // Only generate if value actually changed and we're actively sliding if (roundedLength !== lastGeneratedLength.current && isSliding.current) { lastGeneratedLength.current = roundedLength; - // Show password when sliding if (!showPassword) { setShowPasswordState(true); } const newSettings = { ...(currentSettings || {}), Length: roundedLength } as PasswordSettings; - if (fieldOnChangeRef.current && currentSettings) { + if (currentSettings) { const password = generatePassword(newSettings); if (password) { - fieldOnChangeRef.current(password); + onChangeText(password); } } } - }, [currentSettings, generatePassword, showPassword, setShowPasswordState]); + }, [currentSettings, generatePassword, showPassword, setShowPasswordState, onChangeText]); const handleSliderStart = useCallback(() => { isSliding.current = true; - // Initialize lastGeneratedLength when starting to slide lastGeneratedLength.current = sliderValue; }, [sliderValue]); - const handleSliderComplete = useCallback((value: number) => { + const handleSliderComplete = useCallback((sliderVal: number) => { isSliding.current = false; - const roundedLength = Math.round(value); + const roundedLength = Math.round(sliderVal); if (currentSettings) { const newSettings = { ...currentSettings, Length: roundedLength }; setCurrentSettings(newSettings); } - lastGeneratedLength.current = 0; // Reset for next sliding session + lastGeneratedLength.current = 0; }, [currentSettings]); const handleRefreshPreview = useCallback(() => { @@ -158,12 +162,12 @@ const AdvancedPasswordFieldComponent = forwardRef { - if (fieldOnChangeRef.current && previewPassword) { - fieldOnChangeRef.current(previewPassword); + if (previewPassword) { + onChangeText(previewPassword); setShowPasswordState(true); setShowSettingsModal(false); } - }, [previewPassword, setShowPasswordState]); + }, [previewPassword, onChangeText, setShowPasswordState]); const handleOpenSettings = useCallback(() => { if (currentSettings) { @@ -173,10 +177,10 @@ const AdvancedPasswordFieldComponent = forwardRef { + const updateSetting = useCallback((key: keyof PasswordSettings, settingValue: boolean) => { setCurrentSettings(prev => { if (!prev) return prev; - const newSettings = { ...prev, [key]: value }; + const newSettings = { ...prev, [key]: settingValue }; const password = generatePassword(newSettings); setPreviewPassword(password); return newSettings; @@ -217,9 +221,6 @@ const AdvancedPasswordFieldComponent = forwardRef 0; + return ( - { - fieldOnChangeRef.current = onChange; - currentPasswordRef.current = value as string || ''; + + + {label} {required && *} + - // Use useEffect to update slider value when password value changes - // This avoids setState during render - useEffect(() => { - if (!hasSetInitialLength.current) { - if (!isNewCredential && value && typeof value === 'string' && value.length > 0) { - // Editing existing credential: use actual password length - setSliderValue(value.length); - hasSetInitialLength.current = true; - } else if (isNewCredential) { - // New credential: settings default is already set - hasSetInitialLength.current = true; - } - } - }, [value]); + + - const showClearButton = Platform.OS === 'android' && value && value.length > 0; + {showClearButton && ( + onChangeText('')} + activeOpacity={0.7} + > + + + )} - return ( - - - {label} {required && *} - + setShowPasswordState(!showPassword)} + activeOpacity={0.7} + > + + - - + + + + - {showClearButton && ( - onChange('')} - activeOpacity={0.7} - > - - - )} + + + {t('credentials.passwordLength')} + + {sliderValue} + + + + + + + + + setShowSettingsModal(false)} + > + + + + {t('credentials.changePasswordComplexity')} setShowPasswordState(!showPassword)} + style={styles.closeButton} + onPress={() => setShowSettingsModal(false)} activeOpacity={0.7} > - - - - - + - - - {t('credentials.passwordLength')} - - {sliderValue} + + + + - + - - + + + {t('credentials.includeLowercase')} + updateSetting('UseLowercase', settingValue)} + trackColor={{ false: colors.accentBorder, true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.background : undefined} + /> + - {error && {error.message}} + + {t('credentials.includeUppercase')} + updateSetting('UseUppercase', settingValue)} + trackColor={{ false: colors.accentBorder, true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.background : undefined} + /> + - setShowSettingsModal(false)} - > - - - - {t('credentials.changePasswordComplexity')} - setShowSettingsModal(false)} - activeOpacity={0.7} - > - - - + + {t('credentials.includeNumbers')} + updateSetting('UseNumbers', settingValue)} + trackColor={{ false: colors.accentBorder, true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.background : undefined} + /> + - - - - - - - - - + + {t('credentials.includeSpecialChars')} + updateSetting('UseSpecialChars', settingValue)} + trackColor={{ false: colors.accentBorder, true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.background : undefined} + /> + - - - {t('credentials.includeLowercase')} - updateSetting('UseLowercase', value)} - trackColor={{ false: colors.accentBorder, true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.background : undefined} - /> - - - - {t('credentials.includeUppercase')} - updateSetting('UseUppercase', value)} - trackColor={{ false: colors.accentBorder, true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.background : undefined} - /> - - - - {t('credentials.includeNumbers')} - updateSetting('UseNumbers', value)} - trackColor={{ false: colors.accentBorder, true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.background : undefined} - /> - - - - {t('credentials.includeSpecialChars')} - updateSetting('UseSpecialChars', value)} - trackColor={{ false: colors.accentBorder, true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.background : undefined} - /> - - - - {t('credentials.avoidAmbiguousChars')} - updateSetting('UseNonAmbiguousChars', value)} - trackColor={{ false: colors.accentBorder, true: colors.primary }} - thumbColor={Platform.OS === 'android' ? colors.background : undefined} - /> - - - - - - {t('common.use')} - - + + {t('credentials.avoidAmbiguousChars')} + updateSetting('UseNonAmbiguousChars', settingValue)} + trackColor={{ false: colors.accentBorder, true: colors.primary }} + thumbColor={Platform.OS === 'android' ? colors.background : undefined} + /> - + + + + {t('common.use')} + + - ); - }} - /> + + + ); }); AdvancedPasswordFieldComponent.displayName = 'AdvancedPasswordField'; -export const AdvancedPasswordField = AdvancedPasswordFieldComponent as (props: AdvancedPasswordFieldProps & { ref?: React.Ref }) => JSX.Element; \ No newline at end of file +export const AdvancedPasswordField = AdvancedPasswordFieldComponent; diff --git a/apps/mobile-app/components/form/FormField.tsx b/apps/mobile-app/components/form/FormField.tsx new file mode 100644 index 000000000..6eab32947 --- /dev/null +++ b/apps/mobile-app/components/form/FormField.tsx @@ -0,0 +1,152 @@ +import { MaterialIcons } from '@expo/vector-icons'; +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { View, TextInput, TextInputProps, StyleSheet, TouchableHighlight, Platform } from 'react-native'; + +import { useColors } from '@/hooks/useColorScheme'; + +import { ThemedText } from '@/components/themed/ThemedText'; + +type FormFieldButton = { + icon: keyof typeof MaterialIcons.glyphMap; + onPress: () => void; +} + +export type FormFieldRef = { + focus: () => void; + selectAll: () => void; +} + +type FormFieldProps = Omit & { + label: string; + value: string; + onChangeText: (value: string) => void; + required?: boolean; + buttons?: FormFieldButton[]; + error?: string; +} + +/** + * Simple form field component without react-hook-form. + */ +const FormFieldComponent = forwardRef(({ + label, + value, + onChangeText, + required, + buttons, + error, + ...props +}, ref) => { + const colors = useColors(); + const inputRef = useRef(null); + const [isFocused, setIsFocused] = useState(false); + + useImperativeHandle(ref, () => ({ + focus: (): void => { + inputRef.current?.focus(); + }, + selectAll: (): void => { + inputRef.current?.setSelection(0, (value || '').length); + } + })); + + const colorRed = 'red'; + + const styles = StyleSheet.create({ + button: { + borderLeftColor: colors.accentBorder, + borderLeftWidth: 1, + padding: 10, + }, + clearButton: { + borderRadius: 6, + marginRight: 4, + padding: 6, + }, + errorText: { + color: colorRed, + fontSize: 12, + marginTop: 4, + }, + input: { + color: colors.text, + flex: 1, + fontSize: 16, + marginRight: 5, + padding: 10, + }, + inputContainer: { + alignItems: 'center', + backgroundColor: colors.background, + borderColor: colors.accentBorder, + borderRadius: 6, + borderWidth: 1, + flexDirection: 'row', + }, + inputError: { + borderColor: colorRed, + }, + inputGroup: { + marginBottom: 6, + }, + inputLabel: { + color: colors.textMuted, + fontSize: 12, + marginBottom: 4, + }, + requiredIndicator: { + color: colorRed, + marginLeft: 4, + }, + }); + + const showClearButton = Platform.OS === 'android' && value && value.length > 0 && isFocused; + + return ( + + + {label} {required && *} + + + setIsFocused(true)} + onBlur={() => setIsFocused(false)} + {...props} + /> + {showClearButton && ( + onChangeText('')} + underlayColor={colors.accentBackground} + > + + + )} + {buttons?.map((button, index) => ( + + + + ))} + + {error && {error}} + + ); +}); + +FormFieldComponent.displayName = 'FormField'; + +export const FormField = FormFieldComponent; diff --git a/apps/mobile-app/components/credentials/CredentialCard.tsx b/apps/mobile-app/components/items/ItemCard.tsx similarity index 51% rename from apps/mobile-app/components/credentials/CredentialCard.tsx rename to apps/mobile-app/components/items/ItemCard.tsx index 618f033b9..8ac784162 100644 --- a/apps/mobile-app/components/credentials/CredentialCard.tsx +++ b/apps/mobile-app/components/items/ItemCard.tsx @@ -2,60 +2,59 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { router } from 'expo-router'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Text, TouchableOpacity, Keyboard, Platform, Alert } from 'react-native'; -import ContextMenu, { OnPressMenuItemEvent } from 'react-native-context-menu-view'; +import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view'; +import type { NativeSyntheticEvent } from 'react-native'; import Toast from 'react-native-toast-message'; import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import { useColors } from '@/hooks/useColorScheme'; -import { CredentialIcon } from '@/components/credentials/CredentialIcon'; +import { ItemIcon } from '@/components/items/ItemIcon'; import { useAuth } from '@/context/AuthContext'; -type CredentialCardProps = { - credential: Credential; - onCredentialDelete?: (credentialId: string) => Promise; +type ItemCardProps = { + item: Item; + onItemDelete?: (itemId: string) => Promise; }; /** - * Credential card component. + * Item card component for displaying vault items in a list. */ -export function CredentialCard({ credential, onCredentialDelete }: CredentialCardProps) : React.ReactNode { +export function ItemCard({ item, onItemDelete }: ItemCardProps): React.ReactNode { const colors = useColors(); const { t } = useTranslation(); const { getClipboardClearTimeout } = useAuth(); /** - * Get the display text for a credential, showing username by default, + * Get the display text for an item, showing username by default, * falling back to email only if username is null/undefined/empty */ - const getCredentialDisplayText = (cred: Credential): string => { - let returnValue = ''; - + const getItemDisplayText = (itm: Item): string => { // Show username if available - if (cred.Username) { - returnValue = cred.Username; + const username = getFieldValue(itm, FieldKey.LoginUsername); + if (username) { + // Trim the return value to max. 38 characters. + return username.length > 38 ? username.slice(0, 35) + '...' : username; } // Show email if username is not available - if (cred.Alias?.Email) { - returnValue = cred.Alias.Email; + const email = getFieldValue(itm, FieldKey.LoginEmail); + if (email) { + // Trim the return value to max. 38 characters. + return email.length > 38 ? email.slice(0, 35) + '...' : email; } - // Trim the return value to max. 38 characters. - return returnValue.length > 38 ? returnValue.slice(0, 35) + '...' : returnValue; + return ''; }; /** - * Get the service name for a credential, trimming it to maximum length so it doesn't overflow the UI. + * Get the item name, trimming it to maximum length so it doesn't overflow the UI. */ - const getCredentialServiceName = (cred: Credential): string => { - let returnValue = 'Untitled'; - - if (cred.ServiceName) { - returnValue = cred.ServiceName; - } + const getItemName = (itm: Item): string => { + const returnValue = itm.Name || t('items.untitled'); // Trim the return value to max. 33 characters. return returnValue.length > 33 ? returnValue.slice(0, 30) + '...' : returnValue; @@ -80,22 +79,22 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar * Handles the context menu action when an item is selected. * @param event - The event object containing the selected action details */ - const handleContextMenuAction = async (event: OnPressMenuItemEvent): Promise => { + const handleContextMenuAction = async (event: NativeSyntheticEvent): Promise => { const { name } = event.nativeEvent; switch (name) { - case t('credentials.contextMenu.edit'): + case t('items.contextMenu.edit'): Keyboard.dismiss(); router.push({ - pathname: '/(tabs)/credentials/add-edit', - params: { id: credential.Id } + pathname: '/(tabs)/items/add-edit', + params: { id: item.Id } }); break; - case t('credentials.contextMenu.delete'): + case t('items.contextMenu.delete'): Keyboard.dismiss(); Alert.alert( - t('credentials.deleteCredential'), - t('credentials.deleteConfirm'), + t('items.deleteItem'), + t('items.deleteConfirm'), [ { text: t('common.cancel'), @@ -105,50 +104,59 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar text: t('common.delete'), style: "destructive", /** - * Handles the delete credential action. + * Handles the delete item action. */ - onPress: async () : Promise => { - if (onCredentialDelete) { - await onCredentialDelete(credential.Id); + onPress: async (): Promise => { + if (onItemDelete) { + await onItemDelete(item.Id); } } } ] ); break; - case t('credentials.contextMenu.copyUsername'): - if (credential.Username) { - await copyToClipboard(credential.Username); - if (Platform.OS === 'ios') { - Toast.show({ - type: 'success', - text1: t('credentials.toasts.usernameCopied'), - position: 'bottom', - }); + case t('items.contextMenu.copyUsername'): + { + const username = getFieldValue(item, FieldKey.LoginUsername); + if (username) { + await copyToClipboard(username); + if (Platform.OS === 'ios') { + Toast.show({ + type: 'success', + text1: t('items.toasts.usernameCopied'), + position: 'bottom', + }); + } } } break; - case t('credentials.contextMenu.copyEmail'): - if (credential.Alias?.Email) { - await copyToClipboard(credential.Alias.Email); - if (Platform.OS === 'ios') { - Toast.show({ - type: 'success', - text1: t('credentials.toasts.emailCopied'), - position: 'bottom', - }); + case t('items.contextMenu.copyEmail'): + { + const email = getFieldValue(item, FieldKey.LoginEmail); + if (email) { + await copyToClipboard(email); + if (Platform.OS === 'ios') { + Toast.show({ + type: 'success', + text1: t('items.toasts.emailCopied'), + position: 'bottom', + }); + } } } break; - case t('credentials.contextMenu.copyPassword'): - if (credential.Password) { - await copyToClipboard(credential.Password); - if (Platform.OS === 'ios') { - Toast.show({ - type: 'success', - text1: t('credentials.toasts.passwordCopied'), - position: 'bottom', - }); + case t('items.contextMenu.copyPassword'): + { + const password = getFieldValue(item, FieldKey.LoginPassword); + if (password) { + await copyToClipboard(password); + if (Platform.OS === 'ios') { + Toast.show({ + type: 'success', + text1: t('items.toasts.passwordCopied'), + position: 'bottom', + }); + } } } break; @@ -156,7 +164,7 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar }; /** - * Gets the menu actions for the context menu based on available credential data. + * Gets the menu actions for the context menu based on available item data. * @returns Array of menu action objects with title and icon */ const getMenuActions = (): { @@ -164,50 +172,58 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar systemIcon: string; destructive?: boolean; }[] => { - const actions = [ + const actions: { title: string; systemIcon: string; destructive?: boolean }[] = [ { - title: t('credentials.contextMenu.edit'), + title: t('items.contextMenu.edit'), systemIcon: Platform.select({ ios: 'pencil', android: 'baseline_edit', + default: 'pencil', }), }, { - title: t('credentials.contextMenu.delete'), + title: t('items.contextMenu.delete'), systemIcon: Platform.select({ ios: 'trash', android: 'baseline_delete', + default: 'trash', }), destructive: true, }, ]; - if (credential.Username) { + const username = getFieldValue(item, FieldKey.LoginUsername); + if (username) { actions.push({ - title: t('credentials.contextMenu.copyUsername'), + title: t('items.contextMenu.copyUsername'), systemIcon: Platform.select({ ios: 'person', android: 'baseline_person', + default: 'person', }), }); } - if (credential.Alias?.Email) { + const email = getFieldValue(item, FieldKey.LoginEmail); + if (email) { actions.push({ - title: t('credentials.contextMenu.copyEmail'), + title: t('items.contextMenu.copyEmail'), systemIcon: Platform.select({ ios: 'envelope', android: 'baseline_email', + default: 'envelope', }), }); } - if (credential.Password) { + const password = getFieldValue(item, FieldKey.LoginPassword); + if (password) { actions.push({ - title: t('credentials.contextMenu.copyPassword'), + title: t('items.contextMenu.copyPassword'), systemIcon: Platform.select({ ios: 'key', android: 'baseline_key', + default: 'key', }), }); } @@ -222,26 +238,26 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar marginBottom: 8, padding: 12, }, - credentialContent: { + itemContent: { alignItems: 'center', flexDirection: 'row', }, - credentialInfo: { + itemInfo: { flex: 1, }, - credentialText: { + itemText: { color: colors.textMuted, fontSize: 14, }, + iconStyle: { + marginLeft: 6, + }, logo: { borderRadius: 4, height: 32, marginRight: 12, width: 32, }, - passkeyIcon: { - marginLeft: 6, - }, serviceName: { color: colors.text, fontSize: 16, @@ -265,39 +281,47 @@ export function CredentialCard({ credential, onCredentialDelete }: CredentialCar style={styles.credentialCard} onPress={() => { Keyboard.dismiss(); - router.push(`/(tabs)/credentials/${credential.Id}`); + router.push(`/(tabs)/items/${item.Id}`); }} onLongPress={() => { - // Ignore long press to prevent context menu long press from triggering the credential card press. + // Ignore long press to prevent context menu long press from triggering the item card press. }} activeOpacity={0.7} > - - - + + + - {getCredentialServiceName(credential)} + {getItemName(item)} - {credential.HasPasskey && ( + {item.HasPasskey && ( )} - {credential.HasAttachment && ( + {item.HasAttachment && ( + )} + {item.HasTotp && ( + )} - - {getCredentialDisplayText(credential)} + + {getItemDisplayText(item)} diff --git a/apps/mobile-app/components/credentials/CredentialIcon.tsx b/apps/mobile-app/components/items/ItemIcon.tsx similarity index 96% rename from apps/mobile-app/components/credentials/CredentialIcon.tsx rename to apps/mobile-app/components/items/ItemIcon.tsx index f744b5a3a..3292a4bf3 100644 --- a/apps/mobile-app/components/credentials/CredentialIcon.tsx +++ b/apps/mobile-app/components/items/ItemIcon.tsx @@ -6,17 +6,17 @@ import { SvgUri } from 'react-native-svg'; import servicePlaceholder from '@/assets/images/service-placeholder.webp'; /** - * Credential icon props. + * Item icon props. */ -type CredentialIconProps = { +type ItemIconProps = { logo?: Uint8Array | number[] | string | null; style?: ImageStyle; }; /** - * Credential icon component. + * Item icon component. */ -export function CredentialIcon({ logo, style }: CredentialIconProps) : React.ReactNode { +export function ItemIcon({ logo, style }: ItemIconProps) : React.ReactNode { /** * Get the logo source. */ diff --git a/apps/mobile-app/components/credentials/ServiceUrlNotice.tsx b/apps/mobile-app/components/items/ServiceUrlNotice.tsx similarity index 97% rename from apps/mobile-app/components/credentials/ServiceUrlNotice.tsx rename to apps/mobile-app/components/items/ServiceUrlNotice.tsx index 26e0cb0c0..a181b82e5 100644 --- a/apps/mobile-app/components/credentials/ServiceUrlNotice.tsx +++ b/apps/mobile-app/components/items/ServiceUrlNotice.tsx @@ -30,7 +30,7 @@ export function ServiceUrlNotice({ serviceUrl, onDismiss }: IServiceUrlNoticePro */ const handlePress = (): void => { router.push({ - pathname: '/(tabs)/credentials/add-edit', + pathname: '/(tabs)/items/add-edit', params: { serviceUrl } }); }; diff --git a/apps/mobile-app/components/credentials/details/AliasDetails.tsx b/apps/mobile-app/components/items/details/AliasDetails.tsx similarity index 52% rename from apps/mobile-app/components/credentials/details/AliasDetails.tsx rename to apps/mobile-app/components/items/details/AliasDetails.tsx index bf31ca6b9..93cc19aac 100644 --- a/apps/mobile-app/components/credentials/details/AliasDetails.tsx +++ b/apps/mobile-app/components/items/details/AliasDetails.tsx @@ -1,25 +1,30 @@ import { useTranslation } from 'react-i18next'; import { IdentityHelperUtils } from '@/utils/dist/core/identity-generator'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import FormInputCopyToClipboard from '@/components/form/FormInputCopyToClipboard'; import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; type AliasDetailsProps = { - credential: Credential; + item: Item; }; /** * Alias details component. */ -export const AliasDetails: React.FC = ({ credential }) : React.ReactNode => { +export const AliasDetails: React.FC = ({ item }) : React.ReactNode => { const { t } = useTranslation(); - const hasName = Boolean(credential.Alias?.FirstName?.trim() ?? credential.Alias?.LastName?.trim()); - const fullName = [credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' '); + const firstName = getFieldValue(item, FieldKey.AliasFirstName)?.trim(); + const lastName = getFieldValue(item, FieldKey.AliasLastName)?.trim(); + const birthDate = getFieldValue(item, FieldKey.AliasBirthdate); - if (!hasName && !credential.Alias?.NickName && !IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate)) { + const hasName = Boolean(firstName || lastName); + const fullName = [firstName, lastName].filter(Boolean).join(' '); + + if (!hasName && !IdentityHelperUtils.isValidBirthDate(birthDate)) { return null; } @@ -32,28 +37,22 @@ export const AliasDetails: React.FC = ({ credential }) : Reac value={fullName} /> )} - {credential.Alias?.FirstName && ( + {firstName && ( )} - {credential.Alias?.LastName && ( + {lastName && ( )} - {credential.Alias?.NickName && ( - - )} - {IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate) && ( + {IdentityHelperUtils.isValidBirthDate(birthDate) && ( )} diff --git a/apps/mobile-app/components/credentials/details/AttachmentSection.tsx b/apps/mobile-app/components/items/details/AttachmentSection.tsx similarity index 94% rename from apps/mobile-app/components/credentials/details/AttachmentSection.tsx rename to apps/mobile-app/components/items/details/AttachmentSection.tsx index c0e5f7e7e..40d398fab 100644 --- a/apps/mobile-app/components/credentials/details/AttachmentSection.tsx +++ b/apps/mobile-app/components/items/details/AttachmentSection.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native'; -import type { Credential, Attachment } from '@/utils/dist/core/models/vault'; +import type { Item, Attachment } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; import { useColors } from '@/hooks/useColorScheme'; @@ -19,13 +19,13 @@ import { useDb } from '@/context/DbContext'; import { FilePreviewModal } from './FilePreviewModal'; type AttachmentSectionProps = { - credential: Credential; + item: Item; }; /** * Attachment section component. */ -export const AttachmentSection: React.FC = ({ credential }): React.ReactNode => { +export const AttachmentSection: React.FC = ({ item }): React.ReactNode => { const [attachments, setAttachments] = useState([]); const [previewModalVisible, setPreviewModalVisible] = useState(false); const [selectedFile, setSelectedFile] = useState<{ @@ -176,26 +176,26 @@ export const AttachmentSection: React.FC = ({ credential } try { - const attachmentList = await dbContext.sqliteClient.getAttachmentsForCredential(credential.Id); + const attachmentList = await dbContext.sqliteClient.getAttachmentsForItem(item.Id); setAttachments(attachmentList); } catch (error) { console.error('Error loading attachments:', error); } - }, [credential.Id, dbContext?.sqliteClient]); + }, [item.Id, dbContext?.sqliteClient]); useEffect((): (() => void) => { loadAttachments(); - const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => { - if (changedId === credential.Id) { + const itemChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => { + if (changedId === item.Id) { await loadAttachments(); } }); return () => { - credentialChangedSub.remove(); + itemChangedSub.remove(); }; - }, [credential.Id, dbContext?.sqliteClient, loadAttachments]); + }, [item.Id, dbContext?.sqliteClient, loadAttachments]); if (attachments.length === 0) { return null; diff --git a/apps/mobile-app/components/credentials/details/AttachmentUploader.tsx b/apps/mobile-app/components/items/details/AttachmentUploader.tsx similarity index 99% rename from apps/mobile-app/components/credentials/details/AttachmentUploader.tsx rename to apps/mobile-app/components/items/details/AttachmentUploader.tsx index 52c59757c..0b5a362ef 100644 --- a/apps/mobile-app/components/credentials/details/AttachmentUploader.tsx +++ b/apps/mobile-app/components/items/details/AttachmentUploader.tsx @@ -78,7 +78,7 @@ export const AttachmentUploader: React.FC = ({ Id: crypto.randomUUID(), Filename: file.name, Blob: byteArray, - CredentialId: '', // Will be set when saving credential + ItemId: '', // Will be set when saving item CreatedAt: new Date().toISOString(), UpdatedAt: new Date().toISOString(), IsDeleted: false, diff --git a/apps/mobile-app/components/credentials/details/EmailPreview.tsx b/apps/mobile-app/components/items/details/EmailPreview.tsx similarity index 99% rename from apps/mobile-app/components/credentials/details/EmailPreview.tsx rename to apps/mobile-app/components/items/details/EmailPreview.tsx index eb79573b1..1713b4407 100644 --- a/apps/mobile-app/components/credentials/details/EmailPreview.tsx +++ b/apps/mobile-app/components/items/details/EmailPreview.tsx @@ -372,7 +372,7 @@ export const EmailPreview: React.FC = ({ email }) : React.Rea const emailPrefix = email.split('@')[0]; Linking.openURL(`https://spamok.com/${emailPrefix}/${mail.id}`); } else { - router.push(`/(tabs)/credentials/email/${mail.id}`); + router.push(`/(tabs)/items/email/${mail.id}`); } }} > diff --git a/apps/mobile-app/components/credentials/details/FilePreviewModal.tsx b/apps/mobile-app/components/items/details/FilePreviewModal.tsx similarity index 100% rename from apps/mobile-app/components/credentials/details/FilePreviewModal.tsx rename to apps/mobile-app/components/items/details/FilePreviewModal.tsx diff --git a/apps/mobile-app/components/credentials/details/LoginCredentials.tsx b/apps/mobile-app/components/items/details/LoginFields.tsx similarity index 66% rename from apps/mobile-app/components/credentials/details/LoginCredentials.tsx rename to apps/mobile-app/components/items/details/LoginFields.tsx index 44f0a5787..bf7aeb618 100644 --- a/apps/mobile-app/components/credentials/details/LoginCredentials.tsx +++ b/apps/mobile-app/components/items/details/LoginFields.tsx @@ -2,7 +2,8 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useTranslation } from 'react-i18next'; import { StyleSheet, View } from 'react-native'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import { useColors } from '@/hooks/useColorScheme'; @@ -10,22 +11,22 @@ import FormInputCopyToClipboard from '@/components/form/FormInputCopyToClipboard import { ThemedText } from '@/components/themed/ThemedText'; import { ThemedView } from '@/components/themed/ThemedView'; -type LoginCredentialsProps = { - credential: Credential; +type LoginFieldsProps = { + item: Item; }; /** - * Login credentials component. + * Login fields component. */ -export const LoginCredentials: React.FC = ({ credential }) : React.ReactNode => { +export const LoginFields: React.FC = ({ item }) : React.ReactNode => { const { t } = useTranslation(); const colors = useColors(); - const email = credential.Alias?.Email?.trim(); - const username = credential.Username?.trim(); - const password = credential.Password?.trim(); + const email = getFieldValue(item, FieldKey.LoginEmail)?.trim(); + const username = getFieldValue(item, FieldKey.LoginUsername)?.trim(); + const password = getFieldValue(item, FieldKey.LoginPassword)?.trim(); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const hasLoginCredentials = email || username || password || credential.HasPasskey; + const hasLoginCredentials = email || username || password || item.HasPasskey; if (!hasLoginCredentials) { return null; @@ -88,7 +89,7 @@ export const LoginCredentials: React.FC = ({ credential } value={username} /> )} - {credential.HasPasskey && ( + {item.HasPasskey && ( = ({ credential } {t('passkeys.passkey')} - {credential.PasskeyRpId && ( - - - {t('passkeys.site')}:{' '} - - - {credential.PasskeyRpId} - - - )} - {credential.PasskeyDisplayName && ( - - - {t('passkeys.displayName')}:{' '} - - - {credential.PasskeyDisplayName} - - - )} {t('passkeys.helpText')} diff --git a/apps/mobile-app/components/credentials/details/NotesSection.tsx b/apps/mobile-app/components/items/details/NotesSection.tsx similarity index 87% rename from apps/mobile-app/components/credentials/details/NotesSection.tsx rename to apps/mobile-app/components/items/details/NotesSection.tsx index fd16e30fa..d3ccdbbd8 100644 --- a/apps/mobile-app/components/credentials/details/NotesSection.tsx +++ b/apps/mobile-app/components/items/details/NotesSection.tsx @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next'; -import { View, Text, StyleSheet, Linking, Pressable } from 'react-native'; +import { View, Text, StyleSheet, Linking } from 'react-native'; -import type { Credential } from '@/utils/dist/core/models/vault'; +import type { Item } from '@/utils/dist/core/models/vault'; +import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault'; import { useColors } from '@/hooks/useColorScheme'; @@ -10,7 +11,7 @@ import { ThemedView } from '@/components/themed/ThemedView'; import { RobustPressable } from '@/components/ui/RobustPressable'; type NotesSectionProps = { - credential: Credential; + item: Item; }; /** @@ -64,15 +65,17 @@ const splitTextAndUrls = (text: string): { type: 'text' | 'url', content: string /** * Notes section component. */ -export const NotesSection: React.FC = ({ credential }) : React.ReactNode => { +export const NotesSection: React.FC = ({ item }) : React.ReactNode => { const { t } = useTranslation(); const colors = useColors(); - if (!credential.Notes) { + const notes = getFieldValue(item, FieldKey.NotesContent); + + if (!notes) { return null; } - const parts = splitTextAndUrls(credential.Notes); + const parts = splitTextAndUrls(notes); /** * Handle the link press. diff --git a/apps/mobile-app/components/credentials/details/TotpEditor.tsx b/apps/mobile-app/components/items/details/TotpEditor.tsx similarity index 99% rename from apps/mobile-app/components/credentials/details/TotpEditor.tsx rename to apps/mobile-app/components/items/details/TotpEditor.tsx index a832541df..4dce73900 100644 --- a/apps/mobile-app/components/credentials/details/TotpEditor.tsx +++ b/apps/mobile-app/components/items/details/TotpEditor.tsx @@ -110,7 +110,7 @@ export const TotpEditor: React.FC = ({ Id: crypto.randomUUID().toUpperCase(), Name: name, SecretKey: secretKey, - CredentialId: '' // Will be set when saving the credential + ItemId: '' // Will be set when saving the item }; // Add to the list diff --git a/apps/mobile-app/components/credentials/details/TotpSection.tsx b/apps/mobile-app/components/items/details/TotpSection.tsx similarity index 95% rename from apps/mobile-app/components/credentials/details/TotpSection.tsx rename to apps/mobile-app/components/items/details/TotpSection.tsx index 248ca4da7..bca4f9990 100644 --- a/apps/mobile-app/components/credentials/details/TotpSection.tsx +++ b/apps/mobile-app/components/items/details/TotpSection.tsx @@ -5,7 +5,7 @@ import { View, StyleSheet, TouchableOpacity, Platform } from 'react-native'; import Toast from 'react-native-toast-message'; import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility'; -import type { Credential, TotpCode } from '@/utils/dist/core/models/vault'; +import type { Item, TotpCode } from '@/utils/dist/core/models/vault'; import { useColors } from '@/hooks/useColorScheme'; @@ -15,13 +15,13 @@ import { useAuth } from '@/context/AuthContext'; import { useDb } from '@/context/DbContext'; type TotpSectionProps = { - credential: Credential; + item: Item; }; /** * Totp section component. */ -export const TotpSection: React.FC = ({ credential }) : React.ReactNode => { +export const TotpSection: React.FC = ({ item }) : React.ReactNode => { const [totpCodes, setTotpCodes] = useState([]); const [currentCodes, setCurrentCodes] = useState>({}); const colors = useColors(); @@ -103,7 +103,7 @@ export const TotpSection: React.FC = ({ credential }) : React. } try { - const codes = await dbContext.sqliteClient.getTotpCodesForCredential(credential.Id); + const codes = await dbContext.sqliteClient.getTotpCodesForItem(item.Id); setTotpCodes(codes); } catch (error) { console.error('Error loading TOTP codes:', error); @@ -111,7 +111,7 @@ export const TotpSection: React.FC = ({ credential }) : React. }; loadTotpCodes(); - }, [credential, dbContext?.sqliteClient]); + }, [item, dbContext?.sqliteClient]); useEffect(() => { /** diff --git a/apps/mobile-app/context/NavigationContext.tsx b/apps/mobile-app/context/NavigationContext.tsx index 7b290690a..409ea8d01 100644 --- a/apps/mobile-app/context/NavigationContext.tsx +++ b/apps/mobile-app/context/NavigationContext.tsx @@ -56,8 +56,8 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch return; } - // Priority 2: Default navigation to credentials - router.replace('/(tabs)/credentials'); + // Priority 2: Default navigation to items + router.replace('/(tabs)/items'); }, [returnUrl, router]); /** @@ -72,13 +72,13 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch const params = returnUrl.params || {}; // Check if this is a detail route (has a sub-page after the tab) - const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/'); + const isItemRoute = normalizedPath.includes('/(tabs)/items/'); const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') && !normalizedPath.endsWith('/(tabs)/settings'); - if (isCredentialRoute) { - // Navigate to credentials tab first, then push detail page - router.replace('/(tabs)/credentials'); + if (isItemRoute) { + // Navigate to items tab first, then push detail page + router.replace('/(tabs)/items'); setTimeout(() => { const queryParams = new URLSearchParams(params as Record).toString(); const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; @@ -110,7 +110,7 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch * * Supports: * - Action-based URLs: aliasvault://open/mobile-unlock/[id] - * - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page] + * - Direct routes: aliasvault://items/[id], aliasvault://settings/[page] */ const normalizeDeepLinkPath = (urlOrPath: string): string => { // Remove all URL schemes first @@ -124,8 +124,8 @@ export const NavigationProvider: React.FC<{ children: React.ReactNode }> = ({ ch return path; } - // Handle credential paths - if (path.startsWith('credentials/') || path.includes('/credentials/')) { + // Handle item paths + if (path.startsWith('items/') || path.includes('/items/')) { if (!path.startsWith('/')) { path = `/${path}`; } diff --git a/apps/mobile-app/i18n/locales/en.json b/apps/mobile-app/i18n/locales/en.json index 3930e5982..7d68e8593 100644 --- a/apps/mobile-app/i18n/locales/en.json +++ b/apps/mobile-app/i18n/locales/en.json @@ -429,9 +429,122 @@ }, "navigation": { "credentials": "Credentials", + "vault": "Vault", "emails": "Emails", "settings": "Settings" }, + "items": { + "title": "Items", + "addItem": "Add Item", + "editItem": "Edit Item", + "deleteItem": "Delete Item", + "itemDetails": "Item Details", + "itemCreated": "Item Created", + "emailPreview": "Email Preview", + "untitled": "Untitled", + "name": "Name", + "namePlaceholder": "e.g., Google, Amazon", + "url": "URL", + "urlPlaceholder": "e.g., https://google.com", + "service": "Service", + "serviceName": "Service Name", + "serviceUrl": "Service URL", + "loginCredentials": "Login credentials", + "username": "Username", + "email": "Email", + "alias": "Alias", + "metadata": "Metadata", + "firstName": "First Name", + "lastName": "Last Name", + "nickName": "Nick Name", + "fullName": "Full Name", + "gender": "Gender", + "birthDate": "Birth Date", + "birthDatePlaceholder": "YYYY-MM-DD", + "notes": "Notes", + "randomAlias": "Random Alias", + "manual": "Manual", + "generateRandomAlias": "Generate Random Alias", + "clearAliasFields": "Clear Alias Fields", + "enterFullEmail": "Enter full email address", + "enterEmailPrefix": "Enter email prefix", + "useDomainChooser": "Use domain chooser", + "enterCustomDomain": "Enter custom domain", + "selectEmailDomain": "Select Email Domain", + "privateEmailTitle": "Private Email", + "privateEmailAliasVaultServer": "AliasVault server", + "privateEmailDescription": "E2E encrypted, fully private.", + "publicEmailTitle": "Public Temp Email Providers", + "publicEmailDescription": "Anonymous but limited privacy. Email content is readable by anyone that knows the address.", + "searchPlaceholder": "Search vault...", + "noMatchingItems": "No matching items found", + "noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.", + "noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.", + "noAttachmentsFound": "No items with attachments found", + "recentEmails": "Recent emails", + "loadingEmails": "Loading emails...", + "noEmailsYet": "No emails received yet.", + "offlineEmailsMessage": "You are offline. Please connect to the internet to load your emails.", + "emailLoadError": "An error occurred while loading emails. Please try again later.", + "emailUnexpectedError": "An unexpected error occurred while loading emails. Please try again later.", + "password": "Password", + "passwordLength": "Password Length", + "changePasswordComplexity": "Password Settings", + "includeLowercase": "Lowercase (a-z)", + "includeUppercase": "Uppercase (A-Z)", + "includeNumbers": "Numbers (0-9)", + "includeSpecialChars": "Special Characters (!@#)", + "avoidAmbiguousChars": "Avoid Ambiguous Characters", + "deletingItem": "Deleting item...", + "errorLoadingItems": "Error loading items", + "vaultSyncFailed": "Vault sync failed", + "vaultSyncedSuccessfully": "Vault synced successfully", + "vaultUpToDate": "Vault is up-to-date", + "offlineMessage": "You are offline. Please connect to the internet to sync your vault.", + "itemCreatedMessage": "Your new item has been added to your vault and is ready to use.", + "switchBackToBrowser": "Switch back to your browser to continue.", + "filters": { + "all": "(All) Items", + "passkeys": "Passkeys", + "aliases": "Aliases", + "userpass": "Passwords", + "attachments": "Attachments" + }, + "twoFactorAuth": "Two-factor authentication", + "totpCode": "TOTP Code", + "attachments": "Attachments", + "deleteAttachment": "Delete", + "fileSavedTo": "File saved to", + "previewNotSupported": "Preview not supported", + "downloadToView": "Download the file to view it", + "unsavedChanges": { + "title": "Discard Changes?", + "message": "You have unsaved changes. Are you sure you want to discard them?", + "discard": "Discard" + }, + "toasts": { + "itemUpdated": "Item updated successfully", + "itemCreated": "Item created successfully", + "itemDeleted": "Item deleted successfully", + "usernameCopied": "Username copied to clipboard", + "emailCopied": "Email copied to clipboard", + "passwordCopied": "Password copied to clipboard" + }, + "createNewAliasFor": "Create new alias for", + "errors": { + "loadFailed": "Failed to load item", + "saveFailed": "Failed to save item" + }, + "contextMenu": { + "title": "Item Options", + "edit": "Edit", + "delete": "Delete", + "copyUsername": "Copy Username", + "copyEmail": "Copy Email", + "copyPassword": "Copy Password" + }, + "deleteConfirm": "Are you sure you want to delete this item? This action cannot be undone." + }, "emails": { "title": "Emails", "emailDetails": "Email Details", diff --git a/apps/mobile-app/utils/PostUnlockNavigation.ts b/apps/mobile-app/utils/PostUnlockNavigation.ts index 388178f2e..91a8f9013 100644 --- a/apps/mobile-app/utils/PostUnlockNavigation.ts +++ b/apps/mobile-app/utils/PostUnlockNavigation.ts @@ -51,8 +51,8 @@ export class PostUnlockNavigation { return; } - // Priority 2: Default navigation to credentials - router.replace('/(tabs)/credentials'); + // Priority 2: Default navigation to items + router.replace('/(tabs)/items'); } /** @@ -67,13 +67,13 @@ export class PostUnlockNavigation { const params = returnUrl.params || {}; // Check if this is a detail route (has a sub-page after the tab) - const isCredentialRoute = normalizedPath.includes('/(tabs)/credentials/'); + const isItemRoute = normalizedPath.includes('/(tabs)/items/'); const isSettingsRoute = normalizedPath.includes('/(tabs)/settings/') && !normalizedPath.endsWith('/(tabs)/settings'); - if (isCredentialRoute) { - // Navigate to credentials tab first, then push detail page - router.replace('/(tabs)/credentials'); + if (isItemRoute) { + // Navigate to items tab first, then push detail page + router.replace('/(tabs)/items'); setTimeout(() => { const queryParams = new URLSearchParams(params as Record).toString(); const targetUrl = queryParams ? `${normalizedPath}?${queryParams}` : normalizedPath; @@ -102,7 +102,7 @@ export class PostUnlockNavigation { * * Supports: * - Action-based URLs: aliasvault://open/mobile-unlock/[id] - * - Direct routes: aliasvault://credentials/[id], aliasvault://settings/[page] + * - Direct routes: aliasvault://items/[id], aliasvault://settings/[page] */ private static normalizeDeepLinkPath(urlOrPath: string): string { // Remove all URL schemes first @@ -116,8 +116,8 @@ export class PostUnlockNavigation { return path; } - // Handle credential paths - if (path.startsWith('credentials/') || path.includes('/credentials/')) { + // Handle item paths + if (path.startsWith('items/') || path.includes('/items/')) { if (!path.startsWith('/')) { path = `/${path}`; } diff --git a/apps/mobile-app/utils/SqliteClient.tsx b/apps/mobile-app/utils/SqliteClient.tsx index 53fa9d19d..a4bae0a3c 100644 --- a/apps/mobile-app/utils/SqliteClient.tsx +++ b/apps/mobile-app/utils/SqliteClient.tsx @@ -1,19 +1,89 @@ import { Buffer } from 'buffer'; import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/core/models/metadata'; -import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode, Passkey } from '@/utils/dist/core/models/vault'; +import type { Attachment, Credential, EncryptionKey, PasswordSettings, TotpCode, Passkey, Item } from '@/utils/dist/core/models/vault'; import { VaultSqlGenerator, VaultVersion, checkVersionCompatibility, extractVersionFromMigrationId } from '@/utils/dist/core/vault'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; import NativeVaultManager from '@/specs/NativeVaultManager'; import * as dateFormatter from '@/utils/dateFormatter'; +import { ItemRepository } from '@/utils/db/repositories/ItemRepository'; +import { SettingsRepository } from '@/utils/db/repositories/SettingsRepository'; +import { LogoRepository } from '@/utils/db/repositories/LogoRepository'; +import type { IDatabaseClient, SqliteBindValue } from '@/utils/db/BaseRepository'; +import type { ItemWithDeletedAt } from '@/utils/db/mappers/ItemMapper'; type SQLiteBindValue = string | number | null | Uint8Array; /** * Client for interacting with the SQLite database through native code. + * Implements IDatabaseClient interface for repository pattern. */ -class SqliteClient { +class SqliteClient implements IDatabaseClient { + // Repositories for Item-based access (lazy initialized) + private _itemRepository: ItemRepository | null = null; + private _settingsRepository: SettingsRepository | null = null; + private _logoRepository: LogoRepository | null = null; + + /** + * Get the ItemRepository instance (lazy initialization). + */ + public get itemRepository(): ItemRepository { + if (!this._itemRepository) { + // Use a factory function to create the repository with 'this' as the client + this._itemRepository = Object.setPrototypeOf( + { client: this as IDatabaseClient }, + ItemRepository.prototype + ) as ItemRepository; + // Manually bind 'this' context to all repository methods + Object.getOwnPropertyNames(ItemRepository.prototype).forEach(name => { + const method = ItemRepository.prototype[name as keyof typeof ItemRepository.prototype]; + if (typeof method === 'function' && name !== 'constructor') { + (this._itemRepository as unknown as Record)[name] = method.bind(this._itemRepository); + } + }); + } + return this._itemRepository; + } + + /** + * Get the SettingsRepository instance (lazy initialization). + */ + public get settingsRepository(): SettingsRepository { + if (!this._settingsRepository) { + this._settingsRepository = Object.setPrototypeOf( + { client: this as IDatabaseClient }, + SettingsRepository.prototype + ) as SettingsRepository; + Object.getOwnPropertyNames(SettingsRepository.prototype).forEach(name => { + const method = SettingsRepository.prototype[name as keyof typeof SettingsRepository.prototype]; + if (typeof method === 'function' && name !== 'constructor') { + (this._settingsRepository as unknown as Record)[name] = method.bind(this._settingsRepository); + } + }); + } + return this._settingsRepository; + } + + /** + * Get the LogoRepository instance (lazy initialization). + */ + public get logoRepository(): LogoRepository { + if (!this._logoRepository) { + this._logoRepository = Object.setPrototypeOf( + { client: this as IDatabaseClient }, + LogoRepository.prototype + ) as LogoRepository; + Object.getOwnPropertyNames(LogoRepository.prototype).forEach(name => { + const method = LogoRepository.prototype[name as keyof typeof LogoRepository.prototype]; + if (typeof method === 'function' && name !== 'constructor') { + (this._logoRepository as unknown as Record)[name] = method.bind(this._logoRepository); + } + }); + } + return this._logoRepository; + } + /** * Store the encrypted database via the native code implementation. */ @@ -222,6 +292,122 @@ class SqliteClient { // No-op since the native code handles connection lifecycle } + // ============================================================================ + // NEW: Item-based methods using repository pattern + // ============================================================================ + + /** + * Fetch all items (new V5 schema). + * @returns Array of Item objects + */ + public async getAllItems(): Promise { + return this.itemRepository.getAll(); + } + + /** + * Fetch a single item by ID (new V5 schema). + * @param itemId - The ID of the item to fetch + * @returns Item object or null if not found + */ + public async getItemById(itemId: string): Promise { + return this.itemRepository.getById(itemId); + } + + /** + * Fetch all unique email addresses from items. + * @returns Array of email addresses + */ + public async getAllItemEmailAddresses(): Promise { + return this.itemRepository.getAllEmailAddresses(); + } + + /** + * Get recently deleted items (in trash). + * @returns Array of items with DeletedAt field + */ + public async getRecentlyDeletedItems(): Promise { + return this.itemRepository.getRecentlyDeleted(); + } + + /** + * Create a new item with its fields and related entities. + * @param item - The item to create + * @param attachments - Array of attachments + * @param totpCodes - Array of TOTP codes + * @returns The ID of the created item + */ + public async createItem(item: Item, attachments: Attachment[] = [], totpCodes: TotpCode[] = []): Promise { + return this.itemRepository.create(item, attachments, totpCodes); + } + + /** + * Update an existing item. + * @param item - The item to update + * @param originalAttachmentIds - IDs of attachments before edit + * @param attachments - Current attachments + * @param originalTotpCodeIds - IDs of TOTP codes before edit + * @param totpCodes - Current TOTP codes + * @returns Number of rows affected + */ + public async updateItem( + item: Item, + originalAttachmentIds: string[], + attachments: Attachment[], + originalTotpCodeIds: string[], + totpCodes: TotpCode[] + ): Promise { + return this.itemRepository.update(item, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes); + } + + /** + * Move an item to trash. + * @param itemId - The ID of the item + * @returns Number of rows affected + */ + public async trashItem(itemId: string): Promise { + return this.itemRepository.trash(itemId); + } + + /** + * Restore an item from trash. + * @param itemId - The ID of the item + * @returns Number of rows affected + */ + public async restoreItem(itemId: string): Promise { + return this.itemRepository.restore(itemId); + } + + /** + * Permanently delete an item. + * @param itemId - The ID of the item + * @returns Number of rows affected + */ + public async permanentlyDeleteItem(itemId: string): Promise { + return this.itemRepository.permanentlyDelete(itemId); + } + + /** + * Get TOTP codes for an item (new V5 schema). + * @param itemId - The ID of the item + * @returns Array of TotpCode objects + */ + public async getTotpCodesForItem(itemId: string): Promise { + return this.settingsRepository.getTotpCodesForItem(itemId); + } + + /** + * Get attachments for an item (new V5 schema). + * @param itemId - The ID of the item + * @returns Array of attachments + */ + public async getAttachmentsForItem(itemId: string): Promise { + return this.settingsRepository.getAttachmentsForItem(itemId); + } + + // ============================================================================ + // LEGACY: Credential-based methods (kept for backward compatibility) + // ============================================================================ + /** * Fetch a single credential with its associated service information. * @param credentialId - The ID of the credential to fetch. @@ -1203,9 +1389,8 @@ class SqliteClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any return results.map((row: any) => ({ Id: row.Id, - CredentialId: row.CredentialId, + ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema RpId: row.RpId, - UserId: row.UserId, PublicKey: row.PublicKey, PrivateKey: row.PrivateKey, DisplayName: row.DisplayName, @@ -1257,9 +1442,8 @@ class SqliteClient { const row: any = results[0]; return { Id: row.Id, - CredentialId: row.CredentialId, + ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema RpId: row.RpId, - UserId: row.UserId, PublicKey: row.PublicKey, PrivateKey: row.PrivateKey, DisplayName: row.DisplayName, @@ -1303,9 +1487,8 @@ class SqliteClient { // eslint-disable-next-line @typescript-eslint/no-explicit-any return results.map((row: any) => ({ Id: row.Id, - CredentialId: row.CredentialId, + ItemId: row.ItemId ?? row.CredentialId, // Support both old and new schema RpId: row.RpId, - UserId: row.UserId, PublicKey: row.PublicKey, PrivateKey: row.PrivateKey, DisplayName: row.DisplayName, @@ -1318,7 +1501,7 @@ class SqliteClient { } /** - * Create a new passkey linked to a credential + * Create a new passkey linked to an item * @param passkey - The passkey object to create */ public async createPasskey(passkey: Omit): Promise { @@ -1329,10 +1512,10 @@ class SqliteClient { const query = ` INSERT INTO Passkeys ( - Id, CredentialId, RpId, UserId, PublicKey, PrivateKey, + Id, ItemId, RpId, PublicKey, PrivateKey, PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; // Convert PrfKey to Uint8Array if it's a number array @@ -1343,9 +1526,8 @@ class SqliteClient { await this.executeUpdate(query, [ passkey.Id, - passkey.CredentialId, + passkey.ItemId, passkey.RpId, - passkey.UserId ?? null, passkey.PublicKey, passkey.PrivateKey, prfKeyData, diff --git a/apps/mobile-app/utils/db/BaseRepository.ts b/apps/mobile-app/utils/db/BaseRepository.ts new file mode 100644 index 000000000..e8f9f3f54 --- /dev/null +++ b/apps/mobile-app/utils/db/BaseRepository.ts @@ -0,0 +1,140 @@ +import * as dateFormatter from '@/utils/dateFormatter'; + +import NativeVaultManager from '@/specs/NativeVaultManager'; + +export type SqliteBindValue = string | number | null | Uint8Array; + +/** + * Interface for the core database operations needed by repositories. + * Mobile app version is async as it communicates with native modules. + */ +export interface IDatabaseClient { + executeQuery(query: string, params?: SqliteBindValue[]): Promise; + executeUpdate(query: string, params?: SqliteBindValue[]): Promise; +} + +/** + * Base repository class with common database operations. + * Provides transaction handling, soft delete, and other shared functionality. + * + * Mobile-specific: All operations are async as they communicate with native modules. + */ +export abstract class BaseRepository { + /** + * Constructor for the BaseRepository class. + * @param client - The database client to use for the repository + */ + protected constructor(protected client: IDatabaseClient) {} + + /** + * Execute a function within a transaction. + * Automatically handles begin, commit, and rollback. + * @param fn - The function to execute within the transaction + * @returns The result of the function + */ + protected async withTransaction(fn: () => T | Promise): Promise { + await NativeVaultManager.beginTransaction(); + try { + const result = await fn(); + await NativeVaultManager.commitTransaction(); + return result; + } catch (error) { + await NativeVaultManager.rollbackTransaction(); + throw error; + } + } + + /** + * Soft delete a record by setting IsDeleted = 1. + * @param table - The table name + * @param id - The record ID + * @returns Number of rows affected + */ + protected async softDelete(table: string, id: string): Promise { + const now = dateFormatter.now(); + return this.client.executeUpdate( + `UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`, + [now, id] + ); + } + + /** + * Soft delete records by a foreign key. + * @param table - The table name + * @param foreignKey - The foreign key column name + * @param foreignKeyValue - The foreign key value + * @returns Number of rows affected + */ + protected async softDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): Promise { + const now = dateFormatter.now(); + return this.client.executeUpdate( + `UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE ${foreignKey} = ?`, + [now, foreignKeyValue] + ); + } + + /** + * Hard delete a record permanently. + * @param table - The table name + * @param id - The record ID + * @returns Number of rows affected + */ + protected async hardDelete(table: string, id: string): Promise { + return this.client.executeUpdate(`DELETE FROM ${table} WHERE Id = ?`, [id]); + } + + /** + * Hard delete records by a foreign key. + * @param table - The table name + * @param foreignKey - The foreign key column name + * @param foreignKeyValue - The foreign key value + * @returns Number of rows affected + */ + protected async hardDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): Promise { + return this.client.executeUpdate( + `DELETE FROM ${table} WHERE ${foreignKey} = ?`, + [foreignKeyValue] + ); + } + + /** + * Check if a table exists in the database. + * @param tableName - The name of the table to check + * @returns True if the table exists + */ + protected async tableExists(tableName: string): Promise { + const results = await this.client.executeQuery<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, + [tableName] + ); + return results.length > 0; + } + + /** + * Generate a new UUID in uppercase format. + * @returns A new UUID string + */ + protected generateId(): string { + return crypto.randomUUID().toUpperCase(); + } + + /** + * Get the current timestamp in the standard format. + * @returns Current timestamp string + */ + protected now(): string { + return dateFormatter.now(); + } + + /** + * Build a parameterized IN clause for SQL queries. + * @param values - Array of values for the IN clause + * @returns Object with placeholders string and values array + */ + protected buildInClause(values: string[]): { placeholders: string; values: string[] } { + return { + placeholders: values.map(() => '?').join(','), + values + }; + } +} diff --git a/apps/mobile-app/utils/db/index.ts b/apps/mobile-app/utils/db/index.ts new file mode 100644 index 000000000..f9c9f3a32 --- /dev/null +++ b/apps/mobile-app/utils/db/index.ts @@ -0,0 +1,18 @@ +// Base +export { BaseRepository, type IDatabaseClient, type SqliteBindValue } from './BaseRepository'; + +// Mappers +export { FieldMapper, type FieldRow } from './mappers/FieldMapper'; +export { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from './mappers/ItemMapper'; + +// Queries +export { + ItemQueries, + FieldValueQueries, + FieldDefinitionQueries +} from './queries/ItemQueries'; + +// Repositories +export { ItemRepository } from './repositories/ItemRepository'; +export { SettingsRepository } from './repositories/SettingsRepository'; +export { LogoRepository } from './repositories/LogoRepository'; diff --git a/apps/mobile-app/utils/db/mappers/FieldMapper.ts b/apps/mobile-app/utils/db/mappers/FieldMapper.ts new file mode 100644 index 000000000..f417a7e28 --- /dev/null +++ b/apps/mobile-app/utils/db/mappers/FieldMapper.ts @@ -0,0 +1,208 @@ +import type { ItemField, FieldType } from '@/utils/dist/core/models/vault'; +import { FieldTypes, getSystemField } from '@/utils/dist/core/models/vault'; + +/** + * Raw field row from database query. + */ +export type FieldRow = { + ItemId: string; + FieldKey: string | null; + FieldDefinitionId: string | null; + CustomLabel: string | null; + CustomFieldType: string | null; + CustomIsHidden: number | null; + CustomEnableHistory: number | null; + Value: string; + DisplayOrder: number; +}; + +/** + * Intermediate field representation before grouping. + */ +export type ProcessedField = { + ItemId: string; + FieldKey: string; + Label: string; + FieldType: string; + IsHidden: number; + Value: string; + DisplayOrder: number; + IsCustomField: boolean; + EnableHistory: boolean; +}; + +/** + * Mapper class for processing database field rows into ItemField objects. + * Handles both system fields (with FieldKey) and custom fields (with FieldDefinitionId). + */ +export class FieldMapper { + /** + * Process raw field rows from database into a map of ItemId -> ItemField[]. + * Handles system vs custom fields and multi-value field grouping. + * @param rows - Raw field rows from database + * @returns Map of ItemId to array of ItemField objects + */ + public static processFieldRows(rows: FieldRow[]): Map { + // First, convert rows to processed fields with proper metadata + const processedFields = rows.map(row => this.processFieldRow(row)); + + // Group by ItemId and FieldKey (to handle multi-value fields) + const fieldsByItem = new Map(); + const fieldValuesByKey = new Map(); + + for (const field of processedFields) { + const key = `${field.ItemId}_${field.FieldKey}`; + + // Accumulate values for the same field + if (!fieldValuesByKey.has(key)) { + fieldValuesByKey.set(key, []); + } + fieldValuesByKey.get(key)!.push(field.Value); + + // Create ItemField entry only once per unique FieldKey per item + if (!fieldsByItem.has(field.ItemId)) { + fieldsByItem.set(field.ItemId, []); + } + + const itemFields = fieldsByItem.get(field.ItemId)!; + const existingField = itemFields.find(f => f.FieldKey === field.FieldKey); + + if (!existingField) { + itemFields.push({ + FieldKey: field.FieldKey, + Label: field.Label, + FieldType: field.FieldType as FieldType, + Value: '', // Will be set below + IsHidden: field.IsHidden === 1, + DisplayOrder: field.DisplayOrder, + IsCustomField: field.IsCustomField, + EnableHistory: field.EnableHistory + }); + } + } + + // Set Values (single value or array for multi-value fields) + for (const [itemId, fields] of fieldsByItem) { + for (const field of fields) { + const key = `${itemId}_${field.FieldKey}`; + const values = fieldValuesByKey.get(key) || []; + + if (values.length === 1) { + field.Value = values[0]; + } else { + field.Value = values; + } + } + } + + return fieldsByItem; + } + + /** + * Process a single field row to extract proper metadata. + * System fields use FieldKey and get metadata from SystemFieldRegistry. + * Custom fields use FieldDefinitionId and get metadata from the row. + * @param row - Raw field row + * @returns Processed field with proper metadata + */ + private static processFieldRow(row: FieldRow): ProcessedField { + if (row.FieldKey) { + // System field: has FieldKey, get metadata from SystemFieldRegistry + const systemField = getSystemField(row.FieldKey); + return { + ItemId: row.ItemId, + FieldKey: row.FieldKey, + Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* + FieldType: systemField?.FieldType || FieldTypes.Text, + IsHidden: systemField?.IsHidden ? 1 : 0, + Value: row.Value, + DisplayOrder: row.DisplayOrder, + IsCustomField: false, + EnableHistory: systemField?.EnableHistory ?? false + }; + } else { + // Custom field: has FieldDefinitionId, get metadata from FieldDefinitions + return { + ItemId: row.ItemId, + FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId (UUID) as the key for custom fields + Label: row.CustomLabel || '', + FieldType: row.CustomFieldType || FieldTypes.Text, + IsHidden: row.CustomIsHidden || 0, + Value: row.Value, + DisplayOrder: row.DisplayOrder, + IsCustomField: true, + EnableHistory: row.CustomEnableHistory === 1 + }; + } + } + + /** + * Process field rows for a single item (without ItemId in result). + * Used when fetching a single item by ID. + * @param rows - Raw field rows for a single item + * @returns Array of ItemField objects + */ + public static processFieldRowsForSingleItem(rows: Omit[]): ItemField[] { + const fieldValuesByKey = new Map(); + const uniqueFields = new Map(); + + for (const row of rows) { + const fieldKey = row.FieldKey || row.FieldDefinitionId || ''; + + // Accumulate values + if (!fieldValuesByKey.has(fieldKey)) { + fieldValuesByKey.set(fieldKey, []); + } + fieldValuesByKey.get(fieldKey)!.push(row.Value); + + // Store field metadata (only once per FieldKey) + if (!uniqueFields.has(fieldKey)) { + if (row.FieldKey) { + // System field + const systemField = getSystemField(row.FieldKey); + uniqueFields.set(fieldKey, { + FieldKey: row.FieldKey, + Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* + FieldType: systemField?.FieldType || FieldTypes.Text, + IsHidden: systemField?.IsHidden ? 1 : 0, + DisplayOrder: row.DisplayOrder, + IsCustomField: false, + EnableHistory: systemField?.EnableHistory ?? false + }); + } else { + // Custom field + uniqueFields.set(fieldKey, { + FieldKey: fieldKey, + Label: row.CustomLabel || '', + FieldType: row.CustomFieldType || FieldTypes.Text, + IsHidden: row.CustomIsHidden || 0, + DisplayOrder: row.DisplayOrder, + IsCustomField: true, + EnableHistory: row.CustomEnableHistory === 1 + }); + } + } + } + + // Build fields array with proper single/multi values + return Array.from(uniqueFields.entries()).map(([fieldKey, metadata]) => { + const values = fieldValuesByKey.get(fieldKey) || []; + return { + ...metadata, + FieldType: metadata.FieldType as FieldType, + Value: values.length === 1 ? values[0] : values, + IsHidden: metadata.IsHidden === 1, + IsCustomField: metadata.IsCustomField, + EnableHistory: metadata.EnableHistory + }; + }); + } +} diff --git a/apps/mobile-app/utils/db/mappers/ItemMapper.ts b/apps/mobile-app/utils/db/mappers/ItemMapper.ts new file mode 100644 index 000000000..28a2692fe --- /dev/null +++ b/apps/mobile-app/utils/db/mappers/ItemMapper.ts @@ -0,0 +1,149 @@ +import type { Item, ItemField, ItemTagRef, ItemType } from '@/utils/dist/core/models/vault'; + +/** + * Item with optional DeletedAt field for recently deleted items. + */ +export type ItemWithDeletedAt = Item & { DeletedAt?: string }; + +/** + * Raw item row from database query. + */ +export type ItemRow = { + Id: string; + Name: string; + ItemType: string; + FolderId: string | null; + FolderPath: string | null; + Logo: Uint8Array | null; + HasPasskey: number; + HasAttachment: number; + HasTotp: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt?: string | null; +}; + +/** + * Raw tag row from database query. + */ +export type TagRow = { + ItemId: string; + Id: string; + Name: string; + Color: string | null; +}; + +/** + * Mapper class for converting database rows to Item objects. + */ +export class ItemMapper { + /** + * Map a single database row to an Item object. + * @param row - Raw item row from database + * @param fields - Processed fields for this item + * @param tags - Tags for this item + * @returns Item object + */ + public static mapRow( + row: ItemRow, + fields: ItemField[] = [], + tags: ItemTagRef[] = [] + ): Item { + return { + Id: row.Id, + Name: row.Name, + ItemType: row.ItemType as ItemType, + Logo: row.Logo ?? undefined, + FolderId: row.FolderId, + FolderPath: row.FolderPath || null, + Tags: tags, + Fields: fields, + HasPasskey: row.HasPasskey === 1, + HasAttachment: row.HasAttachment === 1, + HasTotp: row.HasTotp === 1, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt + }; + } + + /** + * Map multiple database rows to Item objects with their fields and tags. + * @param rows - Raw item rows from database + * @param fieldsByItem - Map of ItemId to array of fields + * @param tagsByItem - Map of ItemId to array of tags + * @returns Array of Item objects + */ + public static mapRows( + rows: ItemRow[], + fieldsByItem: Map, + tagsByItem: Map + ): Item[] { + return rows.map(row => this.mapRow( + row, + fieldsByItem.get(row.Id) || [], + tagsByItem.get(row.Id) || [] + )); + } + + /** + * Group tag rows by ItemId into a map. + * @param tagRows - Raw tag rows from database + * @returns Map of ItemId to array of ItemTagRef + */ + public static groupTagsByItem(tagRows: TagRow[]): Map { + const tagsByItem = new Map(); + + for (const tag of tagRows) { + if (!tagsByItem.has(tag.ItemId)) { + tagsByItem.set(tag.ItemId, []); + } + tagsByItem.get(tag.ItemId)!.push({ + Id: tag.Id, + Name: tag.Name, + Color: tag.Color || undefined + }); + } + + return tagsByItem; + } + + /** + * Map tag rows to ItemTagRef array (for single item). + * @param tagRows - Raw tag rows without ItemId + * @returns Array of ItemTagRef + */ + public static mapTagRows(tagRows: Omit[]): ItemTagRef[] { + return tagRows.map(tag => ({ + Id: tag.Id, + Name: tag.Name, + Color: tag.Color || undefined + })); + } + + /** + * Map a single item row for recently deleted items (includes DeletedAt). + * @param row - Raw item row with DeletedAt + * @param fields - Processed fields for this item + * @returns Item object with DeletedAt + */ + public static mapDeletedItemRow( + row: ItemRow & { DeletedAt: string }, + fields: ItemField[] = [] + ): ItemWithDeletedAt { + return { + Id: row.Id, + Name: row.Name, + ItemType: row.ItemType as ItemType, + Logo: row.Logo ? new Uint8Array(row.Logo) : undefined, + FolderId: row.FolderId, + FolderPath: row.FolderPath, + DeletedAt: row.DeletedAt, + HasPasskey: row.HasPasskey === 1, + HasAttachment: row.HasAttachment === 1, + HasTotp: row.HasTotp === 1, + Fields: fields, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt + }; + } +} diff --git a/apps/mobile-app/utils/db/queries/ItemQueries.ts b/apps/mobile-app/utils/db/queries/ItemQueries.ts new file mode 100644 index 000000000..58cf7fe61 --- /dev/null +++ b/apps/mobile-app/utils/db/queries/ItemQueries.ts @@ -0,0 +1,252 @@ +/** + * SQL query constants for Item operations. + * Centralizes all item-related queries to avoid duplication. + * Mirrors the browser extension implementation. + */ +export class ItemQueries { + /** + * Base SELECT for items with common fields. + * Includes LEFT JOIN to Logos, and subqueries for HasPasskey/HasAttachment/HasTotp. + */ + public static readonly BASE_SELECT = ` + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id`; + + /** + * Get all active items (not deleted, not in trash). + */ + public static readonly GET_ALL_ACTIVE = ` + ${ItemQueries.BASE_SELECT} + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY i.CreatedAt DESC`; + + /** + * Get a single item by ID. + */ + public static readonly GET_BY_ID = ` + SELECT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + WHERE i.Id = ? AND i.IsDeleted = 0`; + + /** + * Get field values for multiple items. + * @param itemCount - Number of items (for placeholder generation) + * @returns Query with placeholders + */ + public static getFieldValuesForItems(itemCount: number): string { + const placeholders = Array(itemCount).fill('?').join(','); + return ` + SELECT + fv.ItemId, + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fd.EnableHistory as CustomEnableHistory, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId IN (${placeholders}) + AND fv.IsDeleted = 0 + ORDER BY fv.ItemId, fv.Weight`; + } + + /** + * Get field values for a single item. + */ + public static readonly GET_FIELD_VALUES_FOR_ITEM = ` + SELECT + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fd.EnableHistory as CustomEnableHistory, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId = ? AND fv.IsDeleted = 0 + ORDER BY fv.Weight`; + + /** + * Get all unique email addresses from field values. + */ + public static readonly GET_ALL_EMAIL_ADDRESSES = ` + SELECT DISTINCT fv.Value as Email + FROM FieldValues fv + INNER JOIN Items i ON fv.ItemId = i.Id + WHERE fv.FieldKey = ? + AND fv.Value IS NOT NULL + AND fv.Value != '' + AND fv.IsDeleted = 0 + AND i.IsDeleted = 0 + AND i.DeletedAt IS NULL`; + + /** + * Get all recently deleted items (in trash). + */ + public static readonly GET_RECENTLY_DELETED = ` + ${ItemQueries.BASE_SELECT}, + i.DeletedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL + ORDER BY i.DeletedAt DESC`; + + /** + * Count of recently deleted items. + */ + public static readonly COUNT_RECENTLY_DELETED = ` + SELECT COUNT(*) as count + FROM Items + WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`; + + /** + * Insert a new item. + */ + public static readonly INSERT_ITEM = ` + INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing item. + */ + public static readonly UPDATE_ITEM = ` + UPDATE Items + SET Name = ?, + ItemType = ?, + FolderId = ?, + LogoId = COALESCE(?, LogoId), + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Move item to trash (set DeletedAt). + */ + public static readonly TRASH_ITEM = ` + UPDATE Items + SET DeletedAt = ?, + UpdatedAt = ? + WHERE Id = ? AND IsDeleted = 0`; + + /** + * Restore item from trash (clear DeletedAt). + */ + public static readonly RESTORE_ITEM = ` + UPDATE Items + SET DeletedAt = NULL, + UpdatedAt = ? + WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`; + + /** + * Convert item to tombstone for permanent deletion. + */ + public static readonly TOMBSTONE_ITEM = ` + UPDATE Items + SET IsDeleted = 1, + Name = NULL, + LogoId = NULL, + FolderId = NULL, + UpdatedAt = ? + WHERE Id = ?`; +} + +/** + * SQL query constants for FieldValue operations. + */ +export class FieldValueQueries { + /** + * Get existing field values for an item. + */ + public static readonly GET_EXISTING_FOR_ITEM = ` + SELECT Id, FieldKey, FieldDefinitionId, Value + FROM FieldValues + WHERE ItemId = ? AND IsDeleted = 0`; + + /** + * Insert a new field value. + */ + public static readonly INSERT = ` + INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing field value. + */ + public static readonly UPDATE = ` + UPDATE FieldValues + SET Value = ?, + Weight = ?, + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Soft delete a field value. + */ + public static readonly SOFT_DELETE = ` + UPDATE FieldValues + SET IsDeleted = 1, + UpdatedAt = ? + WHERE Id = ?`; +} + +/** + * SQL query constants for FieldDefinition operations. + */ +export class FieldDefinitionQueries { + /** + * Check if a field definition exists. + */ + public static readonly EXISTS = ` + SELECT Id FROM FieldDefinitions WHERE Id = ?`; + + /** + * Check if a field definition exists and is not deleted. + */ + public static readonly EXISTS_ACTIVE = ` + SELECT Id FROM FieldDefinitions WHERE Id = ? AND IsDeleted = 0`; + + /** + * Insert a new field definition. + */ + public static readonly INSERT = ` + INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing field definition. + */ + public static readonly UPDATE = ` + UPDATE FieldDefinitions + SET Label = ?, + FieldType = ?, + IsHidden = ?, + Weight = ?, + UpdatedAt = ? + WHERE Id = ?`; +} diff --git a/apps/mobile-app/utils/db/repositories/ItemRepository.ts b/apps/mobile-app/utils/db/repositories/ItemRepository.ts new file mode 100644 index 000000000..3c65a0169 --- /dev/null +++ b/apps/mobile-app/utils/db/repositories/ItemRepository.ts @@ -0,0 +1,454 @@ +import type { Item, ItemField, TotpCode, Attachment } from '@/utils/dist/core/models/vault'; +import { FieldKey } from '@/utils/dist/core/models/vault'; + +import { BaseRepository } from '../BaseRepository'; +import { ItemQueries, FieldValueQueries } from '../queries/ItemQueries'; +import { FieldMapper, type FieldRow } from '../mappers/FieldMapper'; +import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper'; + +/** + * SQL query constants for Item-related tag operations. + */ +const TagQueries = { + /** + * Get tags for multiple items. + */ + GET_TAGS_FOR_ITEMS: (itemCount: number): string => { + const placeholders = Array(itemCount).fill('?').join(','); + return ` + SELECT it.ItemId, t.Id, t.Name, t.Color + FROM ItemTags it + INNER JOIN Tags t ON it.TagId = t.Id + WHERE it.ItemId IN (${placeholders}) + AND it.IsDeleted = 0 + AND t.IsDeleted = 0 + ORDER BY t.DisplayOrder`; + }, + + /** + * Get tags for a single item. + */ + GET_TAGS_FOR_ITEM: ` + SELECT t.Id, t.Name, t.Color + FROM ItemTags it + INNER JOIN Tags t ON it.TagId = t.Id + WHERE it.ItemId = ? + AND it.IsDeleted = 0 + AND t.IsDeleted = 0 + ORDER BY t.DisplayOrder` +}; + +/** + * Repository for Item CRUD operations. + * Handles fetching, creating, updating, and deleting items with their related data. + */ +export class ItemRepository extends BaseRepository { + /** + * Fetch all active items (not deleted, not in trash) with their fields and tags. + * @returns Array of Item objects + */ + public async getAll(): Promise { + // 1. Fetch all item rows + const itemRows = await this.client.executeQuery(ItemQueries.GET_ALL_ACTIVE); + + if (itemRows.length === 0) { + return []; + } + + // 2. Fetch field values for all items + const itemIds = itemRows.map(row => row.Id); + const fieldQuery = ItemQueries.getFieldValuesForItems(itemIds.length); + const fieldRows = await this.client.executeQuery(fieldQuery, itemIds); + + // 3. Process fields into a map by ItemId + const fieldsByItem = FieldMapper.processFieldRows(fieldRows); + + // 4. Fetch tags for all items + let tagsByItem = new Map(); + if (await this.tableExists('ItemTags')) { + const tagQuery = TagQueries.GET_TAGS_FOR_ITEMS(itemIds.length); + const tagRows = await this.client.executeQuery(tagQuery, itemIds); + tagsByItem = ItemMapper.groupTagsByItem(tagRows); + } + + // 5. Map rows to Item objects + return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem); + } + + /** + * Fetch a single item by ID with its fields and tags. + * @param itemId - The ID of the item to fetch + * @returns Item object or null if not found + */ + public async getById(itemId: string): Promise { + // 1. Fetch item row + const itemRows = await this.client.executeQuery(ItemQueries.GET_BY_ID, [itemId]); + + if (itemRows.length === 0) { + return null; + } + + const itemRow = itemRows[0]; + + // 2. Fetch field values + const fieldRows = await this.client.executeQuery>( + ItemQueries.GET_FIELD_VALUES_FOR_ITEM, + [itemId] + ); + const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows); + + // 3. Fetch tags + let tags: { Id: string; Name: string; Color?: string }[] = []; + if (await this.tableExists('ItemTags')) { + const tagRows = await this.client.executeQuery>( + TagQueries.GET_TAGS_FOR_ITEM, + [itemId] + ); + tags = ItemMapper.mapTagRows(tagRows); + } + + // 4. Map to Item object + return ItemMapper.mapRow(itemRow, fields, tags); + } + + /** + * Fetch all unique email addresses from field values. + * @returns Array of email addresses + */ + public async getAllEmailAddresses(): Promise { + const results = await this.client.executeQuery<{ Email: string }>( + ItemQueries.GET_ALL_EMAIL_ADDRESSES, + [FieldKey.LoginEmail] + ); + return results.map(row => row.Email); + } + + /** + * Get recently deleted items (in trash). + * @returns Array of items with DeletedAt field + */ + public async getRecentlyDeleted(): Promise { + const itemRows = await this.client.executeQuery( + ItemQueries.GET_RECENTLY_DELETED + ); + + if (itemRows.length === 0) { + return []; + } + + // Fetch fields for deleted items + const itemIds = itemRows.map(row => row.Id); + const fieldQuery = ItemQueries.getFieldValuesForItems(itemIds.length); + const fieldRows = await this.client.executeQuery(fieldQuery, itemIds); + const fieldsByItem = FieldMapper.processFieldRows(fieldRows); + + return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || [])); + } + + /** + * Get count of items in trash. + * @returns Number of items in trash + */ + public async getRecentlyDeletedCount(): Promise { + const results = await this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED); + return results.length > 0 ? results[0].count : 0; + } + + /** + * Move an item to trash (set DeletedAt timestamp). + * @param itemId - The ID of the item to trash + * @returns Number of rows affected + */ + public async trash(itemId: string): Promise { + const now = this.now(); + return this.withTransaction(async () => { + return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [now, now, itemId]); + }); + } + + /** + * Restore an item from trash (clear DeletedAt). + * @param itemId - The ID of the item to restore + * @returns Number of rows affected + */ + public async restore(itemId: string): Promise { + const now = this.now(); + return this.withTransaction(async () => { + return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [now, itemId]); + }); + } + + /** + * Permanently delete an item (tombstone). + * Converts item to tombstone and hard deletes all related data. + * @param itemId - The ID of the item to permanently delete + * @returns Number of rows affected + */ + public async permanentlyDelete(itemId: string): Promise { + return this.withTransaction(async () => { + const now = this.now(); + + // Soft delete related FieldValues + await this.softDeleteByForeignKey('FieldValues', 'ItemId', itemId); + + // Soft delete related data + await this.softDeleteByForeignKey('TotpCodes', 'ItemId', itemId); + await this.softDeleteByForeignKey('Attachments', 'ItemId', itemId); + await this.softDeleteByForeignKey('Passkeys', 'ItemId', itemId); + if (await this.tableExists('ItemTags')) { + await this.softDeleteByForeignKey('ItemTags', 'ItemId', itemId); + } + if (await this.tableExists('FieldHistories')) { + await this.softDeleteByForeignKey('FieldHistories', 'ItemId', itemId); + } + + // Convert item to tombstone + return this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [now, itemId]); + }); + } + + /** + * Create a new item with its fields and related entities. + * @param item - The item to create + * @param attachments - Array of attachments to create + * @param totpCodes - Array of TOTP codes to create + * @param logoRepository - Optional logo repository for logo handling + * @returns The ID of the created item + */ + public async create( + item: Item, + attachments: Attachment[] = [], + totpCodes: TotpCode[] = [] + ): Promise { + return this.withTransaction(async () => { + const now = this.now(); + const itemId = item.Id || this.generateId(); + + // 1. Insert Item + await this.client.executeUpdate(ItemQueries.INSERT_ITEM, [ + itemId, + item.Name, + item.ItemType, + null, // LogoId - handled separately if needed + item.FolderId || null, + now, + now, + 0 + ]); + + // 2. Insert FieldValues + await this.insertFieldValues(itemId, item.Fields, now); + + // 3. Insert TOTP codes + for (const totp of totpCodes) { + if (totp.IsDeleted) continue; + + await this.client.executeUpdate(` + INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [totp.Id || this.generateId(), totp.Name, totp.SecretKey, itemId, now, now, 0]); + } + + // 4. Insert Attachments + for (const attachment of attachments) { + await this.client.executeUpdate(` + INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [attachment.Id, attachment.Filename, attachment.Blob as Uint8Array, itemId, now, now, 0]); + } + + return itemId; + }); + } + + /** + * Update an existing item with its fields and related entities. + * @param item - The item to update + * @param originalAttachmentIds - IDs of attachments that existed before edit + * @param attachments - Current attachments (new and existing) + * @param originalTotpCodeIds - IDs of TOTP codes that existed before edit + * @param totpCodes - Current TOTP codes (new and existing) + * @returns Number of rows affected + */ + public async update( + item: Item, + originalAttachmentIds: string[] = [], + attachments: Attachment[] = [], + originalTotpCodeIds: string[] = [], + totpCodes: TotpCode[] = [] + ): Promise { + return this.withTransaction(async () => { + const now = this.now(); + + // 1. Update Item + await this.client.executeUpdate(ItemQueries.UPDATE_ITEM, [ + item.Name, + item.ItemType, + item.FolderId || null, + null, // LogoId update handled separately if needed + now, + item.Id + ]); + + // 2. Update FieldValues using preserve-and-track strategy + await this.updateFieldValues(item.Id, item.Fields, now); + + // 3. Handle TOTP codes + await this.syncRelatedEntities( + 'TotpCodes', + 'ItemId', + item.Id, + originalTotpCodeIds, + totpCodes.filter(tc => !tc.IsDeleted), + (totp) => [totp.Id || this.generateId(), totp.Name, totp.SecretKey, item.Id, now, now, 0], + `INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + + // 4. Handle Attachments + await this.syncRelatedEntities( + 'Attachments', + 'ItemId', + item.Id, + originalAttachmentIds, + attachments, + (att) => [att.Id, att.Filename, att.Blob as Uint8Array, item.Id, now, now, 0], + `INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + + return 1; + }); + } + + /** + * Insert field values for an item. + */ + private async insertFieldValues(itemId: string, fields: ItemField[], now: string): Promise { + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const values = Array.isArray(field.Value) ? field.Value : [field.Value]; + + for (let j = 0; j < values.length; j++) { + const value = values[j]; + if (value === undefined || value === null || value === '') continue; + + await this.client.executeUpdate(FieldValueQueries.INSERT, [ + this.generateId(), + itemId, + field.IsCustomField ? field.FieldKey : null, // FieldDefinitionId for custom + field.IsCustomField ? null : field.FieldKey, // FieldKey for system + value, + (i * 100) + j, // Weight for ordering + now, + now, + 0 + ]); + } + } + } + + /** + * Update field values using preserve-and-track strategy. + * Preserves existing field value IDs when possible for stable merge behavior. + */ + private async updateFieldValues(itemId: string, fields: ItemField[], now: string): Promise { + // 1. Get existing field values + const existingFields = await this.client.executeQuery<{ + Id: string; + FieldKey: string | null; + FieldDefinitionId: string | null; + Value: string; + }>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [itemId]); + + // 2. Build lookup by composite key (FieldKey or FieldDefinitionId + index) + const existingByKey = new Map(); + for (const existing of existingFields) { + const key = existing.FieldKey || existing.FieldDefinitionId || ''; + if (!existingByKey.has(key)) { + existingByKey.set(key, []); + } + existingByKey.get(key)!.push({ Id: existing.Id, Value: existing.Value }); + } + + // 3. Track which existing IDs we've processed + const processedIds = new Set(); + + // 4. Process each field + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const values = Array.isArray(field.Value) ? field.Value : [field.Value]; + const existingForKey = existingByKey.get(field.FieldKey) || []; + + for (let j = 0; j < values.length; j++) { + const value = values[j]; + if (value === undefined || value === null || value === '') continue; + + const existingEntry = existingForKey[j]; + + if (existingEntry) { + // Update existing if value changed + processedIds.add(existingEntry.Id); + if (existingEntry.Value !== value) { + await this.client.executeUpdate(FieldValueQueries.UPDATE, [ + value, + (i * 100) + j, + now, + existingEntry.Id + ]); + } + } else { + // Insert new field value + await this.client.executeUpdate(FieldValueQueries.INSERT, [ + this.generateId(), + itemId, + field.IsCustomField ? field.FieldKey : null, + field.IsCustomField ? null : field.FieldKey, + value, + (i * 100) + j, + now, + now, + 0 + ]); + } + } + } + + // 5. Soft delete removed fields + for (const existing of existingFields) { + if (!processedIds.has(existing.Id)) { + await this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [now, existing.Id]); + } + } + } + + /** + * Sync related entities (TOTP codes, attachments) with insert/delete tracking. + */ + private async syncRelatedEntities( + tableName: string, + foreignKey: string, + foreignKeyValue: string, + originalIds: string[], + currentEntities: T[], + toParams: (entity: T) => (string | number | null | Uint8Array)[], + insertQuery: string + ): Promise { + const now = this.now(); + const currentIds = currentEntities.map(e => e.Id); + + // Delete entities that were removed + const toDelete = originalIds.filter(id => !currentIds.includes(id)); + for (const id of toDelete) { + await this.client.executeUpdate( + `UPDATE ${tableName} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`, + [now, id] + ); + } + + // Insert new entities + for (const entity of currentEntities) { + if (!originalIds.includes(entity.Id)) { + await this.client.executeUpdate(insertQuery, toParams(entity)); + } + } + } +} diff --git a/apps/mobile-app/utils/db/repositories/LogoRepository.ts b/apps/mobile-app/utils/db/repositories/LogoRepository.ts new file mode 100644 index 000000000..99374eb47 --- /dev/null +++ b/apps/mobile-app/utils/db/repositories/LogoRepository.ts @@ -0,0 +1,159 @@ +import { BaseRepository } from '../BaseRepository'; + +/** + * SQL query constants for Logo operations. + */ +const LogoQueries = { + /** + * Check if logo exists for source. + */ + GET_ID_FOR_SOURCE: ` + SELECT Id FROM Logos + WHERE Source = ? AND IsDeleted = 0 + LIMIT 1`, + + /** + * Insert new logo. + */ + INSERT: ` + INSERT INTO Logos (Id, Source, FileData, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?)`, + + /** + * Count items using a logo. + */ + COUNT_USAGE: ` + SELECT COUNT(*) as count FROM Items + WHERE LogoId = ? AND IsDeleted = 0`, + + /** + * Hard delete logo. + */ + HARD_DELETE: ` + DELETE FROM Logos WHERE Id = ?` +}; + +/** + * Repository for Logo management operations. + */ +export class LogoRepository extends BaseRepository { + /** + * Check if a logo exists for the given source domain. + * @param source The normalized source domain (e.g., 'github.com') + * @returns True if a logo exists for this source + */ + public async hasLogoForSource(source: string): Promise { + const existingLogos = await this.client.executeQuery<{ Id: string }>( + LogoQueries.GET_ID_FOR_SOURCE, + [source] + ); + return existingLogos.length > 0; + } + + /** + * Get the logo ID for a given source domain if it exists. + * @param source The normalized source domain (e.g., 'github.com') + * @returns The logo ID if found, null otherwise + */ + public async getIdForSource(source: string): Promise { + const existingLogos = await this.client.executeQuery<{ Id: string }>( + LogoQueries.GET_ID_FOR_SOURCE, + [source] + ); + return existingLogos.length > 0 ? existingLogos[0].Id : null; + } + + /** + * Get or create a logo ID for the given source domain. + * If a logo for this source already exists, returns its ID. + * Otherwise, creates a new logo entry and returns its ID. + * @param source The normalized source domain (e.g., 'github.com') + * @param logoData The logo image data as Uint8Array + * @param currentDateTime The current date/time string for timestamps + * @returns The logo ID (existing or newly created) + */ + public async getOrCreate(source: string, logoData: Uint8Array, currentDateTime: string): Promise { + // Check if a logo for this source already exists + const existingId = await this.getIdForSource(source); + if (existingId) { + return existingId; + } + + // Create new logo entry + const logoId = this.generateId(); + await this.client.executeUpdate(LogoQueries.INSERT, [ + logoId, + source, + logoData, + currentDateTime, + currentDateTime, + 0 + ]); + + return logoId; + } + + /** + * Clean up orphaned logo if no items reference it. + * @param logoId - The ID of the logo to potentially clean up + */ + public async cleanupOrphanedLogo(logoId: string): Promise { + const usageResult = await this.client.executeQuery<{ count: number }>( + LogoQueries.COUNT_USAGE, + [logoId] + ); + const usageCount = usageResult.length > 0 ? usageResult[0].count : 0; + + if (usageCount === 0) { + await this.client.executeUpdate(LogoQueries.HARD_DELETE, [logoId]); + console.debug(`[LogoRepository] Deleted orphaned logo: ${logoId}`); + } + } + + /** + * Extract and normalize source domain from a URL string. + * Uses lowercase and removes www. prefix for case-insensitive matching. + * @param urlString The URL to extract the domain from + * @returns The normalized source domain (e.g., 'github.com'), or 'unknown' if extraction fails + */ + public extractSourceFromUrl(urlString: string | undefined | null): string { + if (!urlString) { + return 'unknown'; + } + + try { + const url = new URL(urlString.startsWith('http') ? urlString : `https://${urlString}`); + // Normalize hostname: lowercase and remove www. prefix + return url.hostname.toLowerCase().replace(/^www\./, ''); + } catch { + return 'unknown'; + } + } + + /** + * Convert logo data from various formats to Uint8Array. + * @param logo The logo data in various possible formats + * @returns Uint8Array of logo data, or null if conversion fails + */ + public convertLogoToUint8Array(logo: unknown): Uint8Array | null { + if (!logo) { + return null; + } + + try { + // Handle object-like array conversion (from JSON deserialization) + if (typeof logo === 'object' && !ArrayBuffer.isView(logo) && !Array.isArray(logo)) { + const values = Object.values(logo as Record); + return new Uint8Array(values); + } + // Handle existing array types + if (Array.isArray(logo) || logo instanceof ArrayBuffer || logo instanceof Uint8Array) { + return new Uint8Array(logo as ArrayLike); + } + } catch (error) { + console.warn('Failed to convert logo to Uint8Array:', error); + } + + return null; + } +} diff --git a/apps/mobile-app/utils/db/repositories/SettingsRepository.ts b/apps/mobile-app/utils/db/repositories/SettingsRepository.ts new file mode 100644 index 000000000..b060b4cd7 --- /dev/null +++ b/apps/mobile-app/utils/db/repositories/SettingsRepository.ts @@ -0,0 +1,209 @@ +import type { EncryptionKey, PasswordSettings, TotpCode, Attachment } from '@/utils/dist/core/models/vault'; + +import { BaseRepository } from '../BaseRepository'; + +/** + * SQL query constants for Settings and related operations. + */ +const SettingsQueries = { + /** + * Get setting by key. + */ + GET_SETTING: ` + SELECT s.Value + FROM Settings s + WHERE s.Key = ?`, + + /** + * Get all encryption keys. + */ + GET_ENCRYPTION_KEYS: ` + SELECT + x.PublicKey, + x.PrivateKey, + x.IsPrimary + FROM EncryptionKeys x`, + + /** + * Get TOTP codes for an item. + */ + GET_TOTP_FOR_ITEM: ` + SELECT + Id, + Name, + SecretKey, + ItemId + FROM TotpCodes + WHERE ItemId = ? AND IsDeleted = 0`, + + /** + * Get attachments for an item. + */ + GET_ATTACHMENTS_FOR_ITEM: ` + SELECT + Id, + Filename, + Blob, + ItemId, + CreatedAt, + UpdatedAt, + IsDeleted + FROM Attachments + WHERE ItemId = ? AND IsDeleted = 0` +}; + +/** + * Repository for Settings and auxiliary data operations. + */ +export class SettingsRepository extends BaseRepository { + /** + * Get setting from database for a given key. + * Returns default value (empty string by default) if setting is not found. + * @param key - The setting key + * @param defaultValue - Default value if setting not found + * @returns The setting value + */ + public async getSetting(key: string, defaultValue: string = ''): Promise { + const results = await this.client.executeQuery<{ Value: string }>( + SettingsQueries.GET_SETTING, + [key] + ); + return results.length > 0 ? results[0].Value : defaultValue; + } + + /** + * Get the default identity language from the database. + * @returns The stored override value if set, otherwise empty string + */ + public async getDefaultIdentityLanguage(): Promise { + return this.getSetting('DefaultIdentityLanguage'); + } + + /** + * Get the default identity gender preference from the database. + * @returns The gender preference or 'random' if not set + */ + public async getDefaultIdentityGender(): Promise { + return this.getSetting('DefaultIdentityGender', 'random'); + } + + /** + * Get the default identity age range from the database. + * @returns The age range preference or 'random' if not set + */ + public async getDefaultIdentityAgeRange(): Promise { + return this.getSetting('DefaultIdentityAgeRange', 'random'); + } + + /** + * Get the password settings from the database. + * @returns Password settings object + */ + public async getPasswordSettings(): Promise { + const settingsJson = await this.getSetting('PasswordGenerationSettings'); + + const defaultSettings: PasswordSettings = { + Length: 18, + UseLowercase: true, + UseUppercase: true, + UseNumbers: true, + UseSpecialChars: true, + UseNonAmbiguousChars: false + }; + + try { + if (settingsJson) { + return { ...defaultSettings, ...JSON.parse(settingsJson) }; + } + } catch (error) { + console.warn('Failed to parse password settings:', error); + } + + return defaultSettings; + } + + /** + * Fetch all encryption keys. + * @returns Array of encryption keys + */ + public async getAllEncryptionKeys(): Promise { + return this.client.executeQuery(SettingsQueries.GET_ENCRYPTION_KEYS); + } + + /** + * Get TOTP codes for an item. + * @param itemId - The ID of the item to get TOTP codes for + * @returns Array of TotpCode objects + */ + public async getTotpCodesForItem(itemId: string): Promise { + try { + if (!await this.tableExists('TotpCodes')) { + return []; + } + + return this.client.executeQuery(SettingsQueries.GET_TOTP_FOR_ITEM, [itemId]); + } catch (error) { + console.error('Error getting TOTP codes for item:', error); + return []; + } + } + + /** + * Get attachments for an item. + * @param itemId - The ID of the item + * @returns Array of attachments for the item + */ + public async getAttachmentsForItem(itemId: string): Promise { + try { + if (!await this.tableExists('Attachments')) { + return []; + } + + return this.client.executeQuery( + SettingsQueries.GET_ATTACHMENTS_FOR_ITEM, + [itemId] + ); + } catch (error) { + console.error('Error getting attachments for item:', error); + return []; + } + } + + /** + * Get the default email domain for new aliases. + * @returns The default email domain or empty string if not set + */ + public async getDefaultEmailDomain(): Promise { + return this.getSetting('DefaultEmailDomain'); + } + + /** + * Update or insert a setting. + * @param key - The setting key + * @param value - The setting value + */ + public async updateSetting(key: string, value: string): Promise { + await this.withTransaction(async () => { + const now = this.now(); + + // Check if setting exists + const results = await this.client.executeQuery<{ count: number }>( + `SELECT COUNT(*) as count FROM Settings WHERE Key = ?`, + [key] + ); + const exists = results[0]?.count > 0; + + if (exists) { + await this.client.executeUpdate( + `UPDATE Settings SET Value = ?, UpdatedAt = ? WHERE Key = ?`, + [value, now, key] + ); + } else { + await this.client.executeUpdate( + `INSERT INTO Settings (Key, Value, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?)`, + [key, value, now, now, 0] + ); + } + }); + } +}