mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-14 02:15:57 -04:00
Add credential form validation (#771)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
106
apps/mobile-app/components/ValidatedFormField.tsx
Normal file
106
apps/mobile-app/components/ValidatedFormField.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
89
apps/mobile-app/package-lock.json
generated
89
apps/mobile-app/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
apps/mobile-app/utils/validationSchema.ts
Normal file
30
apps/mobile-app/utils/validationSchema.ts
Normal 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()
|
||||
});
|
||||
Reference in New Issue
Block a user