mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-04-04 23:05:19 -04:00
579 lines
18 KiB
TypeScript
579 lines
18 KiB
TypeScript
import { MaterialIcons } from '@expo/vector-icons';
|
|
import Slider from '@react-native-community/slider';
|
|
import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState, useCallback, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { View, TextInput, TextInputProps, StyleSheet, Platform, TouchableOpacity, ScrollView, Switch } from 'react-native';
|
|
|
|
import type { PasswordSettings } from '@/utils/dist/core/models/vault';
|
|
import { CreatePasswordGenerator } from '@/utils/dist/core/password-generator';
|
|
import { HapticsUtility } from '@/utils/HapticsUtility';
|
|
import { sliderToLength, lengthToSlider, SLIDER_MIN, SLIDER_MAX } from '@/utils/passwordLengthSlider';
|
|
|
|
import { useColors } from '@/hooks/useColorScheme';
|
|
|
|
import { ThemedText } from '@/components/themed/ThemedText';
|
|
import { useDb } from '@/context/DbContext';
|
|
import { ModalWrapper } from '@/components/common/ModalWrapper';
|
|
|
|
export type AdvancedPasswordFieldRef = {
|
|
focus: () => void;
|
|
selectAll: () => void;
|
|
};
|
|
|
|
type AdvancedPasswordFieldProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
|
label: string;
|
|
value: string;
|
|
onChangeText: (value: string) => void;
|
|
required?: boolean;
|
|
showPassword?: boolean;
|
|
onShowPasswordChange?: (show: boolean) => void;
|
|
isNewCredential?: boolean;
|
|
/** Optional callback for remove button - when provided, shows X button in label row */
|
|
onRemove?: () => void;
|
|
/** Optional testID for the text input */
|
|
testID?: string;
|
|
/** Optional initial password settings to use immediately (prevents flicker) */
|
|
initialSettings?: PasswordSettings;
|
|
}
|
|
|
|
const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, AdvancedPasswordFieldProps>(({
|
|
label,
|
|
value,
|
|
onChangeText,
|
|
required,
|
|
showPassword: controlledShowPassword,
|
|
onShowPasswordChange,
|
|
isNewCredential = false,
|
|
onRemove,
|
|
testID,
|
|
initialSettings,
|
|
...props
|
|
}, ref) => {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const inputRef = useRef<TextInput>(null);
|
|
const [internalShowPassword, setInternalShowPassword] = useState(false);
|
|
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
|
// Initialize settings immediately from initialSettings if provided, otherwise null
|
|
const [currentSettings, setCurrentSettings] = useState<PasswordSettings | null>(initialSettings || null);
|
|
const [previewPassword, setPreviewPassword] = useState<string>('');
|
|
// Initialize slider value immediately from initialSettings or value length, otherwise default to 16
|
|
const [sliderValue, setSliderValue] = useState<number>(() => {
|
|
if (initialSettings) {
|
|
return lengthToSlider(initialSettings.Length);
|
|
}
|
|
if (!isNewCredential && value && value.length > 0) {
|
|
return lengthToSlider(value.length);
|
|
}
|
|
return lengthToSlider(16);
|
|
});
|
|
const lastGeneratedLength = useRef<number>(0);
|
|
const isSliding = useRef(false);
|
|
const hasSetInitialLength = useRef(!!initialSettings || (!isNewCredential && value && value.length > 0));
|
|
const dbContext = useDb();
|
|
const showPassword = controlledShowPassword ?? internalShowPassword;
|
|
|
|
const setShowPasswordState = useCallback((show: boolean) => {
|
|
if (controlledShowPassword !== undefined) {
|
|
onShowPasswordChange?.(show);
|
|
} else {
|
|
setInternalShowPassword(show);
|
|
}
|
|
}, [controlledShowPassword, onShowPasswordChange]);
|
|
|
|
// Load password settings from database (only if initialSettings not provided)
|
|
useEffect(() => {
|
|
// If we already have initialSettings, skip loading from database
|
|
if (initialSettings) {
|
|
return;
|
|
}
|
|
const loadSettings = async () => {
|
|
try {
|
|
if (dbContext.sqliteClient) {
|
|
const settings = await dbContext.sqliteClient.getPasswordSettings();
|
|
setCurrentSettings(settings);
|
|
// Only update slider if we haven't set it from value yet
|
|
if (!hasSetInitialLength.current) {
|
|
setSliderValue(lengthToSlider(settings.Length));
|
|
hasSetInitialLength.current = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading password settings:', error);
|
|
}
|
|
};
|
|
loadSettings();
|
|
}, [dbContext.sqliteClient, initialSettings]);
|
|
|
|
// Sync slider value with password length (only if not already initialized)
|
|
useEffect(() => {
|
|
if (!hasSetInitialLength.current) {
|
|
if (!isNewCredential && value && value.length > 0) {
|
|
setSliderValue(lengthToSlider(value.length));
|
|
hasSetInitialLength.current = true;
|
|
} else if (isNewCredential) {
|
|
hasSetInitialLength.current = true;
|
|
}
|
|
}
|
|
}, [value, isNewCredential]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => inputRef.current?.focus(),
|
|
selectAll: () => {
|
|
const input = inputRef.current;
|
|
if (input && value) {
|
|
input.setSelection(0, value.length);
|
|
}
|
|
}
|
|
}), [value]);
|
|
|
|
const generatePassword = useCallback((settings: PasswordSettings): string => {
|
|
try {
|
|
const passwordGenerator = CreatePasswordGenerator(settings);
|
|
return passwordGenerator.generateRandomPassword();
|
|
} catch (error) {
|
|
console.error('Error generating password:', error);
|
|
return '';
|
|
}
|
|
}, []);
|
|
|
|
const handleGeneratePassword = useCallback(() => {
|
|
if (currentSettings) {
|
|
const password = generatePassword(currentSettings);
|
|
if (password) {
|
|
onChangeText(password);
|
|
setShowPasswordState(true);
|
|
|
|
// Haptic feedback for password generation
|
|
HapticsUtility.impact();
|
|
}
|
|
}
|
|
}, [currentSettings, generatePassword, onChangeText, setShowPasswordState]);
|
|
|
|
const handleSliderChange = useCallback((sliderVal: number) => {
|
|
setSliderValue(sliderVal);
|
|
const passwordLength = sliderToLength(sliderVal);
|
|
|
|
if (passwordLength !== lastGeneratedLength.current && isSliding.current) {
|
|
lastGeneratedLength.current = passwordLength;
|
|
|
|
if (!showPassword) {
|
|
setShowPasswordState(true);
|
|
}
|
|
|
|
const newSettings = { ...(currentSettings || {}), Length: passwordLength } as PasswordSettings;
|
|
if (currentSettings) {
|
|
const password = generatePassword(newSettings);
|
|
if (password) {
|
|
onChangeText(password);
|
|
}
|
|
}
|
|
}
|
|
}, [currentSettings, generatePassword, showPassword, setShowPasswordState, onChangeText]);
|
|
|
|
const handleSliderStart = useCallback(() => {
|
|
isSliding.current = true;
|
|
lastGeneratedLength.current = sliderToLength(sliderValue);
|
|
}, [sliderValue]);
|
|
|
|
const handleSliderComplete = useCallback((sliderVal: number) => {
|
|
isSliding.current = false;
|
|
const passwordLength = sliderToLength(sliderVal);
|
|
if (currentSettings) {
|
|
const newSettings = { ...currentSettings, Length: passwordLength };
|
|
setCurrentSettings(newSettings);
|
|
}
|
|
lastGeneratedLength.current = 0;
|
|
}, [currentSettings]);
|
|
|
|
const handleRefreshPreview = useCallback(() => {
|
|
if (currentSettings) {
|
|
const password = generatePassword(currentSettings);
|
|
setPreviewPassword(password);
|
|
}
|
|
}, [currentSettings, generatePassword]);
|
|
|
|
const handleUsePassword = useCallback(() => {
|
|
if (previewPassword) {
|
|
onChangeText(previewPassword);
|
|
setShowPasswordState(true);
|
|
setShowSettingsModal(false);
|
|
}
|
|
}, [previewPassword, onChangeText, setShowPasswordState]);
|
|
|
|
const handleOpenSettings = useCallback(() => {
|
|
if (currentSettings) {
|
|
const password = generatePassword(currentSettings);
|
|
setPreviewPassword(password);
|
|
setShowSettingsModal(true);
|
|
}
|
|
}, [currentSettings, generatePassword]);
|
|
|
|
const updateSetting = useCallback((key: keyof PasswordSettings, settingValue: boolean) => {
|
|
setCurrentSettings(prev => {
|
|
if (!prev) return prev;
|
|
const newSettings = { ...prev, [key]: settingValue };
|
|
const password = generatePassword(newSettings);
|
|
setPreviewPassword(password);
|
|
return newSettings;
|
|
});
|
|
}, [generatePassword]);
|
|
|
|
const styles = useMemo(() => StyleSheet.create({
|
|
button: {
|
|
borderLeftColor: colors.accentBorder,
|
|
borderLeftWidth: 1,
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
},
|
|
clearButton: {
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 10,
|
|
},
|
|
closeButton: {
|
|
padding: 8,
|
|
},
|
|
errorText: {
|
|
color: 'red',
|
|
fontSize: 12,
|
|
marginTop: 4,
|
|
},
|
|
input: {
|
|
color: colors.text,
|
|
flex: 1,
|
|
fontSize: 16,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 10,
|
|
},
|
|
inputContainer: {
|
|
alignItems: 'center',
|
|
backgroundColor: colors.background,
|
|
borderColor: colors.accentBorder,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
flexDirection: 'row',
|
|
},
|
|
inputGroup: {
|
|
marginBottom: 6,
|
|
},
|
|
inputLabel: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
},
|
|
labelContainer: {
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 4,
|
|
},
|
|
removeButton: {
|
|
padding: 4,
|
|
},
|
|
modalHeader: {
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 20,
|
|
},
|
|
modalTitle: {
|
|
color: colors.text,
|
|
fontSize: 18,
|
|
fontWeight: '600',
|
|
},
|
|
previewContainer: {
|
|
marginBottom: 20,
|
|
},
|
|
previewInput: {
|
|
color: colors.text,
|
|
flex: 1,
|
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
|
fontSize: 14,
|
|
padding: 12,
|
|
textAlign: 'center',
|
|
},
|
|
previewInputContainer: {
|
|
alignItems: 'center',
|
|
backgroundColor: colors.background,
|
|
borderColor: colors.accentBorder,
|
|
borderRadius: 6,
|
|
borderWidth: 1,
|
|
flexDirection: 'row',
|
|
},
|
|
refreshButton: {
|
|
borderLeftColor: colors.accentBorder,
|
|
borderLeftWidth: 1,
|
|
padding: 10,
|
|
},
|
|
requiredIndicator: {
|
|
color: 'red',
|
|
marginLeft: 4,
|
|
},
|
|
settingItem: {
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 10,
|
|
},
|
|
settingLabel: {
|
|
color: colors.text,
|
|
flex: 1,
|
|
fontSize: 14,
|
|
},
|
|
settingsButton: {
|
|
marginLeft: 8,
|
|
padding: 4,
|
|
},
|
|
settingsSection: {
|
|
marginBottom: 20,
|
|
},
|
|
slider: {
|
|
height: 40,
|
|
width: '100%',
|
|
},
|
|
sliderContainer: {
|
|
marginTop: 8,
|
|
paddingHorizontal: 4,
|
|
},
|
|
sliderHeader: {
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
},
|
|
sliderLabel: {
|
|
color: colors.textMuted,
|
|
fontSize: 12,
|
|
},
|
|
sliderValue: {
|
|
color: colors.text,
|
|
fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace',
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
},
|
|
sliderValueContainer: {
|
|
alignItems: 'center',
|
|
flexDirection: 'row',
|
|
},
|
|
useButton: {
|
|
alignItems: 'center',
|
|
backgroundColor: colors.primary,
|
|
borderRadius: 6,
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
padding: 12,
|
|
},
|
|
useButtonText: {
|
|
color: colors.text,
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
marginLeft: 8,
|
|
},
|
|
}), [colors]);
|
|
|
|
const showClearButton = Platform.OS === 'android' && value && value.length > 0;
|
|
|
|
const settingsModalContent = (
|
|
<>
|
|
<View style={styles.modalHeader}>
|
|
<ThemedText style={styles.modalTitle}>{t('items.changePasswordComplexity')}</ThemedText>
|
|
<TouchableOpacity
|
|
style={styles.closeButton}
|
|
onPress={() => setShowSettingsModal(false)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="close" size={24} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
<View style={styles.previewContainer}>
|
|
<View style={styles.previewInputContainer}>
|
|
<TextInput
|
|
style={styles.previewInput}
|
|
value={previewPassword}
|
|
editable={false}
|
|
/>
|
|
<TouchableOpacity
|
|
style={styles.refreshButton}
|
|
onPress={handleRefreshPreview}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="refresh" size={20} color={colors.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
<View style={styles.settingsSection}>
|
|
<View style={styles.settingItem}>
|
|
<ThemedText style={styles.settingLabel}>{t('items.includeLowercase')}</ThemedText>
|
|
<Switch
|
|
value={currentSettings?.UseLowercase ?? true}
|
|
onValueChange={(settingValue) => updateSetting('UseLowercase', settingValue)}
|
|
trackColor={{ false: colors.accentBorder, true: colors.primary }}
|
|
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.settingItem}>
|
|
<ThemedText style={styles.settingLabel}>{t('items.includeUppercase')}</ThemedText>
|
|
<Switch
|
|
value={currentSettings?.UseUppercase ?? true}
|
|
onValueChange={(settingValue) => updateSetting('UseUppercase', settingValue)}
|
|
trackColor={{ false: colors.accentBorder, true: colors.primary }}
|
|
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.settingItem}>
|
|
<ThemedText style={styles.settingLabel}>{t('items.includeNumbers')}</ThemedText>
|
|
<Switch
|
|
value={currentSettings?.UseNumbers ?? true}
|
|
onValueChange={(settingValue) => updateSetting('UseNumbers', settingValue)}
|
|
trackColor={{ false: colors.accentBorder, true: colors.primary }}
|
|
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.settingItem}>
|
|
<ThemedText style={styles.settingLabel}>{t('items.includeSpecialChars')}</ThemedText>
|
|
<Switch
|
|
value={currentSettings?.UseSpecialChars ?? true}
|
|
onValueChange={(settingValue) => updateSetting('UseSpecialChars', settingValue)}
|
|
trackColor={{ false: colors.accentBorder, true: colors.primary }}
|
|
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.settingItem}>
|
|
<ThemedText style={styles.settingLabel}>{t('items.avoidAmbiguousChars')}</ThemedText>
|
|
<Switch
|
|
value={currentSettings?.UseNonAmbiguousChars ?? false}
|
|
onValueChange={(settingValue) => updateSetting('UseNonAmbiguousChars', settingValue)}
|
|
trackColor={{ false: colors.accentBorder, true: colors.primary }}
|
|
thumbColor={Platform.OS === 'android' ? colors.background : undefined}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.useButton}
|
|
onPress={handleUsePassword}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="keyboard-arrow-down" size={20} color={colors.text} />
|
|
<ThemedText style={styles.useButtonText}>{t('common.use')}</ThemedText>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<View style={styles.inputGroup}>
|
|
<View style={styles.labelContainer}>
|
|
<ThemedText style={styles.inputLabel}>
|
|
{label} {required && <ThemedText style={styles.requiredIndicator}>*</ThemedText>}
|
|
</ThemedText>
|
|
{onRemove && (
|
|
<TouchableOpacity
|
|
style={styles.removeButton}
|
|
onPress={onRemove}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="close" size={18} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
<View style={styles.inputContainer}>
|
|
<TextInput
|
|
ref={inputRef}
|
|
style={styles.input}
|
|
value={value}
|
|
placeholderTextColor={colors.textMuted}
|
|
onChangeText={onChangeText}
|
|
autoCapitalize="none"
|
|
autoComplete="off"
|
|
autoCorrect={false}
|
|
clearButtonMode={Platform.OS === 'ios' ? "while-editing" : "never"}
|
|
secureTextEntry={!showPassword}
|
|
testID={testID}
|
|
accessibilityLabel={testID}
|
|
{...props}
|
|
/>
|
|
|
|
{showClearButton && (
|
|
<TouchableOpacity
|
|
style={styles.clearButton}
|
|
onPress={() => onChangeText('')}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="close" size={16} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={() => setShowPasswordState(!showPassword)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons
|
|
name={showPassword ? "visibility-off" : "visibility"}
|
|
size={20}
|
|
color={colors.primary}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={handleGeneratePassword}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="refresh" size={20} color={colors.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={styles.sliderContainer}>
|
|
<View style={styles.sliderHeader}>
|
|
<ThemedText style={styles.sliderLabel}>{t('items.passwordLength')}</ThemedText>
|
|
<View style={styles.sliderValueContainer}>
|
|
<ThemedText style={styles.sliderValue}>{sliderToLength(sliderValue)}</ThemedText>
|
|
<TouchableOpacity
|
|
style={styles.settingsButton}
|
|
onPress={handleOpenSettings}
|
|
activeOpacity={0.7}
|
|
>
|
|
<MaterialIcons name="settings" size={20} color={colors.primary} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
<Slider
|
|
style={styles.slider}
|
|
minimumValue={SLIDER_MIN}
|
|
maximumValue={SLIDER_MAX}
|
|
value={sliderValue}
|
|
onValueChange={handleSliderChange}
|
|
onSlidingStart={handleSliderStart}
|
|
onSlidingComplete={handleSliderComplete}
|
|
minimumTrackTintColor={colors.primary}
|
|
maximumTrackTintColor={colors.accentBorder}
|
|
thumbTintColor={colors.primary}
|
|
/>
|
|
</View>
|
|
|
|
<ModalWrapper
|
|
isOpen={showSettingsModal}
|
|
onClose={() => setShowSettingsModal(false)}
|
|
showHeaderBorder={false}
|
|
showFooterBorder={false}
|
|
>
|
|
{settingsModalContent}
|
|
</ModalWrapper>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
AdvancedPasswordFieldComponent.displayName = 'AdvancedPasswordField';
|
|
|
|
export const AdvancedPasswordField = AdvancedPasswordFieldComponent;
|