diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index cab43763b..997ad604f 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -1,4 +1,4 @@ -import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, Platform, Animated, ActivityIndicator, Alert, Keyboard } from 'react-native'; +import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, ActivityIndicator, Alert, Keyboard } from 'react-native'; import { useState, useEffect, useRef } from 'react'; import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { ThemedText } from '@/components/ThemedText'; @@ -17,6 +17,10 @@ import { useVaultMutate } from '@/hooks/useVaultMutate'; import { Gender } from '@/utils/shared/identity-generator'; import { IdentityGeneratorEn, IdentityGeneratorNl, IdentityHelperUtils, BaseIdentityGenerator } from '@/utils/shared/identity-generator'; import { PasswordGenerator } from '@/utils/shared/password-generator'; +import { ValidatedFormField } from '@/components/ValidatedFormField'; +import { Resolver, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { credentialSchema } from '@/utils/validationSchema'; type CredentialMode = 'random' | 'manual'; @@ -29,25 +33,132 @@ export default function AddEditCredentialScreen() { const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate(); const navigation = useNavigation(); const serviceNameInputRef = useRef(null); - const [credential, setCredential] = useState>({ - Id: "", - Username: "", - Password: "", - ServiceName: "", - ServiceUrl: "", - Notes: "", - Alias: { - FirstName: "", - LastName: "", - NickName: "", - BirthDate: "0001-01-01", - Gender: Gender.Other, - Email: "" - }, - }); const webApi = useWebApi(); const [isPasswordVisible, setIsPasswordVisible] = useState(false); + const { control, handleSubmit, formState: { errors }, setValue, watch } = useForm({ + resolver: yupResolver(credentialSchema) as Resolver, + defaultValues: { + Id: "", + Username: "", + Password: "", + ServiceName: "", + ServiceUrl: "", + Notes: "", + Alias: { + FirstName: "", + LastName: "", + NickName: "", + BirthDate: "", + Gender: "", + Email: "" + } + } + }); + + const isEditMode = !!id; + + useEffect(() => { + if (isEditMode) { + loadExistingCredential(); + } else if (serviceUrl) { + const decodedUrl = decodeURIComponent(serviceUrl); + const serviceName = extractServiceNameFromUrl(decodedUrl); + setValue('ServiceUrl', decodedUrl); + setValue('ServiceName', serviceName); + } + }, [id, isEditMode, serviceUrl]); + + const loadExistingCredential = async () => { + try { + const existingCredential = await dbContext.sqliteClient!.getCredentialById(id); + if (existingCredential) { + existingCredential.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDisplay(existingCredential.Alias.BirthDate); + Object.entries(existingCredential).forEach(([key, value]) => { + setValue(key as keyof Credential, value); + }); + if (existingCredential.Alias?.FirstName || existingCredential.Alias?.LastName) { + setMode('manual'); + } + } + } catch (err) { + console.error('Error loading credential:', err); + Toast.show({ + type: 'error', + text1: 'Failed to load credential', + text2: 'Please try again' + }); + } + }; + + const onSubmit = async (data: Credential) => { + Keyboard.dismiss(); + + // Deep clone to avoid mutating state + let credentialToSave: Credential = JSON.parse(JSON.stringify(data)); + + // Convert user birthdate entry format (yyyy-mm-dd) into valid ISO 8601 format for database storage + credentialToSave.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDb(credentialToSave.Alias.BirthDate); + + // If we're creating a new credential and mode is random, generate random values + if (!isEditMode && mode === 'random') { + console.log('Generating random values'); + credentialToSave = await generateRandomAlias(); + } + + await executeVaultMutation(async () => { + if (isEditMode) { + await dbContext.sqliteClient!.updateCredentialById(credentialToSave); + } else { + // For new credentials, try to extract favicon + if (credentialToSave.ServiceUrl) { + 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 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; + } + } catch (error) { + console.log('Favicon extraction failed or timed out:', error); + } + } + + const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave); + credentialToSave.Id = credentialId; + } + + // Emit an event to notify list and detail views to refresh + emitter.emit('credentialChanged', credentialToSave.Id); + }); + + // If this was created from autofill (serviceUrl param), show confirmation screen + if (serviceUrl && !isEditMode) { + router.replace('/credentials/autofill-credential-created'); + } else { + if (isEditMode) { + // If editing existing credential, go back to the detail screen via back. + router.back(); + } else { + // If creating new credential, go to the newly created credential via push. + router.replace(`/credentials/${credentialToSave.Id}`); + } + + // Show success toast + setTimeout(() => { + Toast.show({ + type: 'success', + text1: isEditMode ? 'Credential updated successfully' : 'Credential created successfully', + position: 'bottom' + }); + }, 200); + } + }; + function extractServiceNameFromUrl(url: string): string { try { const urlObj = new URL(url); @@ -89,154 +200,87 @@ export default function AddEditCredentialScreen() { headerRight: () => ( - + ), }); - }, [navigation, credential, mode]); + }, [navigation, mode]); - const isEditMode = !!id; + const generateRandomAlias = async (): Promise => { + // Get default identity language from database + const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage(); - useEffect(() => { - let serviceName = ""; - - if (isEditMode) { - loadExistingCredential(); + // Initialize identity generator based on language + let identityGenerator: BaseIdentityGenerator; + switch (identityLanguage) { + case 'nl': + identityGenerator = new IdentityGeneratorNl(); + break; + case 'en': + default: + identityGenerator = new IdentityGeneratorEn(); + break; } - else { - // If serviceUrl is provided, extract the service name from the URL and prefill the form values. - // This is used when the user opens the app from a deep link (e.g. from iOS autofill extension). - if (serviceUrl) { - // Decode the URL-encoded service URL - const decodedUrl = decodeURIComponent(serviceUrl); - // Extract service name from URL - serviceName = extractServiceNameFromUrl(decodedUrl); + // Generate random identity + const identity = await identityGenerator.generateRandomIdentity(); - // Set the form values - // Note: You'll need to implement this based on your form state management - setCredential(prev => ({ - ...prev, - ServiceUrl: decodedUrl, - ServiceName: serviceName, - // ... other form fields - })); - } + // Get password settings from database + const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings(); - // In create mode, autofocus the service name field and select all default text - // so user can start renaming the service immediately if they want. - setTimeout(() => { - serviceNameInputRef.current?.focus(); - if (serviceName.length > 0) { - // If serviceUrl is provided, select all text - serviceNameInputRef.current?.setSelection(0, serviceName.length || 0); - } - }, 200); + // Initialize password generator with settings + const passwordGenerator = new PasswordGenerator(passwordSettings); + const password = passwordGenerator.generateRandomPassword(); + + const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); + const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; + + setValue('Alias.Email', 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.normalizeBirthDateForDisplay(identity.birthDate.toISOString())); + + // In edit mode, preserve existing username and password if they exist + if (isEditMode && watch('Username')) { + // Keep the existing username in edit mode + setValue('Username', watch('Username')); + } else { + // Use the newly generated username + setValue('Username', identity.nickName); } - }, [id, isEditMode, serviceUrl]); - useEffect(() => { - - }, [serviceUrl]); - - const loadExistingCredential = async () => { - try { - const existingCredential = await dbContext.sqliteClient!.getCredentialById(id); - if (existingCredential) { - existingCredential.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDisplay(existingCredential.Alias.BirthDate); - setCredential(existingCredential); - // If credential has custom values, switch to manual mode - if (existingCredential.Alias?.FirstName || existingCredential.Alias?.LastName) { - setMode('manual'); - } - } - } catch (err) { - console.error('Error loading credential:', err); - Toast.show({ - type: 'error', - text1: 'Failed to load credential', - text2: 'Please try again' - }); + if (isEditMode && watch('Password')) { + // Keep the existing password in edit mode + setValue('Password', watch('Password')); + } else { + // Use the newly generated password + setValue('Password', password); + // Make password visible when newly generated + setIsPasswordVisible(true); } - }; - const generateRandomAlias = async () : Promise => { - try { - console.log('Generating random alias'); - // Get default identity language and password settings from database - const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage(); - const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings(); - const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain(); - - // Initialize identity generator based on language - let identityGenerator: BaseIdentityGenerator; - switch (identityLanguage) { - case 'nl': - identityGenerator = new IdentityGeneratorNl(); - break; - case 'en': - default: - identityGenerator = new IdentityGeneratorEn(); - break; + return { + Id: "", + Username: identity.emailPrefix, + Password: password, + ServiceName: watch('ServiceName'), + ServiceUrl: watch('ServiceUrl'), + Notes: "", + Alias: { + FirstName: identity.firstName, + LastName: identity.lastName, + NickName: identity.nickName, + BirthDate: IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()), + Gender: identity.gender, + Email: identity.emailPrefix } - - // Generate random identity - const identity = await identityGenerator.generateRandomIdentity(); - - // Initialize password generator with settings - const passwordGenerator = new PasswordGenerator(passwordSettings); - const password = passwordGenerator.generateRandomPassword(); - - // Create email with domain if available - const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; - - // Create base updated credential object with common properties - const updatedCredential: Partial = { - ...credential, - Alias: { - ...(credential.Alias ?? {}), - Email: email, - FirstName: identity.firstName, - LastName: identity.lastName, - NickName: identity.nickName, - Gender: identity.gender, - BirthDate: IdentityHelperUtils.normalizeBirthDateForDisplay(identity.birthDate.toISOString()), - } - }; - - // In edit mode, preserve existing username and password if they exist - if (isEditMode && credential.Username) { - // Keep the existing username in edit mode - updatedCredential.Username = credential.Username; - } else { - // Use the newly generated username - updatedCredential.Username = identity.nickName; - } - - if (isEditMode && credential.Password) { - // Keep the existing password in edit mode - updatedCredential.Password = credential.Password; - } else { - // Use the newly generated password - updatedCredential.Password = password; - // Make password visible when newly generated - setIsPasswordVisible(true); - } - - setCredential(updatedCredential); - return updatedCredential as Credential; - } catch (error) { - console.error('Error generating random values:', error); - throw error; - } + }; }; const generateRandomUsername = async () => { @@ -260,10 +304,7 @@ export default function AddEditCredentialScreen() { const identity = await identityGenerator.generateRandomIdentity(); // Update only the username - setCredential(prev => ({ - ...prev, - Username: identity.nickName - })); + setValue('Username', identity.nickName); } catch (error) { console.error('Error generating random username:', error); Toast.show({ @@ -284,10 +325,7 @@ export default function AddEditCredentialScreen() { const password = passwordGenerator.generateRandomPassword(); // Update only the password - setCredential(prev => ({ - ...prev, - Password: password - })); + setValue('Password', password); // Make password visible when regenerated setIsPasswordVisible(true); @@ -301,93 +339,12 @@ export default function AddEditCredentialScreen() { } }; - const handleSave = async () => { - Keyboard.dismiss(); - - // Deep clone to avoid mutating state - let credentialToSave: Credential = JSON.parse(JSON.stringify(credential)); - - // Validate and format birth date - credentialToSave.Alias.BirthDate = IdentityHelperUtils.normalizeBirthDateForDb(credentialToSave.Alias.BirthDate); - - // If mode is random, generate random values - if (mode === 'random') { - console.log('Generating random values'); - credentialToSave = await generateRandomAlias(); - } - - await executeVaultMutation(async () => { - if (isEditMode) { - await dbContext.sqliteClient!.updateCredentialById(credentialToSave); - } else { - // For new credentials, try to extract favicon - if (credential.ServiceUrl) { - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Favicon extraction timed out')), 5000) - ); - - const faviconPromise = webApi.get('Favicon/Extract?url=' + credential.ServiceUrl); - const faviconResponse = await Promise.race([faviconPromise, timeoutPromise]) as FaviconExtractModel; - if (faviconResponse?.image) { - const decodedImage = Uint8Array.from(Buffer.from(faviconResponse.image as string, 'base64')); - credential.Logo = decodedImage; - } - } catch (error) { - console.log('Favicon extraction failed or timed out:', error); - } - } - - const credentialId = await dbContext.sqliteClient!.createCredential(credentialToSave); - credentialToSave.Id = credentialId; - } - - // Emit an event to notify list and detail views to refresh - emitter.emit('credentialChanged', credentialToSave.Id); - }); - - // If this was created from autofill (serviceUrl param), show confirmation screen - if (serviceUrl && !isEditMode) { - router.replace('/credentials/autofill-credential-created'); - } else { - if (isEditMode) { - // If editing existing credential, go back to the detail screen via back. - router.back(); - } else { - // If creating new credential, go to the newly created credential via push. - router.replace(`/credentials/${credentialToSave.Id}`); - } - - // Show success toast - setTimeout(() => { - Toast.show({ - type: 'success', - text1: isEditMode ? 'Credential updated successfully' : 'Credential created successfully', - position: 'bottom' - }); - }, 200); - } - }; - const handleAliasChange = (field: keyof Credential['Alias'], value: string | Gender) => { if (field === 'BirthDate') { - setCredential(prev => ({ - ...prev, - Alias: { - ...prev.Alias, - BirthDate: value - } - })); + setValue('Alias.BirthDate', value); } else { - setCredential(prev => ({ - ...prev, - Alias: { - ...prev.Alias, - [field]: value, - BirthDate: prev.Alias?.BirthDate || "0001-01-01" // Ensure BirthDate is always set to satisfy Typescript - } - })); + setValue(`Alias.${field}`, value); } }; @@ -491,7 +448,8 @@ export default function AddEditCredentialScreen() { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, - marginBottom: 16, + marginBottom: 8, + marginTop: 16, }, generateButtonText: { color: '#fff', @@ -601,204 +559,142 @@ export default function AddEditCredentialScreen() { Service - - Service Name - setCredential(prev => ({ ...prev, ServiceName: text }))} - /> - + - - Service URL - setCredential(prev => ({ ...prev, ServiceUrl: text }))} - /> - + {(mode === 'manual' || isEditMode) && ( <> Login credentials - - - - Generate New Alias - - - - Email - handleAliasChange('Email', text)} - /> - + - - Username - - setCredential(prev => ({ ...prev, Username: text }))} - /> - - - - - + setIsPasswordVisible(!isPasswordVisible) + }, + { + icon: "refresh", + onPress: generateRandomPassword + } + ]} + /> - - Password - - setCredential(prev => ({ ...prev, Password: text }))} - secureTextEntry={!isPasswordVisible} - /> - setIsPasswordVisible(!isPasswordVisible)} - > - - - - - - - + + + Generate Random Alias + + + Alias - - First Name - handleAliasChange('FirstName', text)} - /> - + - - Last Name - handleAliasChange('LastName', text)} - /> - + - - Nick Name - handleAliasChange('NickName', text)} - /> - + - - Gender - handleAliasChange('Gender', text)} - /> - + - - Birth Date - handleAliasChange('BirthDate', text)} - /> - + Metadata - - Notes - setCredential(prev => ({ ...prev, Notes: text }))} - multiline - /> - + {/* TODO: Add TOTP management */} diff --git a/apps/mobile-app/components/ValidatedFormField.tsx b/apps/mobile-app/components/ValidatedFormField.tsx new file mode 100644 index 000000000..a9615fe2b --- /dev/null +++ b/apps/mobile-app/components/ValidatedFormField.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { View, TextInput, TextInputProps, StyleSheet, TouchableOpacity, TouchableHighlight } from 'react-native'; +import { ThemedText } from './ThemedText'; +import { Controller, Control, FieldValues, Path } from 'react-hook-form'; +import { useColors } from '@/hooks/useColorScheme'; +import { MaterialIcons } from '@expo/vector-icons'; + +interface FormFieldButton { + icon: string; + onPress: () => void; +} + +interface ValidatedFormFieldProps extends Omit { + label: string; + name: Path; + control: Control; + required?: boolean; + buttons?: FormFieldButton[]; +} + +export const ValidatedFormField = ({ + label, + name, + control, + required, + buttons, + ...props +}: ValidatedFormFieldProps) => { + const colors = useColors(); + const styles = StyleSheet.create({ + container: { + marginBottom: 16, + }, + inputGroup: { + marginBottom: 6, + }, + inputLabel: { + fontSize: 12, + marginBottom: 4, + color: colors.textMuted, + }, + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderColor: colors.accentBorder, + borderRadius: 4, + backgroundColor: colors.background, + }, + input: { + flex: 1, + padding: 10, + fontSize: 16, + color: colors.text, + }, + inputError: { + borderColor: 'red', + }, + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, + requiredIndicator: { + color: 'red', + marginLeft: 4, + }, + button: { + padding: 10, + borderLeftWidth: 1, + borderLeftColor: colors.accentBorder, + }, + }); + + return ( + ( + + {label} {required && *} + + + {buttons?.map((button, index) => ( + + + + ))} + + {error && {error.message}} + + )} + /> + ); +}; \ No newline at end of file diff --git a/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx b/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx index c810c68e0..f9d1038ef 100644 --- a/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx +++ b/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx @@ -13,6 +13,12 @@ export const LoginCredentials: React.FC = ({ credential } const username = credential.Username?.trim(); const password = credential.Password?.trim(); + const hasLoginCredentials = email || username || password; + + if (!hasLoginCredentials) { + return null; + } + return ( Login credentials diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index 109deda90..5542741c1 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^14.0.2", + "@hookform/resolvers": "^5.0.1", "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", @@ -35,6 +36,7 @@ "otpauth": "^9.4.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-hook-form": "^7.56.1", "react-native": "0.76.9", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-argon2": "^2.0.1", @@ -48,7 +50,8 @@ "react-native-toast-message": "^2.2.1", "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", - "secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0" + "secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0", + "yup": "^1.6.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -59,6 +62,7 @@ "@types/react": "~18.3.12", "@types/react-test-renderer": "^18.3.0", "@types/sql.js": "^1.4.9", + "@types/yup": "^0.29.14", "jest": "^29.2.1", "jest-expo": "~52.0.6", "react-native-svg-transformer": "^1.5.0", @@ -2931,6 +2935,18 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@hookform/resolvers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.0.1.tgz", + "integrity": "sha512-u/+Jp83luQNx9AdyW2fIPGY6Y7NG68eN2ZW8FOJYL+M0i4s49+refdJdOp/A9n9HFQtQs3HIDHQvX3ZET2o7YA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -4112,6 +4128,12 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -4800,6 +4822,13 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "license": "MIT" }, + "node_modules/@types/yup": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==", + "dev": true, + "license": "MIT" + }, "node_modules/@urql/core": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz", @@ -12508,6 +12537,12 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -12754,6 +12789,22 @@ "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.56.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz", + "integrity": "sha512-qWAVokhSpshhcEuQDSANHx3jiAEFzu2HAaaQIzi/r9FNPm1ioAvuJSD4EuZzWd7Al7nTRKcKPnBKO7sRn+zavQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -14870,6 +14921,12 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -14897,6 +14954,12 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", + "license": "MIT" + }, "node_modules/tough-cookie": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", @@ -15792,6 +15855,30 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz", + "integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==", + "license": "MIT", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index f750e1b72..d1491c87d 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@expo/vector-icons": "^14.0.2", + "@hookform/resolvers": "^5.0.1", "@react-native-async-storage/async-storage": "1.23.1", "@react-navigation/bottom-tabs": "^7.2.0", "@react-navigation/native": "^7.0.14", @@ -42,6 +43,7 @@ "expo-haptics": "~14.0.1", "expo-linking": "~7.0.5", "expo-local-authentication": "~15.0.2", + "expo-modules-core": "~2.2.3", "expo-router": "~4.0.20", "expo-sharing": "~13.0.1", "expo-splash-screen": "~0.29.24", @@ -54,6 +56,7 @@ "otpauth": "^9.4.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-hook-form": "^7.56.1", "react-native": "0.76.9", "react-native-aes-gcm-crypto": "^0.2.2", "react-native-argon2": "^2.0.1", @@ -68,7 +71,7 @@ "react-native-web": "~0.19.13", "react-native-webview": "13.12.5", "secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0", - "expo-modules-core": "~2.2.3" + "yup": "^1.6.1" }, "devDependencies": { "@babel/core": "^7.25.2", @@ -79,6 +82,7 @@ "@types/react": "~18.3.12", "@types/react-test-renderer": "^18.3.0", "@types/sql.js": "^1.4.9", + "@types/yup": "^0.29.14", "jest": "^29.2.1", "jest-expo": "~52.0.6", "react-native-svg-transformer": "^1.5.0", diff --git a/apps/mobile-app/utils/validationSchema.ts b/apps/mobile-app/utils/validationSchema.ts new file mode 100644 index 000000000..04ec5e6f6 --- /dev/null +++ b/apps/mobile-app/utils/validationSchema.ts @@ -0,0 +1,30 @@ +import * as Yup from 'yup'; + +/** + * Credential add/edit form validation schema used by react-hook-form. + */ +export const credentialSchema = Yup.object().shape({ + ServiceName: Yup.string().required('Service name is required'), + ServiceUrl: Yup.string().url('Invalid URL format').optional(), + Alias: Yup.object().shape({ + FirstName: Yup.string().optional(), + LastName: Yup.string().optional(), + NickName: Yup.string().optional(), + BirthDate: Yup.string() + .nullable() + .notRequired() + .test( + 'is-valid-date-format', + 'Date must be in YYYY-MM-DD format', + value => { + if (!value) return true; // allow empty + return /^\d{4}-\d{2}-\d{2}$/.test(value); + }, + ), + Gender: Yup.string().optional(), + Email: Yup.string().email('Invalid email format').optional() + }), + Username: Yup.string().optional(), + Password: Yup.string().nullable().notRequired(), + Notes: Yup.string().optional() +});