Add credential form validation (#771)

This commit is contained in:
Leendert de Borst
2025-05-02 13:22:15 +02:00
parent 8c06b46044
commit 3be65beb06
6 changed files with 545 additions and 416 deletions

View File

@@ -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<TextInput>(null);
const [credential, setCredential] = useState<Partial<Credential>>({
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<Credential>({
resolver: yupResolver(credentialSchema) as Resolver<Credential>,
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<FaviconExtractModel>('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: () => (
<View style={{ flexDirection: 'row' }}>
<TouchableOpacity
onPress={handleSave}
onPress={handleSubmit(onSubmit)}
style={{ padding: 10, paddingRight: 0 }}
>
<MaterialIcons
name="save"
size={24}
color={colors.primary}
/>
<MaterialIcons name="save" size={24} color={colors.primary} />
</TouchableOpacity>
</View>
),
});
}, [navigation, credential, mode]);
}, [navigation, mode]);
const isEditMode = !!id;
const generateRandomAlias = async (): Promise<Credential> => {
// 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<Credential> => {
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> = {
...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<FaviconExtractModel>('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() {
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>Service</ThemedText>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Service Name</ThemedText>
<TextInput
ref={serviceNameInputRef}
style={styles.input}
placeholder="Service Name"
placeholderTextColor={colors.textMuted}
value={credential.ServiceName}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => setCredential(prev => ({ ...prev, ServiceName: text }))}
/>
</View>
<ValidatedFormField
control={control}
name="ServiceName"
label="Service Name"
required
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Service URL</ThemedText>
<TextInput
style={styles.input}
placeholder="Service URL"
placeholderTextColor={colors.textMuted}
value={credential.ServiceUrl}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => setCredential(prev => ({ ...prev, ServiceUrl: text }))}
/>
</View>
<ValidatedFormField
control={control}
name="ServiceUrl"
label="Service URL"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
{(mode === 'manual' || isEditMode) && (
<>
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>Login credentials</ThemedText>
<View style={styles.inputGroup}>
<TouchableOpacity style={styles.generateButton} onPress={generateRandomAlias}>
<MaterialIcons name="auto-fix-high" size={20} color="#fff" />
<ThemedText style={styles.generateButtonText}>Generate New Alias</ThemedText>
</TouchableOpacity>
</View>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Email</ThemedText>
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
value={credential.Alias?.Email}
onChangeText={(text) => handleAliasChange('Email', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Username"
label="Username"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
buttons={[
{
icon: "refresh",
onPress: generateRandomUsername
}
]}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Username</ThemedText>
<View style={styles.inputWithButton}>
<TextInput
style={styles.inputField}
placeholder="Username"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
value={credential.Username}
onChangeText={(text) => setCredential(prev => ({ ...prev, Username: text }))}
/>
<TouchableOpacity
style={styles.inputButton}
onPress={generateRandomUsername}
>
<MaterialIcons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
</View>
<ValidatedFormField
control={control}
name="Password"
label="Password"
secureTextEntry={!isPasswordVisible}
buttons={[
{
icon: isPasswordVisible ? "visibility-off" : "visibility",
onPress: () => setIsPasswordVisible(!isPasswordVisible)
},
{
icon: "refresh",
onPress: generateRandomPassword
}
]}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Password</ThemedText>
<View style={styles.inputWithButton}>
<TextInput
style={styles.inputField}
placeholder="Password"
placeholderTextColor={colors.textMuted}
value={credential.Password}
onChangeText={(text) => setCredential(prev => ({ ...prev, Password: text }))}
secureTextEntry={!isPasswordVisible}
/>
<TouchableOpacity
style={styles.inputButton}
onPress={() => setIsPasswordVisible(!isPasswordVisible)}
>
<MaterialIcons
name={isPasswordVisible ? "visibility-off" : "visibility"}
size={20}
color={colors.primary}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.inputButton}
onPress={generateRandomPassword}
>
<MaterialIcons name="refresh" size={20} color={colors.primary} />
</TouchableOpacity>
</View>
</View>
<TouchableOpacity style={styles.generateButton} onPress={generateRandomAlias}>
<MaterialIcons name="auto-fix-high" size={20} color="#fff" />
<ThemedText style={styles.generateButtonText}>Generate Random Alias</ThemedText>
</TouchableOpacity>
<ValidatedFormField
control={control}
name="Alias.Email"
label="Email"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>Alias</ThemedText>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>First Name</ThemedText>
<TextInput
style={styles.input}
placeholder="First Name"
placeholderTextColor={colors.textMuted}
value={credential.Alias?.FirstName}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => handleAliasChange('FirstName', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Alias.FirstName"
label="First Name"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Last Name</ThemedText>
<TextInput
style={styles.input}
placeholder="Last Name"
placeholderTextColor={colors.textMuted}
value={credential.Alias?.LastName}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => handleAliasChange('LastName', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Alias.LastName"
label="Last Name"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Nick Name</ThemedText>
<TextInput
style={styles.input}
placeholder="Nick Name"
placeholderTextColor={colors.textMuted}
value={credential.Alias?.NickName}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => handleAliasChange('NickName', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Alias.NickName"
label="Nick Name"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Gender</ThemedText>
<TextInput
style={styles.input}
placeholder="Gender"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
value={credential.Alias?.Gender}
onChangeText={(text) => handleAliasChange('Gender', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Alias.Gender"
label="Gender"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Birth Date</ThemedText>
<TextInput
style={styles.input}
placeholder="Birth Date (YYYY-MM-DD)"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
value={credential.Alias?.BirthDate}
onChangeText={(text) => handleAliasChange('BirthDate', text)}
/>
</View>
<ValidatedFormField
control={control}
name="Alias.BirthDate"
label="Birth Date"
placeholder="YYYY-MM-DD"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
</View>
<View style={styles.section}>
<ThemedText style={styles.sectionTitle}>Metadata</ThemedText>
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>Notes</ThemedText>
<TextInput
style={styles.notesInput}
placeholder="Notes"
placeholderTextColor={colors.textMuted}
value={credential.Notes}
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={(text) => setCredential(prev => ({ ...prev, Notes: text }))}
multiline
/>
</View>
<ValidatedFormField
control={control}
name="Notes"
label="Notes"
multiline
numberOfLines={4}
textAlignVertical="top"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
/>
{/* TODO: Add TOTP management */}
</View>

View File

@@ -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<T extends FieldValues> extends Omit<TextInputProps, 'value' | 'onChangeText'> {
label: string;
name: Path<T>;
control: Control<T>;
required?: boolean;
buttons?: FormFieldButton[];
}
export const ValidatedFormField = <T extends FieldValues>({
label,
name,
control,
required,
buttons,
...props
}: ValidatedFormFieldProps<T>) => {
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 (
<Controller
control={control}
name={name}
render={({ field: { onChange, value }, fieldState: { error } }) => (
<View style={styles.inputGroup}>
<ThemedText style={styles.inputLabel}>{label} {required && <ThemedText style={styles.requiredIndicator}>*</ThemedText>}</ThemedText>
<View style={[styles.inputContainer, error ? styles.inputError : null]}>
<TextInput
style={styles.input}
value={value as string}
placeholderTextColor={colors.textMuted}
onChangeText={onChange}
{...props}
/>
{buttons?.map((button, index) => (
<TouchableHighlight
key={index}
style={styles.button}
onPress={button.onPress}
underlayColor={colors.accentBackground}
>
<MaterialIcons name={button.icon as any} size={20} color={colors.primary} />
</TouchableHighlight>
))}
</View>
{error && <ThemedText style={styles.errorText}>{error.message}</ThemedText>}
</View>
)}
/>
);
};

View File

@@ -13,6 +13,12 @@ export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }
const username = credential.Username?.trim();
const password = credential.Password?.trim();
const hasLoginCredentials = email || username || password;
if (!hasLoginCredentials) {
return null;
}
return (
<ThemedView style={styles.section}>
<ThemedText type="subtitle">Login credentials</ThemedText>

View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -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()
});