mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-02 05:46:39 -05:00
Linting refactor (#771)
This commit is contained in:
@@ -42,6 +42,9 @@ module.exports = {
|
||||
version: "detect",
|
||||
},
|
||||
"import/ignore": ["node_modules/react-native/index\\.js"],
|
||||
'react-native/components': {
|
||||
Text: ['ThemedText'],
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// TypeScript
|
||||
@@ -144,5 +147,12 @@ module.exports = {
|
||||
"no-console": ["error", { allow: ["warn", "error", "info", "debug"] }],
|
||||
"spaced-comment": ["error", "always"],
|
||||
"multiline-comment-style": ["error", "starred-block"],
|
||||
|
||||
// TODO: this line is added to prevent "Raw text (×) cannot be used outside of a <Text> tag" errors.
|
||||
// When adding proper i18n multilingual enforcement checks, the following line should be removed
|
||||
'react-native/no-raw-text': 'off',
|
||||
|
||||
// Disable prop-types rule because we're using TypeScript for type-checking
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { Tabs, router } from 'expo-router';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Platform, View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Platform, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import TabBarBackground from '@/components/ui/TabBarBackground';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
@@ -10,7 +10,10 @@ import { useDb } from '@/context/DbContext';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
export default function TabLayout() {
|
||||
/**
|
||||
* This is the main layout for the app. It is used to navigate between the tabs.
|
||||
*/
|
||||
export default function TabLayout() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
@@ -27,7 +30,7 @@ export default function TabLayout() {
|
||||
const timer = setTimeout(() => {
|
||||
router.replace('/login');
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
return () : void => clearTimeout(timer);
|
||||
}
|
||||
}, [requireLoginOrUnlock]);
|
||||
|
||||
@@ -35,12 +38,40 @@ export default function TabLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iconContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconNotificationContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: -4,
|
||||
width: 16,
|
||||
},
|
||||
iconNotificationText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenListeners={{
|
||||
/**
|
||||
* Listener for the tab press event.
|
||||
* @param {Object} e - The event object.
|
||||
* @param {string} e.target - The target pathname.
|
||||
*/
|
||||
tabPress: (e) => {
|
||||
const targetPathname = (e.target as string).split('-')[0];
|
||||
console.log('Tab pressed in layout, navigating to:', targetPathname);
|
||||
emitter.emit('tabPress', targetPathname);
|
||||
},
|
||||
}}
|
||||
@@ -51,7 +82,7 @@ export default function TabLayout() {
|
||||
tabBarStyle: Platform.select({
|
||||
ios: {
|
||||
position: 'absolute',
|
||||
//backgroundColor: colors.tabBarBackground,
|
||||
// backgroundColor: colors.tabBarBackground,
|
||||
},
|
||||
default: {},
|
||||
}),
|
||||
@@ -60,6 +91,9 @@ export default function TabLayout() {
|
||||
name="credentials"
|
||||
options={{
|
||||
title: 'Credentials',
|
||||
/**
|
||||
* Icon for the credentials tab.
|
||||
*/
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="key.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
@@ -67,6 +101,9 @@ export default function TabLayout() {
|
||||
name="emails"
|
||||
options={{
|
||||
title: 'Emails',
|
||||
/**
|
||||
* Icon for the emails tab.
|
||||
*/
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="envelope.fill" color={color} />,
|
||||
}}
|
||||
/>
|
||||
@@ -74,34 +111,15 @@ export default function TabLayout() {
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
/**
|
||||
* Icon for the settings tab.
|
||||
*/
|
||||
tabBarIcon: ({ color }) => (
|
||||
<View style={{ position: 'relative' }}>
|
||||
<View style={styles.iconContainer}>
|
||||
<IconSymbol size={28} name="gear" color={color} />
|
||||
{Platform.OS === 'ios' && authContext.shouldShowIosAutofillReminder && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
width: 16,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ThemedText
|
||||
style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
}}
|
||||
>
|
||||
1
|
||||
</ThemedText>
|
||||
<View style={styles.iconNotificationContainer}>
|
||||
<ThemedText style={styles.iconNotificationText}>1</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ActivityIndicator, View, Text, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ThemedScrollView } from '@/components/ThemedScrollView';
|
||||
@@ -13,11 +15,13 @@ import { AliasDetails } from '@/components/credentialDetails/AliasDetails';
|
||||
import { NotesSection } from '@/components/credentialDetails/NotesSection';
|
||||
import { EmailPreview } from '@/components/credentialDetails/EmailPreview';
|
||||
import { TotpSection } from '@/components/credentialDetails/TotpSection';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
|
||||
export default function CredentialDetailsScreen() {
|
||||
/**
|
||||
* Credential details screen.
|
||||
*/
|
||||
export default function CredentialDetailsScreen() : React.ReactNode {
|
||||
const { id } = useLocalSearchParams();
|
||||
const [credential, setCredential] = useState<Credential | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -26,33 +30,44 @@ export default function CredentialDetailsScreen() {
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* Handle the edit button press.
|
||||
*/
|
||||
const handleEdit = useCallback(() : void => {
|
||||
router.push(`/(tabs)/credentials/add-edit?id=${id}`);
|
||||
}, [id, router]);
|
||||
|
||||
// Set header buttons
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
/**
|
||||
* Header right button.
|
||||
*/
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={styles.headerRightContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={handleEdit}
|
||||
style={{ padding: 10, paddingRight: 0 }}
|
||||
style={styles.headerRightButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="edit"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
name="edit"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation, credential]);
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/(tabs)/credentials/add-edit?id=${id}`);
|
||||
}
|
||||
}, [navigation, credential, handleEdit, colors.primary]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCredential = async () => {
|
||||
if (!dbContext.dbAvailable || !id) return;
|
||||
/**
|
||||
* Load the credential.
|
||||
*/
|
||||
const loadCredential = async () : Promise<void> => {
|
||||
if (!dbContext.dbAvailable || !id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cred = await dbContext.sqliteClient!.getCredentialById(id as string);
|
||||
@@ -69,20 +84,19 @@ export default function CredentialDetailsScreen() {
|
||||
// Add listener for credential changes
|
||||
const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
|
||||
if (changedId === id) {
|
||||
console.log('This credential was changed, refreshing details');
|
||||
await loadCredential();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
return () : void => {
|
||||
credentialChangedSub.remove();
|
||||
Toast.hide();
|
||||
};
|
||||
}, [id, dbContext.dbAvailable]);
|
||||
}, [id, dbContext.dbAvailable, dbContext.sqliteClient]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#f97316" />
|
||||
</View>
|
||||
);
|
||||
@@ -94,8 +108,8 @@ export default function CredentialDetailsScreen() {
|
||||
|
||||
return (
|
||||
<ThemedScrollView style={styles.container}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
>
|
||||
<ThemedView style={styles.header}>
|
||||
<CredentialIcon logo={credential.Logo} style={styles.logo} />
|
||||
@@ -125,20 +139,34 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginTop: 6,
|
||||
padding: 16,
|
||||
},
|
||||
headerRightButton: {
|
||||
padding: 10,
|
||||
},
|
||||
headerRightContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
headerText: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
height: 48,
|
||||
width: 48,
|
||||
},
|
||||
serviceName: {
|
||||
fontSize: 24,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { Stack } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export default function CredentialsLayout() {
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
/**
|
||||
* Credentials layout.
|
||||
*/
|
||||
export default function CredentialsLayout() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { StyleSheet, View, TextInput, TouchableOpacity, ScrollView, ActivityIndicator, Alert, Keyboard } from 'react-native';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, View, TouchableOpacity, ScrollView, Alert, Keyboard } from 'react-native';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
@@ -8,8 +13,6 @@ import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { FaviconExtractModel } from '@/utils/types/webapi/FaviconExtractModel';
|
||||
import { AliasVaultToast } from '@/components/Toast';
|
||||
@@ -17,14 +20,15 @@ import { useVaultMutate } from '@/hooks/useVaultMutate';
|
||||
import { IdentityGeneratorEn, IdentityGeneratorNl, IdentityHelperUtils, BaseIdentityGenerator } from '@/utils/shared/identity-generator';
|
||||
import { PasswordGenerator } from '@/utils/shared/password-generator';
|
||||
import { ValidatedFormField, ValidatedFormFieldRef } from '@/components/ValidatedFormField';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { credentialSchema } from '@/utils/validationSchema';
|
||||
import LoadingOverlay from '@/components/LoadingOverlay';
|
||||
|
||||
type CredentialMode = 'random' | 'manual';
|
||||
|
||||
export default function AddEditCredentialScreen() {
|
||||
/**
|
||||
* Add or edit a credential screen.
|
||||
*/
|
||||
export default function AddEditCredentialScreen() : React.ReactNode {
|
||||
const { id, serviceUrl } = useLocalSearchParams<{ id: string, serviceUrl?: string }>();
|
||||
const router = useRouter();
|
||||
const colors = useColors();
|
||||
@@ -36,7 +40,7 @@ export default function AddEditCredentialScreen() {
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const serviceNameRef = useRef<ValidatedFormFieldRef>(null);
|
||||
|
||||
const { control, handleSubmit, formState: { errors }, setValue, watch } = useForm<Credential>({
|
||||
const { control, handleSubmit, setValue, watch } = useForm<Credential>({
|
||||
resolver: yupResolver(credentialSchema) as Resolver<Credential>,
|
||||
defaultValues: {
|
||||
Id: "",
|
||||
@@ -61,31 +65,10 @@ export default function AddEditCredentialScreen() {
|
||||
*/
|
||||
const isEditMode = id !== undefined && id.length > 0;
|
||||
|
||||
/**
|
||||
* On mount, load an existing credential if we're in edit mode, or extract the service name from the service URL
|
||||
* if we're in add mode and the service URL is provided (by native autofill component).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
loadExistingCredential();
|
||||
} else if (serviceUrl) {
|
||||
const decodedUrl = decodeURIComponent(serviceUrl);
|
||||
const serviceName = extractServiceNameFromUrl(decodedUrl);
|
||||
setValue('ServiceUrl', decodedUrl);
|
||||
setValue('ServiceName', serviceName);
|
||||
|
||||
// Focus and select the service name field
|
||||
setTimeout(() => {
|
||||
serviceNameRef.current?.focus();
|
||||
serviceNameRef.current?.selectAll();
|
||||
}, 100);
|
||||
}
|
||||
}, [id, isEditMode, serviceUrl]);
|
||||
|
||||
/**
|
||||
* Load an existing credential from the database in edit mode.
|
||||
*/
|
||||
const loadExistingCredential = async () => {
|
||||
const loadExistingCredential = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
const existingCredential = await dbContext.sqliteClient!.getCredentialById(id);
|
||||
if (existingCredential) {
|
||||
@@ -105,13 +88,99 @@ export default function AddEditCredentialScreen() {
|
||||
text2: 'Please try again'
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [id, dbContext.sqliteClient, setValue]);
|
||||
|
||||
/**
|
||||
* On mount, load an existing credential if we're in edit mode, or extract the service name from the service URL
|
||||
* if we're in add mode and the service URL is provided (by native autofill component).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
loadExistingCredential();
|
||||
} else if (serviceUrl) {
|
||||
const decodedUrl = decodeURIComponent(serviceUrl);
|
||||
const serviceName = extractServiceNameFromUrl(decodedUrl);
|
||||
setValue('ServiceUrl', decodedUrl);
|
||||
setValue('ServiceName', serviceName);
|
||||
|
||||
// Focus and select the service name field
|
||||
setTimeout(() => {
|
||||
serviceNameRef.current?.focus();
|
||||
serviceNameRef.current?.selectAll();
|
||||
}, 100);
|
||||
}
|
||||
}, [id, isEditMode, serviceUrl, loadExistingCredential, setValue]);
|
||||
|
||||
/**
|
||||
* Initialize the identity and password generators with settings from user's vault.
|
||||
* @returns {identityGenerator: BaseIdentityGenerator, passwordGenerator: PasswordGenerator}
|
||||
*/
|
||||
const initializeGenerators = useCallback(async () : Promise<{ identityGenerator: BaseIdentityGenerator, passwordGenerator: PasswordGenerator }> => {
|
||||
// Get default identity language from database
|
||||
const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage();
|
||||
|
||||
// Initialize identity generator based on language
|
||||
let identityGenerator: BaseIdentityGenerator;
|
||||
switch (identityLanguage) {
|
||||
case 'nl':
|
||||
identityGenerator = new IdentityGeneratorNl();
|
||||
break;
|
||||
case 'en':
|
||||
default:
|
||||
identityGenerator = new IdentityGeneratorEn();
|
||||
break;
|
||||
}
|
||||
|
||||
// Get password settings from database
|
||||
const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings();
|
||||
|
||||
// Initialize password generator with settings
|
||||
const passwordGenerator = new PasswordGenerator(passwordSettings);
|
||||
|
||||
return { identityGenerator, passwordGenerator };
|
||||
}, [dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Generate a random alias and password.
|
||||
*/
|
||||
const generateRandomAlias = useCallback(async (): Promise<void> => {
|
||||
const { identityGenerator, passwordGenerator } = await initializeGenerators();
|
||||
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
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, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated username
|
||||
setValue('Username', identity.nickName);
|
||||
}
|
||||
|
||||
if (isEditMode && watch('Password')) {
|
||||
// Keep the existing password in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated password
|
||||
setValue('Password', password);
|
||||
// Make password visible when newly generated
|
||||
setIsPasswordVisible(true);
|
||||
}
|
||||
}, [isEditMode, watch, setValue, setIsPasswordVisible, initializeGenerators, dbContext.sqliteClient]);
|
||||
|
||||
/**
|
||||
* Submit the form for either creating or updating a credential.
|
||||
* @param {Credential} data - The form data.
|
||||
*/
|
||||
const onSubmit = async (data: Credential) => {
|
||||
const onSubmit = useCallback(async (data: Credential) : Promise<void> => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
// If we're creating a new credential and mode is random, generate random values
|
||||
@@ -120,19 +189,19 @@ export default function AddEditCredentialScreen() {
|
||||
}
|
||||
|
||||
// Assemble the credential to save
|
||||
let credentialToSave: Credential = {
|
||||
const credentialToSave: Credential = {
|
||||
Id: isEditMode ? id : '',
|
||||
Username: watch('Username'),
|
||||
Password: watch('Password'),
|
||||
ServiceName: watch('ServiceName'),
|
||||
ServiceUrl: watch('ServiceUrl'),
|
||||
Username: data.Username,
|
||||
Password: data.Password,
|
||||
ServiceName: data.ServiceName,
|
||||
ServiceUrl: data.ServiceUrl,
|
||||
Alias: {
|
||||
FirstName: watch('Alias.FirstName'),
|
||||
LastName: watch('Alias.LastName'),
|
||||
NickName: watch('Alias.NickName'),
|
||||
BirthDate: watch('Alias.BirthDate'),
|
||||
Gender: watch('Alias.Gender'),
|
||||
Email: watch('Alias.Email')
|
||||
FirstName: data.Alias.FirstName,
|
||||
LastName: data.Alias.LastName,
|
||||
NickName: data.Alias.NickName,
|
||||
BirthDate: data.Alias.BirthDate,
|
||||
Gender: data.Alias.Gender,
|
||||
Email: data.Alias.Email
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +221,8 @@ export default function AddEditCredentialScreen() {
|
||||
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);
|
||||
} catch {
|
||||
// Favicon extraction failed or timed out, this is not a critical error so we can ignore it.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,8 +259,11 @@ export default function AddEditCredentialScreen() {
|
||||
});
|
||||
}, 200);
|
||||
}
|
||||
};
|
||||
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, mode, generateRandomAlias, webApi]);
|
||||
|
||||
/**
|
||||
* Extract the service name from the service URL.
|
||||
*/
|
||||
function extractServiceNameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -212,100 +284,16 @@ export default function AddEditCredentialScreen() {
|
||||
// For domains like app.example.com, return Example.com
|
||||
const mainDomain = hostParts.slice(-2).join('.');
|
||||
return mainDomain.charAt(0).toUpperCase() + mainDomain.slice(1);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// If URL parsing fails, return the original URL
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Set header buttons
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: isEditMode ? 'Edit Credential' : 'Add Credential',
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={{ padding: 10, paddingLeft: 0 }}
|
||||
>
|
||||
<ThemedText style={{ color: colors.primary }}>Cancel</ThemedText>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
style={{ padding: 10, paddingRight: 0 }}
|
||||
>
|
||||
<MaterialIcons name="save" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation, mode]);
|
||||
|
||||
/**
|
||||
* Initialize the identity and password generators with settings from user's vault.
|
||||
* @returns {identityGenerator: BaseIdentityGenerator, passwordGenerator: PasswordGenerator}
|
||||
* Generate a random username.
|
||||
*/
|
||||
const initializeGenerators = async () => {
|
||||
// Get default identity language from database
|
||||
const identityLanguage = await dbContext.sqliteClient!.getDefaultIdentityLanguage();
|
||||
|
||||
// Initialize identity generator based on language
|
||||
let identityGenerator: BaseIdentityGenerator;
|
||||
switch (identityLanguage) {
|
||||
case 'nl':
|
||||
identityGenerator = new IdentityGeneratorNl();
|
||||
break;
|
||||
case 'en':
|
||||
default:
|
||||
identityGenerator = new IdentityGeneratorEn();
|
||||
break;
|
||||
}
|
||||
|
||||
// Get password settings from database
|
||||
const passwordSettings = await dbContext.sqliteClient!.getPasswordSettings();
|
||||
|
||||
// Initialize password generator with settings
|
||||
const passwordGenerator = new PasswordGenerator(passwordSettings);
|
||||
|
||||
return { identityGenerator, passwordGenerator };
|
||||
};
|
||||
|
||||
const generateRandomAlias = async (): Promise<void> => {
|
||||
const { identityGenerator, passwordGenerator } = await initializeGenerators();
|
||||
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
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, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated username
|
||||
setValue('Username', identity.nickName);
|
||||
}
|
||||
|
||||
if (isEditMode && watch('Password')) {
|
||||
// Keep the existing password in edit mode, so don't do anything here.
|
||||
} else {
|
||||
// Use the newly generated password
|
||||
setValue('Password', password);
|
||||
// Make password visible when newly generated
|
||||
setIsPasswordVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomUsername = async () => {
|
||||
const generateRandomUsername = async () : Promise<void> => {
|
||||
try {
|
||||
const { identityGenerator } = await initializeGenerators();
|
||||
const identity = await identityGenerator.generateRandomIdentity();
|
||||
@@ -320,7 +308,10 @@ export default function AddEditCredentialScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const generateRandomPassword = async () => {
|
||||
/**
|
||||
* Generate a random password.
|
||||
*/
|
||||
const generateRandomPassword = async () : Promise<void> => {
|
||||
try {
|
||||
const { passwordGenerator } = await initializeGenerators();
|
||||
const password = passwordGenerator.generateRandomPassword();
|
||||
@@ -336,7 +327,10 @@ export default function AddEditCredentialScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
/**
|
||||
* Handle the delete button press.
|
||||
*/
|
||||
const handleDelete = async () : Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
@@ -354,12 +348,12 @@ export default function AddEditCredentialScreen() {
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
|
||||
/**
|
||||
* Delete the credential.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
await executeVaultMutation(async () => {
|
||||
console.log('Starting delete operation');
|
||||
await dbContext.sqliteClient!.deleteCredentialById(id);
|
||||
console.log('Credential deleted successfully');
|
||||
});
|
||||
|
||||
// Show success toast
|
||||
@@ -371,8 +365,10 @@ export default function AddEditCredentialScreen() {
|
||||
});
|
||||
}, 200);
|
||||
|
||||
// Hard navigate back to the credentials list as the credential that was
|
||||
// shown in the previous screen is now deleted.
|
||||
/*
|
||||
* Hard navigate back to the credentials list as the credential that was
|
||||
* shown in the previous screen is now deleted.
|
||||
*/
|
||||
router.replace('/credentials');
|
||||
}
|
||||
}
|
||||
@@ -386,22 +382,50 @@ export default function AddEditCredentialScreen() {
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginTop: 36,
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
marginTop: 36,
|
||||
},
|
||||
modeSelector: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
backgroundColor: colors.accentBackground,
|
||||
deleteButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: colors.errorText,
|
||||
fontWeight: '600',
|
||||
},
|
||||
generateButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
generateButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
headerLeftButton: {
|
||||
padding: 10,
|
||||
paddingLeft: 0,
|
||||
},
|
||||
headerRightButton: {
|
||||
padding: 10,
|
||||
paddingRight: 0,
|
||||
},
|
||||
modeButton: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
alignItems: 'center',
|
||||
borderRadius: 6,
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
},
|
||||
modeButtonActive: {
|
||||
backgroundColor: colors.primary,
|
||||
@@ -411,49 +435,58 @@ export default function AddEditCredentialScreen() {
|
||||
fontWeight: '600',
|
||||
},
|
||||
modeButtonTextActive: {
|
||||
color: '#fff',
|
||||
color: colors.primarySurfaceText,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
modeSelector: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
padding: 4,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
marginBottom: 24,
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
generateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
},
|
||||
generateButtonText: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
deleteButton: {
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
deleteButtonText: {
|
||||
color: colors.errorText,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
// Set header buttons
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
title: isEditMode ? 'Edit Credential' : 'Add Credential',
|
||||
/**
|
||||
* Header left button.
|
||||
*/
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.headerLeftButton}
|
||||
>
|
||||
<ThemedText style={{ color: colors.primary }}>Cancel</ThemedText>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
/**
|
||||
* Header right button.
|
||||
*/
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
style={styles.headerRightButton}
|
||||
>
|
||||
<MaterialIcons name="save" size={24} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerRightButton]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading) && (
|
||||
@@ -500,100 +533,103 @@ export default function AddEditCredentialScreen() {
|
||||
</View>
|
||||
{(mode === 'manual' || isEditMode) && (
|
||||
<>
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Login credentials</ThemedText>
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Login credentials</ThemedText>
|
||||
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Username"
|
||||
label="Username"
|
||||
buttons={[
|
||||
{
|
||||
icon: "refresh",
|
||||
onPress: generateRandomUsername
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Password"
|
||||
label="Password"
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
buttons={[
|
||||
{
|
||||
icon: isPasswordVisible ? "visibility-off" : "visibility",
|
||||
onPress: () => setIsPasswordVisible(!isPasswordVisible)
|
||||
},
|
||||
{
|
||||
icon: "refresh",
|
||||
onPress: generateRandomPassword
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</View>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Username"
|
||||
label="Username"
|
||||
buttons={[
|
||||
{
|
||||
icon: "refresh",
|
||||
onPress: generateRandomUsername
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Password"
|
||||
label="Password"
|
||||
secureTextEntry={!isPasswordVisible}
|
||||
buttons={[
|
||||
{
|
||||
icon: isPasswordVisible ? "visibility-off" : "visibility",
|
||||
/**
|
||||
* Toggle the visibility of the password.
|
||||
*/
|
||||
onPress: () => setIsPasswordVisible(!isPasswordVisible)
|
||||
},
|
||||
{
|
||||
icon: "refresh",
|
||||
onPress: generateRandomPassword
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Alias</ThemedText>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.FirstName"
|
||||
label="First Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.LastName"
|
||||
label="Last Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.NickName"
|
||||
label="Nick Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.Gender"
|
||||
label="Gender"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.BirthDate"
|
||||
label="Birth Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Alias</ThemedText>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.FirstName"
|
||||
label="First Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.LastName"
|
||||
label="Last Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.NickName"
|
||||
label="Nick Name"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.Gender"
|
||||
label="Gender"
|
||||
/>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Alias.BirthDate"
|
||||
label="Birth Date"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Metadata</ThemedText>
|
||||
<View style={styles.section}>
|
||||
<ThemedText style={styles.sectionTitle}>Metadata</ThemedText>
|
||||
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Notes"
|
||||
label="Notes"
|
||||
multiline={true}
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{/* TODO: Add TOTP management */}
|
||||
</View>
|
||||
<ValidatedFormField
|
||||
control={control}
|
||||
name="Notes"
|
||||
label="Notes"
|
||||
multiline={true}
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{/* TODO: Add TOTP management */}
|
||||
</View>
|
||||
|
||||
{isEditMode && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<ThemedText style={styles.deleteButtonText}>Delete Credential</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
{isEditMode && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<ThemedText style={styles.deleteButtonText}>Delete Credential</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
|
||||
@@ -1,64 +1,80 @@
|
||||
import { StyleSheet, View, TouchableOpacity, Platform } from 'react-native';
|
||||
import { StyleSheet, View, TouchableOpacity } from 'react-native';
|
||||
import { useNavigation, useRouter } from 'expo-router';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function AutofillCredentialCreatedScreen() {
|
||||
/**
|
||||
* Autofill credential created screen.
|
||||
*/
|
||||
export default function AutofillCredentialCreatedScreen() : React.ReactNode {
|
||||
const router = useRouter();
|
||||
const colors = useColors();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Set header buttons
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<TouchableOpacity
|
||||
onPress={handleStayInApp}
|
||||
style={{ padding: 10, paddingRight: 0 }}
|
||||
>
|
||||
<ThemedText style={{ color: colors.primary }}>Dismiss</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [navigation]);
|
||||
|
||||
const handleStayInApp = () => {
|
||||
/**
|
||||
* Handle the stay in app button press.
|
||||
*/
|
||||
const handleStayInApp = useCallback(() => {
|
||||
router.back();
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
boldMessage: {
|
||||
fontWeight: 'bold',
|
||||
marginTop: 20,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
headerRightButton: {
|
||||
padding: 10,
|
||||
paddingRight: 0,
|
||||
},
|
||||
iconContainer: {
|
||||
marginBottom: 30,
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 30,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
message: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginBottom: 30,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
|
||||
// Set header buttons
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
/**
|
||||
* Header right button.
|
||||
*/
|
||||
headerRight: () => (
|
||||
<TouchableOpacity
|
||||
onPress={handleStayInApp}
|
||||
style={styles.headerRightButton}
|
||||
>
|
||||
<ThemedText style={{ color: colors.primary }}>Dismiss</ThemedText>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
});
|
||||
}, [navigation, colors.primary, styles.headerRightButton, handleStayInApp]);
|
||||
|
||||
return (
|
||||
<ThemedSafeAreaView style={styles.container}>
|
||||
<ThemedView style={styles.content}>
|
||||
@@ -75,7 +91,7 @@ export default function AutofillCredentialCreatedScreen() {
|
||||
<ThemedText style={styles.message}>
|
||||
Your new credential has been added to your vault and is now available for password autofill.
|
||||
</ThemedText>
|
||||
<ThemedText style={[styles.message, { fontWeight: 'bold', marginTop: 20 }]}>
|
||||
<ThemedText style={[styles.message, styles.boldMessage]}>
|
||||
Switch back to your browser to continue.
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import EmailDetailsScreen from '../../emails/[id]';
|
||||
|
||||
import EmailDetailsScreen from '@/app/(tabs)/emails/[id]';
|
||||
|
||||
/**
|
||||
* CredentialEmailPreviewScreen Component
|
||||
@@ -13,6 +14,6 @@ import EmailDetailsScreen from '../../emails/[id]';
|
||||
* - Maintains UI consistency by reusing the same email details view
|
||||
* - Provides a better user experience by keeping context within the credentials flow
|
||||
*/
|
||||
export default function CredentialEmailPreviewScreen() {
|
||||
export default function CredentialEmailPreviewScreen() : React.ReactNode {
|
||||
return <EmailDetailsScreen />;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { StyleSheet, Text, FlatList, ActivityIndicator, TouchableOpacity, TextIn
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
@@ -14,10 +17,11 @@ import { CredentialCard } from '@/components/CredentialCard';
|
||||
import { TitleContainer } from '@/components/TitleContainer';
|
||||
import { CollapsibleHeader } from '@/components/CollapsibleHeader';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
export default function CredentialsScreen() {
|
||||
/**
|
||||
* Credentials screen.
|
||||
*/
|
||||
export default function CredentialsScreen() : React.ReactNode {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const { syncVault } = useVaultSync();
|
||||
@@ -27,11 +31,39 @@ export default function CredentialsScreen() {
|
||||
const navigation = useNavigation();
|
||||
const [isTabFocused, setIsTabFocused] = useState(false);
|
||||
const router = useRouter();
|
||||
const [credentialsList, setCredentialsList] = useState<Credential[]>([]);
|
||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
|
||||
const isAuthenticated = authContext.isLoggedIn;
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
|
||||
/**
|
||||
* Load credentials.
|
||||
*/
|
||||
const loadCredentials = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
const credentials = await dbContext.sqliteClient!.getAllCredentials();
|
||||
setCredentialsList(credentials);
|
||||
} catch (err) {
|
||||
// Error loading credentials, show error toast
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: 'Error loading credentials',
|
||||
text2: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [dbContext.sqliteClient]);
|
||||
|
||||
const headerButtons = [{
|
||||
icon: 'add' as const,
|
||||
position: 'right' as const,
|
||||
onPress: () => router.push('/(tabs)/credentials/add-edit')
|
||||
/**
|
||||
* Add credential.
|
||||
*/
|
||||
onPress: () : void => router.push('/(tabs)/credentials/add-edit')
|
||||
}];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,7 +77,6 @@ export default function CredentialsScreen() {
|
||||
|
||||
const tabPressSub = emitter.addListener('tabPress', (routeName: string) => {
|
||||
if (routeName === 'credentials' && isTabFocused) {
|
||||
console.log('Credentials tab re-pressed while focused: reset screen');
|
||||
setSearchQuery(''); // Reset search
|
||||
setRefreshing(false); // Reset refreshing
|
||||
// Scroll to top
|
||||
@@ -55,35 +86,16 @@ export default function CredentialsScreen() {
|
||||
|
||||
// Add listener for credential changes
|
||||
const credentialChangedSub = emitter.addListener('credentialChanged', async () => {
|
||||
console.log('Credential changed, refreshing list');
|
||||
await loadCredentials();
|
||||
});
|
||||
|
||||
return () => {
|
||||
return () : void => {
|
||||
tabPressSub.remove();
|
||||
credentialChangedSub.remove();
|
||||
unsubscribeFocus();
|
||||
unsubscribeBlur();
|
||||
};
|
||||
}, [isTabFocused]);
|
||||
|
||||
const [credentialsList, setCredentialsList] = useState<Credential[]>([]);
|
||||
const [isLoadingCredentials, setIsLoadingCredentials] = useState(false);
|
||||
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
|
||||
const isAuthenticated = authContext.isLoggedIn;
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const credentials = await dbContext.sqliteClient!.getAllCredentials();
|
||||
setCredentialsList(credentials);
|
||||
} catch (err) {
|
||||
console.error('Error loading credentials:', err);
|
||||
}
|
||||
};
|
||||
}, [isTabFocused, loadCredentials, navigation]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
@@ -94,6 +106,9 @@ export default function CredentialsScreen() {
|
||||
|
||||
// Sync vault and load credentials
|
||||
await syncVault({
|
||||
/**
|
||||
* On success.
|
||||
*/
|
||||
onSuccess: async (hasNewVault) => {
|
||||
// Calculate remaining time needed to reach minimum duration
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
@@ -113,6 +128,9 @@ export default function CredentialsScreen() {
|
||||
visibilityTime: 1200,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* On error.
|
||||
*/
|
||||
onError: (error) => {
|
||||
console.error('Error syncing vault:', error);
|
||||
setRefreshing(false);
|
||||
@@ -142,70 +160,74 @@ export default function CredentialsScreen() {
|
||||
setIsLoadingCredentials(true);
|
||||
loadCredentials();
|
||||
setIsLoadingCredentials(false);
|
||||
}, [isAuthenticated, isDatabaseAvailable]);
|
||||
}, [isAuthenticated, isDatabaseAvailable, loadCredentials]);
|
||||
|
||||
const filteredCredentials = credentialsList.filter(credential => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
|
||||
return (
|
||||
credential.ServiceName?.toLowerCase().includes(searchLower) ||
|
||||
credential.Username?.toLowerCase().includes(searchLower) ||
|
||||
credential.Alias?.Email?.toLowerCase().includes(searchLower) ||
|
||||
credential.ServiceName?.toLowerCase().includes(searchLower) ??
|
||||
credential.Username?.toLowerCase().includes(searchLower) ??
|
||||
credential.Alias?.Email?.toLowerCase().includes(searchLower) ??
|
||||
credential.ServiceUrl?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
clearButton: {
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -12 }],
|
||||
},
|
||||
clearButtonText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 20,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginTop: 36,
|
||||
padding: 16,
|
||||
paddingTop: 0,
|
||||
marginTop: 36,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 4,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
searchContainer: {
|
||||
position: 'relative',
|
||||
},
|
||||
searchIcon: {
|
||||
left: 12,
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: [{ translateY: -17 }],
|
||||
zIndex: 1,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
paddingLeft: 40,
|
||||
paddingRight: Platform.OS === 'android' ? 40 : 12,
|
||||
},
|
||||
stepContainer: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
credentialItem: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
marginTop: 24,
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
color: colors.text,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
fontSize: 16,
|
||||
paddingRight: Platform.OS === 'android' ? 40 : 12,
|
||||
paddingLeft: 40,
|
||||
},
|
||||
clearButton: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -12 }],
|
||||
padding: 4,
|
||||
},
|
||||
searchIcon: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
top: '50%',
|
||||
transform: [{ translateY: -17 }],
|
||||
zIndex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -233,12 +255,12 @@ export default function CredentialsScreen() {
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={{ paddingBottom: 40, paddingTop: 4 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
ListHeaderComponent={
|
||||
<ThemedView>
|
||||
<TitleContainer title="Credentials" />
|
||||
<ThemedView style={{ position: 'relative' }}>
|
||||
<ThemedView style={styles.searchContainer}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={20}
|
||||
@@ -246,7 +268,7 @@ export default function CredentialsScreen() {
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.searchInput]}
|
||||
style={styles.searchInput}
|
||||
placeholder="Search credentials..."
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={searchQuery}
|
||||
@@ -260,7 +282,7 @@ export default function CredentialsScreen() {
|
||||
style={styles.clearButton}
|
||||
onPress={() => setSearchQuery('')}
|
||||
>
|
||||
<ThemedText style={{ fontSize: 20, color: colors.textMuted }}>×</ThemedText>
|
||||
<ThemedText style={styles.clearButtonText}>×</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ThemedView>
|
||||
@@ -278,7 +300,7 @@ export default function CredentialsScreen() {
|
||||
<CredentialCard credential={item} />
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
<Text style={[styles.emptyText]}>
|
||||
<Text style={styles.emptyText}>
|
||||
{searchQuery ? 'No matching credentials found' : 'No credentials found'}
|
||||
</Text>
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, TouchableOpacity, ActivityIndicator, Alert, Share, useColorScheme, TextInput, Linking } from 'react-native';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { StyleSheet, View, TouchableOpacity, ActivityIndicator, Alert, Share, useColorScheme, TextInput } from 'react-native';
|
||||
import { useLocalSearchParams, useRouter, useNavigation, Stack } from 'expo-router';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { Email } from '@/utils/types/webapi/Email';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import WebView from 'react-native-webview';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { IconSymbol } from '@/components/ui/IconSymbol';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
|
||||
export default function EmailDetailsScreen() {
|
||||
/**
|
||||
* Email details screen.
|
||||
*/
|
||||
export default function EmailDetailsScreen() : React.ReactNode {
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
@@ -29,37 +33,10 @@ export default function EmailDetailsScreen() {
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
const [associatedCredential, setAssociatedCredential] = useState<Credential | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmail();
|
||||
}, [id]);
|
||||
|
||||
// Set navigation options
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setHtmlView(!isHtmlView)}
|
||||
style={{ padding: 10, paddingRight: 0 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isHtmlView ? 'text-outline' : 'document-outline'}
|
||||
size={22}
|
||||
color="#FFA500"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDelete}
|
||||
style={{ padding: 10, paddingRight: 0 }}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color="#FF0000" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [isHtmlView, navigation]);
|
||||
|
||||
const loadEmail = async () => {
|
||||
/**
|
||||
* Load the email.
|
||||
*/
|
||||
const loadEmail = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -93,9 +70,16 @@ export default function EmailDetailsScreen() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [dbContext.sqliteClient, id, webApi]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
useEffect(() => {
|
||||
loadEmail();
|
||||
}, [id, loadEmail]);
|
||||
|
||||
/**
|
||||
* Handle the delete button press.
|
||||
*/
|
||||
const handleDelete = useCallback(async () : Promise<void> => {
|
||||
Alert.alert(
|
||||
'Delete Email',
|
||||
'Are you sure you want to delete this email? This action is permanent and cannot be undone.',
|
||||
@@ -107,7 +91,10 @@ export default function EmailDetailsScreen() {
|
||||
{
|
||||
text: 'Delete',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
/**
|
||||
* Handle the delete button press.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
try {
|
||||
// Delete the email from the server.
|
||||
await webApi.delete(`Email/${id}`);
|
||||
@@ -124,9 +111,12 @@ export default function EmailDetailsScreen() {
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
}, [id, router, webApi]);
|
||||
|
||||
const handleDownloadAttachment = async (attachment: Email['attachments'][0]) => {
|
||||
/**
|
||||
* Handle the download attachment button press.
|
||||
*/
|
||||
const handleDownloadAttachment = async (attachment: Email['attachments'][0]) : Promise<void> => {
|
||||
try {
|
||||
const base64EncryptedAttachment = await webApi.downloadBlobAndConvertToBase64(
|
||||
`Email/${id}/attachments/${attachment.id}`
|
||||
@@ -166,108 +156,134 @@ export default function EmailDetailsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCredential = () => {
|
||||
/**
|
||||
* Handle the open credential button press.
|
||||
*/
|
||||
const handleOpenCredential = () : void => {
|
||||
if (associatedCredential) {
|
||||
router.push(`/(tabs)/credentials/${associatedCredential.Id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
attachment: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
padding: 12,
|
||||
},
|
||||
attachmentName: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
},
|
||||
attachments: {
|
||||
borderTopColor: colors.accentBorder,
|
||||
borderTopWidth: 1,
|
||||
padding: 16,
|
||||
},
|
||||
attachmentsTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
centerContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
viewLight: {
|
||||
backgroundColor: colors.background,
|
||||
divider: {
|
||||
backgroundColor: colors.accentBorder,
|
||||
height: 1,
|
||||
marginVertical: 2,
|
||||
},
|
||||
viewDark: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorBackground,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
color: colors.textMuted,
|
||||
headerRightButton: {
|
||||
padding: 10,
|
||||
paddingRight: 0,
|
||||
},
|
||||
topBox: {
|
||||
backgroundColor: colors.background,
|
||||
headerRightContainer: {
|
||||
flexDirection: 'row',
|
||||
alignSelf: 'flex-start',
|
||||
padding: 2,
|
||||
},
|
||||
subjectContainer: {
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
paddingLeft: 5,
|
||||
width: '90%',
|
||||
},
|
||||
metadataIcon: {
|
||||
width: 30,
|
||||
paddingTop: 6,
|
||||
},
|
||||
metadataContainer: {
|
||||
padding: 2,
|
||||
},
|
||||
metadataCredential: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
metadataCredentialIcon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
metadataHeading: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 0,
|
||||
marginTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
metadataIcon: {
|
||||
paddingTop: 6,
|
||||
width: 30,
|
||||
},
|
||||
metadataLabel: {
|
||||
paddingBottom: 4,
|
||||
paddingLeft: 5,
|
||||
paddingTop: 4,
|
||||
width: 60,
|
||||
},
|
||||
metadataRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
padding: 2,
|
||||
},
|
||||
metadataLabel: {
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
paddingLeft: 5,
|
||||
width: 60,
|
||||
metadataText: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
marginBottom: 0,
|
||||
marginTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
metadataValue: {
|
||||
flex: 1,
|
||||
paddingBottom: 4,
|
||||
paddingTop: 4,
|
||||
paddingLeft: 5,
|
||||
flex: 1,
|
||||
},
|
||||
metadataHeading: {
|
||||
fontWeight: 'bold',
|
||||
marginTop: 0,
|
||||
paddingTop: 0,
|
||||
marginBottom: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
},
|
||||
metadataText: {
|
||||
marginTop: 0,
|
||||
marginBottom: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: 13,
|
||||
color: colors.text,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.accentBorder,
|
||||
marginVertical: 2,
|
||||
},
|
||||
subject: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
color: colors.text,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
paddingTop: 4,
|
||||
},
|
||||
plainText: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
fontSize: 15,
|
||||
padding: 16,
|
||||
},
|
||||
subject: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subjectContainer: {
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 5,
|
||||
paddingTop: 8,
|
||||
width: '90%',
|
||||
},
|
||||
textDark: {
|
||||
color: colors.text,
|
||||
@@ -275,32 +291,52 @@ export default function EmailDetailsScreen() {
|
||||
textLight: {
|
||||
color: colors.text,
|
||||
},
|
||||
attachments: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.accentBorder,
|
||||
},
|
||||
attachmentsTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: colors.text,
|
||||
},
|
||||
attachment: {
|
||||
topBox: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: colors.background,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
padding: 2,
|
||||
},
|
||||
attachmentName: {
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: colors.textMuted,
|
||||
viewDark: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
viewLight: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
webView: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Set navigation options
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
/**
|
||||
* Header right button.
|
||||
*/
|
||||
headerRight: () => (
|
||||
<View style={styles.headerRightContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setHtmlView(!isHtmlView)}
|
||||
style={styles.headerRightButton}
|
||||
>
|
||||
<Ionicons
|
||||
name={isHtmlView ? 'text-outline' : 'document-outline'}
|
||||
size={22}
|
||||
color="#FFA500"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={handleDelete}
|
||||
style={styles.headerRightButton}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={22} color="#FF0000" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [isHtmlView, navigation, handleDelete, styles.headerRightButton, styles.headerRightContainer]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.centerContainer, isDarkMode ? styles.viewDark : styles.viewLight]}>
|
||||
@@ -351,15 +387,15 @@ export default function EmailDetailsScreen() {
|
||||
<View style={styles.metadataValue}>
|
||||
<ThemedText style={styles.metadataText}>{email.subject}</ThemedText>
|
||||
{associatedCredential && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleOpenCredential} style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<IconSymbol size={16} name="key.fill" color={colors.primary} style={{ marginRight: 4 }} />
|
||||
<ThemedText style={[styles.metadataText, { color: colors.primary }]}>
|
||||
{associatedCredential.ServiceName}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
<>
|
||||
<TouchableOpacity onPress={handleOpenCredential} style={styles.metadataCredential}>
|
||||
<IconSymbol size={16} name="key.fill" color={colors.primary} style={styles.metadataCredentialIcon} />
|
||||
<ThemedText style={[styles.metadataText, { color: colors.primary }]}>
|
||||
{associatedCredential.ServiceName}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.metadataIcon}>
|
||||
<Ionicons name="chevron-up-outline" size={22} color={isDarkMode ? '#eee' : '#000'} />
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function EmailsLayout() {
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
/**
|
||||
* Emails layout.
|
||||
*/
|
||||
export default function EmailsLayout() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, ActivityIndicator, ScrollView, RefreshControl, Animated } from 'react-native';
|
||||
import { Stack, useNavigation } from 'expo-router';
|
||||
import { useNavigation } from 'expo-router';
|
||||
|
||||
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
@@ -15,31 +16,45 @@ import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
import { EmailCard } from '@/components/EmailCard';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
|
||||
// Simple hook for minimum duration loading state
|
||||
const useMinDurationLoading = (initialState: boolean, minDuration: number): [boolean, (newState: boolean) => void] => {
|
||||
/**
|
||||
* Hook for minimum duration loading state.
|
||||
*/
|
||||
const useMinDurationLoading = (
|
||||
initialState: boolean,
|
||||
minDuration: number
|
||||
): [boolean, (newState: boolean) => void] => {
|
||||
const [state, setState] = useState(initialState);
|
||||
const [timer, setTimer] = useState<NodeJS.Timeout | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const setStateWithMinDuration = useCallback((newState: boolean) => {
|
||||
if (newState) {
|
||||
setState(true);
|
||||
} else {
|
||||
if (timer) clearTimeout(timer);
|
||||
const newTimer = setTimeout(() => setState(false), minDuration);
|
||||
setTimer(newTimer);
|
||||
}
|
||||
}, [minDuration, timer]);
|
||||
const setStateWithMinDuration = useCallback(
|
||||
(newState: boolean) => {
|
||||
if (newState) {
|
||||
setState(true);
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => setState(false), minDuration);
|
||||
}
|
||||
},
|
||||
[minDuration] // ✅ No dependency on timerRef, it won't change
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
return () : void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [timer]);
|
||||
}, []);
|
||||
|
||||
return [state, setStateWithMinDuration];
|
||||
};
|
||||
|
||||
export default function EmailsScreen() {
|
||||
/**
|
||||
* Emails screen.
|
||||
*/
|
||||
export default function EmailsScreen() : React.ReactNode {
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const colors = useColors();
|
||||
@@ -52,37 +67,9 @@ export default function EmailsScreen() {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isTabFocused, setIsTabFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFocus = navigation.addListener('focus', () => {
|
||||
setIsTabFocused(true);
|
||||
});
|
||||
|
||||
const unsubscribeBlur = navigation.addListener('blur', () => {
|
||||
setIsTabFocused(false);
|
||||
});
|
||||
|
||||
const sub = emitter.addListener('tabPress', (routeName: string) => {
|
||||
if (routeName === 'emails' && isTabFocused) {
|
||||
console.log('Emails tab re-pressed while focused: reset screen');
|
||||
// Scroll to top
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Add listener for email refresh which other components can trigger,
|
||||
// e.g. the email delete event in email details screen.
|
||||
const refreshSub = emitter.addListener('refreshEmails', () => {
|
||||
loadEmails();
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.remove();
|
||||
unsubscribeFocus();
|
||||
unsubscribeBlur();
|
||||
refreshSub.remove();
|
||||
};
|
||||
}, [isTabFocused]);
|
||||
|
||||
/**
|
||||
* Load emails.
|
||||
*/
|
||||
const loadEmails = useCallback(async () : Promise<void> => {
|
||||
try {
|
||||
setError(null);
|
||||
@@ -110,26 +97,96 @@ export default function EmailsScreen() {
|
||||
|
||||
setEmails(decryptedEmails);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} catch {
|
||||
throw new Error('Failed to load emails');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
}
|
||||
}, [dbContext?.sqliteClient, webApi]);
|
||||
}, [dbContext?.sqliteClient, webApi, setIsLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeFocus = navigation.addListener('focus', () => {
|
||||
setIsTabFocused(true);
|
||||
});
|
||||
|
||||
const unsubscribeBlur = navigation.addListener('blur', () => {
|
||||
setIsTabFocused(false);
|
||||
});
|
||||
|
||||
const sub = emitter.addListener('tabPress', (routeName: string) => {
|
||||
if (routeName === 'emails' && isTabFocused) {
|
||||
// Scroll to top
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Add listener for email refresh which other components can trigger,
|
||||
* e.g. the email delete event in email details screen.
|
||||
*/
|
||||
const refreshSub = emitter.addListener('refreshEmails', () => {
|
||||
loadEmails();
|
||||
});
|
||||
|
||||
return () : void => {
|
||||
sub.remove();
|
||||
unsubscribeFocus();
|
||||
unsubscribeBlur();
|
||||
refreshSub.remove();
|
||||
};
|
||||
}, [isTabFocused, loadEmails, navigation]);
|
||||
|
||||
/**
|
||||
* Load emails on mount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [loadEmails]);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
/**
|
||||
* Refresh the emails on pull to refresh.
|
||||
*/
|
||||
const onRefresh = useCallback(async () : Promise<void> => {
|
||||
setIsRefreshing(true);
|
||||
await loadEmails();
|
||||
setIsRefreshing(false);
|
||||
}, [loadEmails]);
|
||||
|
||||
const renderContent = () => {
|
||||
const styles = StyleSheet.create({
|
||||
centerContainer: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginTop: 22,
|
||||
padding: 16,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 4,
|
||||
},
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorBackground,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the content.
|
||||
*/
|
||||
const renderContent = () : React.ReactNode => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
@@ -150,7 +207,7 @@ export default function EmailsScreen() {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText style={styles.emptyText}>
|
||||
You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.
|
||||
You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
@@ -161,43 +218,6 @@ export default function EmailsScreen() {
|
||||
));
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
marginTop: 22,
|
||||
},
|
||||
headerImage: {
|
||||
color: colors.textMuted,
|
||||
bottom: -90,
|
||||
left: -35,
|
||||
position: 'absolute',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorBackground,
|
||||
textAlign: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refreshIndicator: {
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedSafeAreaView style={styles.container}>
|
||||
<CollapsibleHeader
|
||||
@@ -213,7 +233,7 @@ export default function EmailsScreen() {
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={{ paddingBottom: 40, paddingTop: 4 }}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
/**
|
||||
* Settings layout.
|
||||
*/
|
||||
export default function SettingsLayout() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { StyleSheet, View, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function AutoLockScreen() {
|
||||
/**
|
||||
* Auto-lock screen.
|
||||
*/
|
||||
export default function AutoLockScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { getAutoLockTimeout, setAutoLockTimeout } = useAuth();
|
||||
const [autoLockTimeout, setAutoLockTimeoutState] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAutoLockTimeout = async () => {
|
||||
/**
|
||||
* Load the auto-lock timeout.
|
||||
*/
|
||||
const loadAutoLockTimeout = async () : Promise<void> => {
|
||||
const timeout = await getAutoLockTimeout();
|
||||
setAutoLockTimeoutState(timeout);
|
||||
};
|
||||
loadAutoLockTimeout();
|
||||
}, []);
|
||||
}, [getAutoLockTimeout]);
|
||||
|
||||
const timeoutOptions = [
|
||||
{ label: 'Never', value: 0 },
|
||||
@@ -35,38 +42,38 @@ export default function AutoLockScreen() {
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
header: {
|
||||
borderBottomColor: colors.accentBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
padding: 16,
|
||||
},
|
||||
headerText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
},
|
||||
option: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
optionText: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
},
|
||||
optionText: {
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
selectedIcon: {
|
||||
marginLeft: 8,
|
||||
color: colors.primary,
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -78,7 +85,7 @@ export default function AutoLockScreen() {
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.headerText}>
|
||||
Choose how long the app can stay in the background before requiring re-authentication. You'll need to use Face ID or enter your password to unlock the vault again.
|
||||
Choose how long the app can stay in the background before requiring re-authentication. You'll need to use Face ID or enter your password to unlock the vault again.
|
||||
</ThemedText>
|
||||
</View>
|
||||
{timeoutOptions.map((option) => (
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { StyleSheet, View, ScrollView, TouchableOpacity, Image, Animated, Platform } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import { router } from 'expo-router';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { TitleContainer } from '@/components/TitleContainer';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { CollapsibleHeader } from '@/components/CollapsibleHeader';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import avatarImage from '@/assets/images/avatar.webp';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
/**
|
||||
* Settings screen.
|
||||
*/
|
||||
export default function SettingsScreen() : React.ReactNode {
|
||||
const webApi = useWebApi();
|
||||
const colors = useColors();
|
||||
const { username, getAuthMethodDisplay, shouldShowIosAutofillReminder, getBiometricDisplayName } = useAuth();
|
||||
@@ -21,163 +26,171 @@ export default function SettingsScreen() {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [autoLockDisplay, setAutoLockDisplay] = useState<string>('');
|
||||
const [authMethodDisplay, setAuthMethodDisplay] = useState<string>('');
|
||||
const [biometricDisplayName, setBiometricDisplayName] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadAutoLockDisplay = async () => {
|
||||
/**
|
||||
* Load the auto-lock display.
|
||||
*/
|
||||
const loadAutoLockDisplay = async () : Promise<void> => {
|
||||
const autoLockTimeout = await getAutoLockTimeout();
|
||||
let display = 'Never';
|
||||
|
||||
if (autoLockTimeout === 5) display = '5 seconds';
|
||||
else if (autoLockTimeout === 30) display = '30 seconds';
|
||||
else if (autoLockTimeout === 60) display = '1 minute';
|
||||
else if (autoLockTimeout === 900) display = '15 minutes';
|
||||
else if (autoLockTimeout === 1800) display = '30 minutes';
|
||||
else if (autoLockTimeout === 3600) display = '1 hour';
|
||||
else if (autoLockTimeout === 14400) display = '4 hours';
|
||||
else if (autoLockTimeout === 28800) display = '8 hours';
|
||||
if (autoLockTimeout === 5) {
|
||||
display = '5 seconds';
|
||||
} else if (autoLockTimeout === 30) {
|
||||
display = '30 seconds';
|
||||
} else if (autoLockTimeout === 60) {
|
||||
display = '1 minute';
|
||||
} else if (autoLockTimeout === 900) {
|
||||
display = '15 minutes';
|
||||
} else if (autoLockTimeout === 1800) {
|
||||
display = '30 minutes';
|
||||
} else if (autoLockTimeout === 3600) {
|
||||
display = '1 hour';
|
||||
} else if (autoLockTimeout === 14400) {
|
||||
display = '4 hours';
|
||||
} else if (autoLockTimeout === 28800) {
|
||||
display = '8 hours';
|
||||
}
|
||||
|
||||
setAutoLockDisplay(display);
|
||||
};
|
||||
|
||||
const loadAuthMethodDisplay = async () => {
|
||||
/**
|
||||
* Load the auth method display.
|
||||
*/
|
||||
const loadAuthMethodDisplay = async () : Promise<void> => {
|
||||
const authMethod = await getAuthMethodDisplay();
|
||||
setAuthMethodDisplay(authMethod);
|
||||
};
|
||||
|
||||
const loadBiometricDisplayName = async () => {
|
||||
const displayName = await getBiometricDisplayName();
|
||||
setBiometricDisplayName(displayName);
|
||||
};
|
||||
|
||||
loadAutoLockDisplay();
|
||||
loadAuthMethodDisplay();
|
||||
loadBiometricDisplayName();
|
||||
}, [getAutoLockTimeout, getAuthMethodDisplay, getBiometricDisplayName]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
/**
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
await webApi.logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const handleVaultUnlockPress = () => {
|
||||
/**
|
||||
* Handle the vault unlock press.
|
||||
*/
|
||||
const handleVaultUnlockPress = () : void => {
|
||||
router.push('/(tabs)/settings/vault-unlock');
|
||||
};
|
||||
|
||||
const handleAutoLockPress = () => {
|
||||
/**
|
||||
* Handle the auto-lock press.
|
||||
*/
|
||||
const handleAutoLockPress = () : void => {
|
||||
router.push('/(tabs)/settings/auto-lock');
|
||||
};
|
||||
|
||||
const handleIosAutofillPress = () => {
|
||||
/**
|
||||
* Handle the iOS autofill press.
|
||||
*/
|
||||
const handleIosAutofillPress = () : void => {
|
||||
router.push('/(tabs)/settings/ios-autofill');
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatar: {
|
||||
borderRadius: 20,
|
||||
height: 40,
|
||||
marginRight: 12,
|
||||
width: 40,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
marginTop: 22,
|
||||
padding: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
paddingTop: 4,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
marginTop: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 16,
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
settingItemIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingItemContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.accentBorder,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
marginLeft: 52,
|
||||
},
|
||||
settingItemText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
settingItemValue: {
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
marginRight: 8,
|
||||
settingItem: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
settingItemBadge: {
|
||||
backgroundColor: colors.primary,
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
marginRight: 8,
|
||||
width: 16,
|
||||
},
|
||||
settingItemBadgeText: {
|
||||
color: '#FFFFFF',
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
settingItemContent: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
settingItemIcon: {
|
||||
alignItems: 'center',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
width: 24,
|
||||
},
|
||||
settingItemText: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
},
|
||||
settingItemValue: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginRight: 8,
|
||||
},
|
||||
userInfoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 10,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
usernameText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
padding: 16,
|
||||
borderRadius: 10,
|
||||
marginHorizontal: 16,
|
||||
marginTop: 20,
|
||||
},
|
||||
logoutButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
versionContainer: {
|
||||
marginTop: 20,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
versionText: {
|
||||
@@ -202,14 +215,14 @@ export default function SettingsScreen() {
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={{ paddingBottom: 40, paddingTop: 4 }}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
<TitleContainer title="Settings" />
|
||||
<View style={styles.userInfoContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/avatar.webp')}
|
||||
source={avatarImage}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<ThemedText style={styles.usernameText}>Logged in as: {username}</ThemedText>
|
||||
@@ -225,7 +238,7 @@ export default function SettingsScreen() {
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="key-outline" size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<View style={styles.settingItemContent}>
|
||||
<ThemedText style={styles.settingItemText}>iOS Autofill</ThemedText>
|
||||
{shouldShowIosAutofillReminder && (
|
||||
<View style={styles.settingItemBadge}>
|
||||
@@ -244,7 +257,7 @@ export default function SettingsScreen() {
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="lock-closed" size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<View style={styles.settingItemContent}>
|
||||
<ThemedText style={styles.settingItemText}>Vault Unlock Method</ThemedText>
|
||||
<ThemedText style={styles.settingItemValue}>{authMethodDisplay}</ThemedText>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
|
||||
@@ -258,7 +271,7 @@ export default function SettingsScreen() {
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="timer-outline" size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<View style={styles.settingItemContent}>
|
||||
<ThemedText style={styles.settingItemText}>Auto-lock Timeout</ThemedText>
|
||||
<ThemedText style={styles.settingItemValue}>{autoLockDisplay}</ThemedText>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
|
||||
@@ -272,10 +285,10 @@ export default function SettingsScreen() {
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="log-out" size={20} color="#FF3B30" />
|
||||
<Ionicons name="log-out" size={20} color={colors.primary} />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<ThemedText style={[styles.settingItemText, { color: '#FF3B30' }]}>Logout</ThemedText>
|
||||
<View style={styles.settingItemContent}>
|
||||
<ThemedText style={[styles.settingItemText, { color: colors.primary }]}>Logout</ThemedText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -1,101 +1,100 @@
|
||||
import { StyleSheet, View, TouchableOpacity, ScrollView, Linking } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
export default function IosAutofillScreen() {
|
||||
/**
|
||||
* iOS autofill screen.
|
||||
*/
|
||||
export default function IosAutofillScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const { markIosAutofillConfigured, shouldShowIosAutofillReminder } = useAuth();
|
||||
|
||||
const handleConfigurePress = async () => {
|
||||
/**
|
||||
* Handle the configure press.
|
||||
*/
|
||||
const handleConfigurePress = async () : Promise<void> => {
|
||||
await markIosAutofillConfigured();
|
||||
await Linking.openURL('App-Prefs:root');
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleAlreadyConfigured = async () => {
|
||||
/**
|
||||
* Handle the already configured press.
|
||||
*/
|
||||
const handleAlreadyConfigured = async () : Promise<void> => {
|
||||
await markIosAutofillConfigured();
|
||||
router.back();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
instructionContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
instructionTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
},
|
||||
instructionStep: {
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
marginBottom: 8,
|
||||
lineHeight: 22,
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 15,
|
||||
color: colors.textMuted,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
configureButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
configureButtonText: {
|
||||
color: '#FFFFFF',
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
noticeContainer: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
padding: 16,
|
||||
margin: 16,
|
||||
borderRadius: 10,
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: 15,
|
||||
header: {
|
||||
padding: 16,
|
||||
},
|
||||
headerText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
},
|
||||
instructionContainer: {
|
||||
padding: 16,
|
||||
},
|
||||
instructionStep: {
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
marginBottom: 8,
|
||||
},
|
||||
instructionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
marginTop: 12,
|
||||
paddingVertical: 16,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
warningText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 15,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -115,41 +114,41 @@ export default function IosAutofillScreen() {
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
1. Open iOS Settings via the button below
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
2. Go to "General"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
3. Tap "AutoFill & Passwords"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
4. Enable "AliasVault"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
5. Disable other password providers (e.g. "iCloud Passwords") to avoid conflicts
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.warningText}>
|
||||
Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.configureButton}
|
||||
onPress={handleConfigurePress}
|
||||
>
|
||||
<ThemedText style={styles.configureButtonText}>
|
||||
Open Settings
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{shouldShowIosAutofillReminder && (
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={handleAlreadyConfigured}
|
||||
>
|
||||
<ThemedText style={styles.secondaryButtonText}>
|
||||
I already configured it
|
||||
style={styles.configureButton}
|
||||
onPress={handleConfigurePress}
|
||||
>
|
||||
<ThemedText style={styles.configureButtonText}>
|
||||
Open iOS Settings
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{shouldShowIosAutofillReminder && (
|
||||
<TouchableOpacity
|
||||
style={styles.secondaryButton}
|
||||
onPress={handleAlreadyConfigured}
|
||||
>
|
||||
<ThemedText style={styles.secondaryButtonText}>
|
||||
I already configured it
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
2. Go to "General"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
3. Tap "AutoFill & Passwords"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
4. Enable "AliasVault"
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.instructionStep}>
|
||||
5. Disable other password providers (e.g. "iCloud Passwords") to avoid conflicts
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.warningText}>
|
||||
Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill.
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { StyleSheet, View, ScrollView, Alert, Platform, Linking, Switch, TouchableOpacity } from 'react-native';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedSafeAreaView } from '@/components/ThemedSafeAreaView';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import * as LocalAuthentication from 'expo-local-authentication';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { AuthMethod, useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function VaultUnlockSettingsScreen() {
|
||||
/**
|
||||
* Vault unlock settings screen.
|
||||
*/
|
||||
export default function VaultUnlockSettingsScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const { setAuthMethods, getEnabledAuthMethods, getBiometricDisplayName } = useAuth();
|
||||
@@ -16,7 +20,10 @@ export default function VaultUnlockSettingsScreen() {
|
||||
const [_, setEnabledAuthMethods] = useState<AuthMethod[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
/**
|
||||
* Initialize the auth methods.
|
||||
*/
|
||||
const initializeAuth = async () : Promise<void> => {
|
||||
const compatible = await LocalAuthentication.hasHardwareAsync();
|
||||
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
setHasFaceID(compatible && enrolled);
|
||||
@@ -42,7 +49,10 @@ export default function VaultUnlockSettingsScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateAuthMethods = async () => {
|
||||
/**
|
||||
* Update the auth methods.
|
||||
*/
|
||||
const updateAuthMethods = async () : Promise<void> => {
|
||||
const currentAuthMethods = await getEnabledAuthMethods();
|
||||
const newAuthMethods = isFaceIDEnabled ? ['faceid', 'password'] : ['password'];
|
||||
|
||||
@@ -51,14 +61,13 @@ export default function VaultUnlockSettingsScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Updating auth methods to', newAuthMethods);
|
||||
setAuthMethods(newAuthMethods as AuthMethod[]);
|
||||
};
|
||||
|
||||
updateAuthMethods();
|
||||
}, [isFaceIDEnabled, setAuthMethods, getEnabledAuthMethods, initialized]);
|
||||
|
||||
const handleFaceIDToggle = useCallback(async (value: boolean) => {
|
||||
const handleFaceIDToggle = useCallback(async (value: boolean) : Promise<void> => {
|
||||
if (value && !hasFaceID) {
|
||||
Alert.alert(
|
||||
'Face ID Not Available',
|
||||
@@ -66,7 +75,10 @@ export default function VaultUnlockSettingsScreen() {
|
||||
[
|
||||
{
|
||||
text: 'Open Settings',
|
||||
onPress: () => {
|
||||
/**
|
||||
* Handle the open settings press.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setIsFaceIDEnabled(true);
|
||||
setAuthMethods(['faceid', 'password']);
|
||||
if (Platform.OS === 'ios') {
|
||||
@@ -77,7 +89,10 @@ export default function VaultUnlockSettingsScreen() {
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
onPress: () => {
|
||||
/**
|
||||
* Handle the cancel press.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setIsFaceIDEnabled(false);
|
||||
setAuthMethods(['password']);
|
||||
},
|
||||
@@ -89,56 +104,56 @@ export default function VaultUnlockSettingsScreen() {
|
||||
|
||||
setIsFaceIDEnabled(value);
|
||||
setAuthMethods(value ? ['faceid', 'password'] : ['password']);
|
||||
}, [hasFaceID]);
|
||||
}, [hasFaceID, setAuthMethods]);
|
||||
|
||||
const styles = useMemo(() => StyleSheet.create({
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
header: {
|
||||
padding: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
},
|
||||
headerText: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
optionContainer: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
option: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
},
|
||||
optionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 4,
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
helpText: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: 4,
|
||||
},
|
||||
disabledText: {
|
||||
color: colors.textMuted,
|
||||
opacity: 0.5,
|
||||
},
|
||||
}), [colors]);
|
||||
header: {
|
||||
borderBottomColor: colors.accentBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
padding: 16,
|
||||
},
|
||||
headerText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
},
|
||||
helpText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 13,
|
||||
marginTop: 4,
|
||||
},
|
||||
option: {
|
||||
borderBottomColor: colors.accentBorder,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
optionContainer: {
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
optionHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 4,
|
||||
},
|
||||
optionText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedSafeAreaView style={styles.container}>
|
||||
|
||||
@@ -4,12 +4,15 @@ import { StyleSheet } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
/**
|
||||
* Not found screen.
|
||||
*/
|
||||
export default function NotFoundScreen() : React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
||||
<Link href="/login" style={styles.link}>
|
||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
||||
</Link>
|
||||
@@ -20,8 +23,8 @@ export default function NotFoundScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
|
||||
@@ -4,9 +4,12 @@ import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import 'react-native-reanimated';
|
||||
// Required for certain modules such as secure-remote-password which relies on crypto.getRandomValues
|
||||
// and this is not available in react-native without this polyfill
|
||||
/*
|
||||
* Required for certain modules such as secure-remote-password which relies on crypto.getRandomValues
|
||||
* and this is not available in react-native without this polyfill
|
||||
*/
|
||||
import 'react-native-get-random-values';
|
||||
|
||||
import { useColors, useColorScheme } from '@/hooks/useColorScheme';
|
||||
@@ -14,12 +17,15 @@ import { DbProvider } from '@/context/DbContext';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { WebApiProvider } from '@/context/WebApiContext';
|
||||
import { AliasVaultToast } from '@/components/Toast';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import SpaceMono from '@/assets/fonts/SpaceMono-Regular.ttf';
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
function RootLayoutNav() {
|
||||
/**
|
||||
* Root layout navigation.
|
||||
*/
|
||||
function RootLayoutNav() : React.ReactNode {
|
||||
const colorScheme = useColorScheme();
|
||||
const colors = useColors();
|
||||
|
||||
@@ -68,9 +74,12 @@ function RootLayoutNav() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
/**
|
||||
* Root layout.
|
||||
*/
|
||||
export default function RootLayout() : React.ReactNode {
|
||||
const [loaded] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
SpaceMono: SpaceMono,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Redirect } from 'expo-router';
|
||||
import { install } from 'react-native-quick-crypto';
|
||||
|
||||
export default function AppIndex() {
|
||||
/**
|
||||
* App index which is the entry point of the app and redirects to the sync screen, which will
|
||||
* redirect to the login screen if the user is not logged in or to the main tabs screen if the user is logged in.
|
||||
*/
|
||||
export default function AppIndex() : React.ReactNode {
|
||||
// Install the react-native-quick-crypto library which is used by the EncryptionUtility
|
||||
install();
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator, Linking, Animated } from 'react-native';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Buffer } from 'buffer';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator, Linking, Animated } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -16,15 +18,21 @@ import { useColors } from '@/hooks/useColorScheme';
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LoginResponse } from '@/utils/types/webapi/Login';
|
||||
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
|
||||
|
||||
|
||||
export default function LoginScreen() {
|
||||
/**
|
||||
* Login screen.
|
||||
*/
|
||||
export default function LoginScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
const [apiUrl, setApiUrl] = useState<string>(AppInfo.DEFAULT_API_URL);
|
||||
|
||||
const loadApiUrl = async () => {
|
||||
/**
|
||||
* Load the API URL.
|
||||
*/
|
||||
const loadApiUrl = async () : Promise<void> => {
|
||||
const storedUrl = await AsyncStorage.getItem('apiUrl');
|
||||
if (storedUrl && storedUrl.length > 0) {
|
||||
setApiUrl(storedUrl);
|
||||
@@ -40,14 +48,17 @@ export default function LoginScreen() {
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
loadApiUrl();
|
||||
}, []);
|
||||
}, [fadeAnim]);
|
||||
|
||||
// Update URL when returning from settings
|
||||
useFocusEffect(() => {
|
||||
loadApiUrl();
|
||||
});
|
||||
|
||||
const getDisplayUrl = () => {
|
||||
/**
|
||||
* Get the display URL.
|
||||
*/
|
||||
const getDisplayUrl = () : string => {
|
||||
const cleanUrl = apiUrl.replace('https://', '').replace('/api', '');
|
||||
return cleanUrl === 'app.aliasvault.net' ? 'aliasvault.net' : cleanUrl;
|
||||
};
|
||||
@@ -61,7 +72,7 @@ export default function LoginScreen() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [twoFactorRequired, setTwoFactorRequired] = useState(false);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [loginResponse, setLoginResponse] = useState<any>(null);
|
||||
const [loginResponse, setLoginResponse] = useState<LoginResponse | null>(null);
|
||||
const [passwordHashString, setPasswordHashString] = useState<string | null>(null);
|
||||
const [passwordHashBase64, setPasswordHashBase64] = useState<string | null>(null);
|
||||
const [loginStatus, setLoginStatus] = useState<string | null>(null);
|
||||
@@ -82,9 +93,9 @@ export default function LoginScreen() {
|
||||
const processVaultResponse = async (
|
||||
token: string,
|
||||
refreshToken: string,
|
||||
vaultResponseJson: any,
|
||||
vaultResponseJson: VaultResponse,
|
||||
passwordHashBase64: string
|
||||
) => {
|
||||
) : Promise<void> => {
|
||||
await authContext.setAuthTokens(credentials.username, token, refreshToken);
|
||||
await dbContext.initializeDatabase(vaultResponseJson, passwordHashBase64);
|
||||
await authContext.login();
|
||||
@@ -99,7 +110,10 @@ export default function LoginScreen() {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
/**
|
||||
* Handle the submit.
|
||||
*/
|
||||
const handleSubmit = async () : Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -153,7 +167,7 @@ export default function LoginScreen() {
|
||||
|
||||
setLoginStatus('Syncing vault');
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
const vaultResponseJson = await webApi.authFetch<any>('Vault', { method: 'GET', headers: {
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
@@ -185,7 +199,10 @@ export default function LoginScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTwoFactorSubmit = async () => {
|
||||
/**
|
||||
* Handle the two factor submit.
|
||||
*/
|
||||
const handleTwoFactorSubmit = async () : Promise<void> => {
|
||||
setIsLoading(true);
|
||||
setLoginStatus('Verifying authentication code');
|
||||
setError(null);
|
||||
@@ -215,7 +232,7 @@ export default function LoginScreen() {
|
||||
|
||||
setLoginStatus('Syncing vault');
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
const vaultResponseJson = await webApi.authFetch<any>('Vault', { method: 'GET', headers: {
|
||||
const vaultResponseJson = await webApi.authFetch<VaultResponse>('Vault', { method: 'GET', headers: {
|
||||
'Authorization': `Bearer ${validationResponse.token.token}`
|
||||
} });
|
||||
|
||||
@@ -245,148 +262,142 @@ export default function LoginScreen() {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
headerSection: {
|
||||
backgroundColor: colors.loginHeader,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
appName: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
formContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
color: colors.text,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 45,
|
||||
paddingHorizontal: 4,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 8,
|
||||
},
|
||||
button: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
rememberMeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
checkbox: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderWidth: 2,
|
||||
borderRadius: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderColor: colors.accentBorder,
|
||||
},
|
||||
checkboxInner: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 2,
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
width: 20,
|
||||
},
|
||||
checkboxChecked: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
rememberMeText: {
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
},
|
||||
noteText: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
color: colors.textMuted,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
color: colors.textMuted,
|
||||
checkboxInner: {
|
||||
borderRadius: 2,
|
||||
height: 12,
|
||||
width: 12,
|
||||
},
|
||||
clickableDomain: {
|
||||
color: colors.primary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
},
|
||||
formContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
headerContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
headerSection: {
|
||||
backgroundColor: colors.loginHeader,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
headerSubtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
},
|
||||
headerTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
input: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 45,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 10,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
noteText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
rememberMeContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
rememberMeText: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -401,7 +412,7 @@ export default function LoginScreen() {
|
||||
</SafeAreaView>
|
||||
<ThemedView style={styles.content}>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator status={loginStatus || 'Loading...'} />
|
||||
<LoadingIndicator status={loginStatus ?? 'Loading...'} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.headerContainer}>
|
||||
@@ -425,7 +436,7 @@ export default function LoginScreen() {
|
||||
|
||||
{twoFactorRequired ? (
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={[styles.label]}>Authentication Code</Text>
|
||||
<Text style={styles.label}>Authentication Code</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<MaterialIcons
|
||||
name="security"
|
||||
@@ -472,13 +483,13 @@ export default function LoginScreen() {
|
||||
<Text style={styles.buttonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[styles.noteText]}>
|
||||
Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.
|
||||
<Text style={styles.noteText}>
|
||||
Note: if you don't have access to your authenticator device, you can reset your 2FA with a recovery code by logging in via the website.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.formContainer}>
|
||||
<Text style={[styles.label]}>Username or email</Text>
|
||||
<Text style={styles.label}>Username or email</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<MaterialIcons
|
||||
name="person"
|
||||
@@ -496,7 +507,7 @@ export default function LoginScreen() {
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.label]}>Password</Text>
|
||||
<Text style={styles.label}>Password</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<MaterialIcons
|
||||
name="lock"
|
||||
@@ -517,12 +528,12 @@ export default function LoginScreen() {
|
||||
</View>
|
||||
<View style={styles.rememberMeContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.checkbox]}
|
||||
style={styles.checkbox}
|
||||
onPress={() => setRememberMe(!rememberMe)}
|
||||
>
|
||||
<View style={[styles.checkboxInner, rememberMe && styles.checkboxChecked]} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.rememberMeText]}>Remember me</Text>
|
||||
<Text style={styles.rememberMeText}>Remember me</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
@@ -535,7 +546,7 @@ export default function LoginScreen() {
|
||||
<Text style={styles.buttonText}>Login</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.noteText]}>
|
||||
<Text style={styles.noteText}>
|
||||
No account yet?{' '}
|
||||
<Text
|
||||
style={styles.clickableDomain}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { StyleSheet, View, Text, SafeAreaView, TextInput, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
|
||||
type ApiOption = {
|
||||
@@ -16,7 +16,10 @@ const DEFAULT_OPTIONS: ApiOption[] = [
|
||||
{ label: 'Self-hosted', value: 'custom' }
|
||||
];
|
||||
|
||||
export default function SettingsScreen() {
|
||||
/**
|
||||
* Settings screen (for logged out users).
|
||||
*/
|
||||
export default function SettingsScreen() : React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [selectedOption, setSelectedOption] = useState<string>(DEFAULT_OPTIONS[0].value);
|
||||
const [customUrl, setCustomUrl] = useState<string>('');
|
||||
@@ -26,7 +29,10 @@ export default function SettingsScreen() {
|
||||
loadStoredSettings();
|
||||
}, []);
|
||||
|
||||
const loadStoredSettings = async () => {
|
||||
/**
|
||||
* Load the stored settings.
|
||||
*/
|
||||
const loadStoredSettings = async () : Promise<void> => {
|
||||
try {
|
||||
const apiUrl = await AsyncStorage.getItem('apiUrl');
|
||||
const matchingOption = DEFAULT_OPTIONS.find(opt => opt.value === apiUrl);
|
||||
@@ -44,7 +50,10 @@ export default function SettingsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionChange = async (value: string) => {
|
||||
/**
|
||||
* Handle the option change.
|
||||
*/
|
||||
const handleOptionChange = async (value: string) : Promise<void> => {
|
||||
setSelectedOption(value);
|
||||
if (value !== 'custom') {
|
||||
await AsyncStorage.setItem('apiUrl', value);
|
||||
@@ -52,7 +61,10 @@ export default function SettingsScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomUrlChange = async (value: string) => {
|
||||
/**
|
||||
* Handle the custom URL change.
|
||||
*/
|
||||
const handleCustomUrlChange = async (value: string) : Promise<void> => {
|
||||
setCustomUrl(value);
|
||||
await AsyncStorage.setItem('apiUrl', value);
|
||||
};
|
||||
@@ -66,41 +78,30 @@ export default function SettingsScreen() {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
},
|
||||
formContainer: {
|
||||
gap: 16,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
padding: 12,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
color: colors.text,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
borderColor: colors.accentBorder,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
optionButton: {
|
||||
padding: 12,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
marginBottom: 8,
|
||||
padding: 12,
|
||||
},
|
||||
optionButtonSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
@@ -113,10 +114,21 @@ export default function SettingsScreen() {
|
||||
optionButtonTextSelected: {
|
||||
color: colors.text,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
},
|
||||
versionText: {
|
||||
textAlign: 'center',
|
||||
color: colors.textMuted,
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { router } from 'expo-router';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
import NativeVaultManager from '../specs/NativeVaultManager';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useVaultSync } from '@/hooks/useVaultSync';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import NativeVaultManager from '../specs/NativeVaultManager';
|
||||
|
||||
export default function SyncScreen() {
|
||||
/**
|
||||
* Sync screen which will redirect to the login screen if the user is not logged in
|
||||
* or to the credentials screen if the user is logged in.
|
||||
*/
|
||||
export default function SyncScreen() : React.ReactNode {
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const { syncVault } = useVaultSync();
|
||||
@@ -16,26 +23,29 @@ export default function SyncScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
hasInitialized.current = true;
|
||||
|
||||
|
||||
async function initialize() {
|
||||
/**
|
||||
* Initialize the app.
|
||||
*/
|
||||
const initialize = async () : Promise<void> => {
|
||||
const { isLoggedIn, enabledAuthMethods } = await authContext.initializeAuth();
|
||||
|
||||
// If user is not logged in, navigate to login immediately
|
||||
if (!isLoggedIn) {
|
||||
console.log('User not logged in, navigating to login');
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform initial vault sync
|
||||
console.log('Initial vault sync');
|
||||
await syncVault({
|
||||
initialSync: true,
|
||||
/**
|
||||
* Handle the status update.
|
||||
*/
|
||||
onStatus: (message) => {
|
||||
setStatus(message);
|
||||
}
|
||||
@@ -47,7 +57,6 @@ export default function SyncScreen() {
|
||||
if (hasStoredVault) {
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
console.log('FaceID is not enabled, navigating to unlock screen');
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
@@ -59,45 +68,52 @@ export default function SyncScreen() {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setStatus('Decrypting vault');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
console.log('FaceID unlock successful, navigating to credentials');
|
||||
|
||||
// Navigate to credentials
|
||||
router.replace('/(tabs)/credentials');
|
||||
|
||||
return;
|
||||
}
|
||||
else {
|
||||
console.log('FaceID unlock failed, navigating to unlock screen');
|
||||
} else {
|
||||
router.replace('/unlock');
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Vault is not initialized which means the database does not exist or decryption key is missing
|
||||
// from device's keychain. Navigate to the unlock screen.
|
||||
console.log('Vault is not initialized (db file does not exist), navigating to unlock screen');
|
||||
} else {
|
||||
/*
|
||||
* Vault is not initialized which means the database does not exist or decryption key is missing
|
||||
* from device's keychain. Navigate to the unlock screen.
|
||||
*/
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('FaceID unlock failed:', error);
|
||||
// If FaceID fails (too many attempts, manual cancel, etc.)
|
||||
// navigate to unlock screen
|
||||
} catch {
|
||||
/*
|
||||
* If FaceID fails (too many attempts, manual cancel, etc.)
|
||||
* navigate to unlock screen
|
||||
*/
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get here, something went wrong with the FaceID unlock
|
||||
// Navigate to unlock screen as a fallback
|
||||
console.log('FaceID unlock failed, navigating to unlock screen');
|
||||
/*
|
||||
* If we get here, something went wrong with the FaceID unlock
|
||||
* Navigate to unlock screen as a fallback
|
||||
*/
|
||||
router.replace('/unlock');
|
||||
}
|
||||
|
||||
initialize();
|
||||
}, [syncVault]);
|
||||
}, [syncVault, authContext, dbContext]);
|
||||
|
||||
return (
|
||||
<ThemedView style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ThemedView style={styles.container}>
|
||||
{status ? <LoadingIndicator status={status} /> : null}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { StyleSheet, View, TextInput, TouchableOpacity, Alert, Image, KeyboardAvoidingView, Platform } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
@@ -11,9 +13,12 @@ import Logo from '@/assets/images/logo.svg';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { SrpUtility } from '@/utils/SrpUtility';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import avatarImage from '@/assets/images/avatar.webp';
|
||||
|
||||
export default function UnlockScreen() {
|
||||
/**
|
||||
* Unlock screen.
|
||||
*/
|
||||
export default function UnlockScreen() : React.ReactNode {
|
||||
const { isLoggedIn, username, isFaceIDEnabled } = useAuth();
|
||||
const { testDatabaseConnection } = useDb();
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -24,14 +29,20 @@ export default function UnlockScreen() {
|
||||
const srpUtil = new SrpUtility(webApi);
|
||||
|
||||
useEffect(() => {
|
||||
const checkFaceIDStatus = async () => {
|
||||
/**
|
||||
* Check the face ID status.
|
||||
*/
|
||||
const checkFaceIDStatus = async () : Promise<void> => {
|
||||
const enabled = await isFaceIDEnabled();
|
||||
setIsFaceIDAvailable(enabled);
|
||||
};
|
||||
checkFaceIDStatus();
|
||||
}, [isFaceIDEnabled]);
|
||||
|
||||
const handleUnlock = async () => {
|
||||
/**
|
||||
* Handle the unlock.
|
||||
*/
|
||||
const handleUnlock = async () : Promise<void> => {
|
||||
if (!password) {
|
||||
Alert.alert('Error', 'Please enter your password');
|
||||
return;
|
||||
@@ -48,8 +59,6 @@ export default function UnlockScreen() {
|
||||
// Initialize the database with the provided password
|
||||
const loginResponse = await srpUtil.initiateLogin(username);
|
||||
|
||||
console.log('loginResponse', loginResponse);
|
||||
|
||||
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
|
||||
password,
|
||||
loginResponse.salt,
|
||||
@@ -63,138 +72,145 @@ export default function UnlockScreen() {
|
||||
if (await testDatabaseConnection(passwordHashBase64)) {
|
||||
// Navigate to credentials
|
||||
router.replace('/(tabs)/credentials');
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
Alert.alert('Error', 'Incorrect password. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
Alert.alert('Error', 'Incorrect password. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Clear any stored tokens or session data
|
||||
// This will be handled by the auth context
|
||||
/**
|
||||
* Handle the logout.
|
||||
*/
|
||||
const handleLogout = async () : Promise<void> => {
|
||||
/*
|
||||
* Clear any stored tokens or session data
|
||||
* This will be handled by the auth context
|
||||
*/
|
||||
await webApi.logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const handleFaceIDRetry = async () => {
|
||||
/**
|
||||
* Handle the face ID retry.
|
||||
*/
|
||||
const handleFaceIDRetry = async () : Promise<void> => {
|
||||
router.replace('/');
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
avatar: {
|
||||
borderRadius: 20,
|
||||
height: 40,
|
||||
marginRight: 12,
|
||||
width: 40,
|
||||
},
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
content: {
|
||||
width: '100%',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logo: {
|
||||
width: 200,
|
||||
height: 80,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
color: colors.text,
|
||||
paddingTop: 4,
|
||||
},
|
||||
avatarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
username: {
|
||||
fontSize: 18,
|
||||
textAlign: 'center',
|
||||
opacity: 0.8,
|
||||
color: colors.text,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
color: colors.text,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 12,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 50,
|
||||
paddingHorizontal: 16,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
button: {
|
||||
width: '100%',
|
||||
height: 50,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
faceIdButton: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
faceIdButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
logoutButton: {
|
||||
input: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 50,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
width: '100%',
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 12,
|
||||
},
|
||||
keyboardAvoidingView: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
logo: {
|
||||
height: 80,
|
||||
width: 200,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
logoutButton: {
|
||||
alignItems: 'center',
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
logoutButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 16,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
opacity: 0.7,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
paddingTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
username: {
|
||||
color: colors.text,
|
||||
fontSize: 18,
|
||||
opacity: 0.8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -213,7 +229,7 @@ export default function UnlockScreen() {
|
||||
<ThemedText style={styles.title}>Unlock Vault</ThemedText>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/avatar.webp')}
|
||||
source={avatarImage}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<ThemedText style={styles.username}>{username}</ThemedText>
|
||||
|
||||
30
apps/mobile-app/assets.d.ts
vendored
Normal file
30
apps/mobile-app/assets.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
// assets.d.ts
|
||||
declare module '*.png' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpg' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.jpeg' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.gif' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.webp' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
|
||||
declare module '*.ttf' {
|
||||
const content: number;
|
||||
export default content;
|
||||
}
|
||||
@@ -4,12 +4,15 @@ import { Credential } from '@/utils/types/Credential';
|
||||
import FormInputCopyToClipboard from '@/components/FormInputCopyToClipboard';
|
||||
import { IdentityHelperUtils } from '@/utils/shared/identity-generator';
|
||||
|
||||
interface AliasDetailsProps {
|
||||
type AliasDetailsProps = {
|
||||
credential: Credential;
|
||||
}
|
||||
};
|
||||
|
||||
export const AliasDetails: React.FC<AliasDetailsProps> = ({ credential }) => {
|
||||
const hasName = Boolean(credential.Alias?.FirstName?.trim() || credential.Alias?.LastName?.trim());
|
||||
/**
|
||||
* Alias details component.
|
||||
*/
|
||||
export const AliasDetails: React.FC<AliasDetailsProps> = ({ credential }) : React.ReactNode => {
|
||||
const hasName = Boolean(credential.Alias?.FirstName?.trim() ?? credential.Alias?.LastName?.trim());
|
||||
const fullName = [credential.Alias?.FirstName, credential.Alias?.LastName].filter(Boolean).join(' ');
|
||||
|
||||
if (!hasName && !credential.Alias?.NickName && !IdentityHelperUtils.isValidBirthDate(credential.Alias?.BirthDate)) {
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Linking } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { MailboxEmail } from '@/utils/types/webapi/MailboxEmail';
|
||||
import { MailboxBulkRequest, MailboxBulkResponse } from '@/utils/types/webapi/MailboxBulk';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { ThemedText } from '../ThemedText';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
import { router } from 'expo-router';
|
||||
import { ThemedView } from '../ThemedView';
|
||||
import { AppInfo } from '@/utils/AppInfo';
|
||||
import { PulseDot } from '@/components/PulseDot';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
|
||||
type EmailPreviewProps = {
|
||||
email: string | undefined;
|
||||
};
|
||||
|
||||
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
/**
|
||||
* Email preview component.
|
||||
*/
|
||||
export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) : React.ReactNode => {
|
||||
const [emails, setEmails] = useState<MailboxEmail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastEmailId, setLastEmailId] = useState<number>(0);
|
||||
@@ -25,12 +29,10 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
const dbContext = useDb();
|
||||
const colors = useColors();
|
||||
|
||||
// Sanity check: if no email is provided, don't render anything.
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isPublicDomain = async (emailAddress: string): Promise<boolean> => {
|
||||
/**
|
||||
* Check if the email is a public domain.
|
||||
*/
|
||||
const isPublicDomain = useCallback(async (emailAddress: string): Promise<boolean> => {
|
||||
// Get public domains from stored metadata
|
||||
const metadata = await dbContext?.sqliteClient?.getVaultMetadata();
|
||||
if (!metadata) {
|
||||
@@ -38,11 +40,18 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
}
|
||||
|
||||
return metadata.publicEmailDomains.includes(emailAddress.split('@')[1]);
|
||||
};
|
||||
}, [dbContext]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmails = async () => {
|
||||
/**
|
||||
* Load the emails.
|
||||
*/
|
||||
const loadEmails = async () : Promise<void> => {
|
||||
try {
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPublic = await isPublicDomain(email);
|
||||
setIsSpamOk(isPublic);
|
||||
|
||||
@@ -70,7 +79,9 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
setEmails(latestMails);
|
||||
} else {
|
||||
// For private domains, use existing encrypted email logic
|
||||
if (!dbContext?.sqliteClient) return;
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all encryption keys
|
||||
const encryptionKeys = await dbContext.sqliteClient.getAllEncryptionKeys();
|
||||
@@ -113,48 +124,53 @@ export const EmailPreview: React.FC<EmailPreviewProps> = ({ email }) => {
|
||||
loadEmails();
|
||||
// Set up auto-refresh interval
|
||||
const interval = setInterval(loadEmails, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [email, loading, webApi, dbContext]);
|
||||
return () : void => clearInterval(interval);
|
||||
}, [email, loading, webApi, dbContext, isPublicDomain]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 16,
|
||||
paddingBottom: 0,
|
||||
date: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
},
|
||||
emailItem: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
},
|
||||
placeholderText: {
|
||||
color: colors.textMuted,
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
},
|
||||
emailItem: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.accentBorder,
|
||||
backgroundColor: colors.accentBackground,
|
||||
section: {
|
||||
padding: 16,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
subject: {
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
date: {
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
color: colors.textMuted,
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
});
|
||||
|
||||
// Sanity check: if no email is provided, don't render anything.
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedView style={styles.section}>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { View } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import FormInputCopyToClipboard from '@/components/FormInputCopyToClipboard';
|
||||
|
||||
interface LoginCredentialsProps {
|
||||
type LoginCredentialsProps = {
|
||||
credential: Credential;
|
||||
}
|
||||
};
|
||||
|
||||
export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }) => {
|
||||
/**
|
||||
* Login credentials component.
|
||||
*/
|
||||
export const LoginCredentials: React.FC<LoginCredentialsProps> = ({ credential }) : React.ReactNode => {
|
||||
const email = credential.Alias?.Email?.trim();
|
||||
const username = credential.Username?.trim();
|
||||
const password = credential.Password?.trim();
|
||||
|
||||
const hasLoginCredentials = email || username || password;
|
||||
const hasLoginCredentials = email ?? username ?? password;
|
||||
|
||||
if (!hasLoginCredentials) {
|
||||
return null;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { View, Text, useColorScheme, StyleSheet, Linking, Pressable } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
|
||||
interface NotesSectionProps {
|
||||
type NotesSectionProps = {
|
||||
credential: Credential;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Split text into parts, separating URLs from regular text to make them clickable.
|
||||
*/
|
||||
const splitTextAndUrls = (text: string): Array<{ type: 'text' | 'url', content: string, url?: string }> => {
|
||||
const splitTextAndUrls = (text: string): { type: 'text' | 'url', content: string, url?: string }[] => {
|
||||
const urlPattern = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g;
|
||||
|
||||
const parts: Array<{ type: 'text' | 'url', content: string, url?: string }> = [];
|
||||
const parts: { type: 'text' | 'url', content: string, url?: string }[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
@@ -55,7 +56,10 @@ const splitTextAndUrls = (text: string): Array<{ type: 'text' | 'url', content:
|
||||
return parts;
|
||||
};
|
||||
|
||||
export const NotesSection: React.FC<NotesSectionProps> = ({ credential }) => {
|
||||
/**
|
||||
* Notes section component.
|
||||
*/
|
||||
export const NotesSection: React.FC<NotesSectionProps> = ({ credential }) : React.ReactNode => {
|
||||
const colorScheme = useColorScheme();
|
||||
const isDarkMode = colorScheme === 'dark';
|
||||
|
||||
@@ -65,7 +69,10 @@ export const NotesSection: React.FC<NotesSectionProps> = ({ credential }) => {
|
||||
|
||||
const parts = splitTextAndUrls(credential.Notes);
|
||||
|
||||
const handleLinkPress = (url: string) => {
|
||||
/**
|
||||
* Handle the link press.
|
||||
*/
|
||||
const handleLinkPress = (url: string) : void => {
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
@@ -103,21 +110,21 @@ export const NotesSection: React.FC<NotesSectionProps> = ({ credential }) => {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
gap: 8,
|
||||
},
|
||||
notesContainer: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
fontSize: 14,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
notes: {
|
||||
fontSize: 14,
|
||||
},
|
||||
notesContainer: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
},
|
||||
section: {
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
});
|
||||
@@ -1,25 +1,32 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, StyleSheet, Pressable, useColorScheme, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, useColorScheme, TouchableOpacity } from 'react-native';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { Credential } from '@/utils/types/Credential';
|
||||
import { TotpCode } from '@/utils/types/TotpCode';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
type TotpSectionProps = {
|
||||
credential: Credential;
|
||||
};
|
||||
|
||||
export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
/**
|
||||
* Totp section component.
|
||||
*/
|
||||
export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) : React.ReactNode => {
|
||||
const [totpCodes, setTotpCodes] = useState<TotpCode[]>([]);
|
||||
const [currentCodes, setCurrentCodes] = useState<Record<string, string>>({});
|
||||
const colorScheme = useColorScheme();
|
||||
const isDarkMode = colorScheme === 'dark';
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Get the remaining seconds.
|
||||
*/
|
||||
const getRemainingSeconds = (step = 30): number => {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
secret: 'dummy',
|
||||
@@ -30,11 +37,17 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
return totp.period - (Math.floor(Date.now() / 1000) % totp.period);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the remaining percentage.
|
||||
*/
|
||||
const getRemainingPercentage = (): number => {
|
||||
const remaining = getRemainingSeconds();
|
||||
return Math.floor(((30.0 - remaining) / 30.0) * 100);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the totp code.
|
||||
*/
|
||||
const generateTotpCode = (secretKey: string): string => {
|
||||
try {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
@@ -50,6 +63,9 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the totp code to the clipboard.
|
||||
*/
|
||||
const copyToClipboard = async (code: string): Promise<void> => {
|
||||
try {
|
||||
await Clipboard.setStringAsync(code);
|
||||
@@ -65,7 +81,10 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadTotpCodes = async () => {
|
||||
/**
|
||||
* Load the totp codes.
|
||||
*/
|
||||
const loadTotpCodes = async () : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
@@ -82,6 +101,9 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
}, [credential.Id, dbContext?.sqliteClient]);
|
||||
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Update the totp codes.
|
||||
*/
|
||||
const updateTotpCodes = (prevCodes: Record<string, string>): Record<string, string> => {
|
||||
const newCodes: Record<string, string> = {};
|
||||
totpCodes.forEach(code => {
|
||||
@@ -105,7 +127,7 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
setCurrentCodes(updateTotpCodes);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
return () : void => clearInterval(intervalId);
|
||||
}, [totpCodes]);
|
||||
|
||||
if (totpCodes.length === 0) {
|
||||
@@ -159,46 +181,46 @@ export const TotpSection: React.FC<TotpSectionProps> = ({ credential }) => {
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
content: {
|
||||
marginTop: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
},
|
||||
codeContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
code: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 2,
|
||||
},
|
||||
timerContainer: {
|
||||
alignItems: 'flex-end',
|
||||
codeContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
container: {
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
},
|
||||
content: {
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
progressBar: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: 2,
|
||||
height: 4,
|
||||
overflow: 'hidden',
|
||||
width: 40,
|
||||
},
|
||||
progressFill: {
|
||||
backgroundColor: '#007AFF',
|
||||
height: '100%',
|
||||
},
|
||||
timer: {
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
progressBar: {
|
||||
width: 40,
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#007AFF',
|
||||
timerContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
});
|
||||
@@ -20,11 +20,13 @@ export const Colors = {
|
||||
headerBackground: '#fff',
|
||||
tabBarBackground: '#fff',
|
||||
primary: '#f49541',
|
||||
primarySurfaceText: '#ffffff',
|
||||
secondary: '#6b7280',
|
||||
tertiary: '#eabf69',
|
||||
loginHeader: '#f6dfc4',
|
||||
},
|
||||
dark: {
|
||||
white: '#ffffff',
|
||||
text: '#ECEDEE',
|
||||
textMuted: '#9BA1A6',
|
||||
background: '#111827',
|
||||
@@ -40,6 +42,7 @@ export const Colors = {
|
||||
headerBackground: '#1f2937',
|
||||
tabBarBackground: '#1f2937',
|
||||
primary: '#f49541',
|
||||
primarySurfaceText: '#ffffff',
|
||||
secondary: '#6b7280',
|
||||
tertiary: '#eabf69',
|
||||
loginHeader: '#5c4331',
|
||||
|
||||
Reference in New Issue
Block a user