Linting refactor (#771)

This commit is contained in:
Leendert de Borst
2025-05-04 10:48:46 +02:00
parent 6e7e985c26
commit 5e2bdc6861
30 changed files with 1738 additions and 1351 deletions

View File

@@ -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',
},
};

View File

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

View File

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

View File

@@ -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 (

View File

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

View File

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

View File

@@ -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 />;
}

View File

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

View File

@@ -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'} />

View File

@@ -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 (

View File

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

View File

@@ -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 (

View File

@@ -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&apos;ll need to use Face ID or enter your password to unlock the vault again.
</ThemedText>
</View>
{timeoutOptions.map((option) => (

View File

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

View File

@@ -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 &quot;General&quot;
</ThemedText>
<ThemedText style={styles.instructionStep}>
3. Tap &quot;AutoFill & Passwords&quot;
</ThemedText>
<ThemedText style={styles.instructionStep}>
4. Enable &quot;AliasVault&quot;
</ThemedText>
<ThemedText style={styles.instructionStep}>
5. Disable other password providers (e.g. &quot;iCloud Passwords&quot;) to avoid conflicts
</ThemedText>
<ThemedText style={styles.warningText}>
Note: You&apos;ll need to authenticate with Face ID/Touch ID or your device passcode when using autofill.
</ThemedText>
</View>
</ScrollView>
</ThemedView>

View File

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

View File

@@ -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&apos;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,
},

View File

@@ -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(() => {

View File

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

View File

@@ -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&apos;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}

View File

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

View File

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

View File

@@ -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
View 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;
}

View File

@@ -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)) {

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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