diff --git a/apps/mobile-app/.eslintrc.js b/apps/mobile-app/.eslintrc.js index f2d7c975f..ddf37ed27 100644 --- a/apps/mobile-app/.eslintrc.js +++ b/apps/mobile-app/.eslintrc.js @@ -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 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', }, }; diff --git a/apps/mobile-app/app/(tabs)/_layout.tsx b/apps/mobile-app/app/(tabs)/_layout.tsx index 17de12bde..782c44015 100644 --- a/apps/mobile-app/app/(tabs)/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/_layout.tsx @@ -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 ( { 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 }) => , }} /> @@ -67,6 +101,9 @@ export default function TabLayout() { name="emails" options={{ title: 'Emails', + /** + * Icon for the emails tab. + */ tabBarIcon: ({ color }) => , }} /> @@ -74,34 +111,15 @@ export default function TabLayout() { name="settings" options={{ title: 'Settings', + /** + * Icon for the settings tab. + */ tabBarIcon: ({ color }) => ( - + {Platform.OS === 'ios' && authContext.shouldShowIosAutofillReminder && ( - - - 1 - + + 1 )} diff --git a/apps/mobile-app/app/(tabs)/credentials/[id].tsx b/apps/mobile-app/app/(tabs)/credentials/[id].tsx index cc323da1f..b5ee78ab2 100644 --- a/apps/mobile-app/app/(tabs)/credentials/[id].tsx +++ b/apps/mobile-app/app/(tabs)/credentials/[id].tsx @@ -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(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: () => ( - + + name="edit" + size={24} + color={colors.primary} + /> ), }); - }, [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 => { + 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 ( - + ); @@ -94,8 +108,8 @@ export default function CredentialDetailsScreen() { return ( @@ -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, diff --git a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx b/apps/mobile-app/app/(tabs)/credentials/_layout.tsx index 7ad4eae94..2900c79db 100644 --- a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/_layout.tsx @@ -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 ( diff --git a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx index 4e35667ec..bc9342b9e 100644 --- a/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/add-edit.tsx @@ -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(null); - const { control, handleSubmit, formState: { errors }, setValue, watch } = useForm({ + const { control, handleSubmit, setValue, watch } = useForm({ resolver: yupResolver(credentialSchema) as Resolver, 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 => { 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 => { + 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 => { 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: () => ( - router.back()} - style={{ padding: 10, paddingLeft: 0 }} - > - Cancel - - ), - headerRight: () => ( - - - - - - ), - }); - }, [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 => { - 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 => { 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 => { 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 => { if (!id) { return; } @@ -354,12 +348,12 @@ export default function AddEditCredentialScreen() { { text: "Delete", style: "destructive", - onPress: async () => { - + /** + * Delete the credential. + */ + onPress: async () : Promise => { 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: () => ( + router.back()} + style={styles.headerLeftButton} + > + Cancel + + ), + /** + * Header right button. + */ + headerRight: () => ( + + + + ), + }); + }, [navigation, mode, handleSubmit, onSubmit, colors.primary, isEditMode, router, styles.headerLeftButton, styles.headerRightButton]); + return ( <> {(isLoading) && ( @@ -500,100 +533,103 @@ export default function AddEditCredentialScreen() { {(mode === 'manual' || isEditMode) && ( <> - - Login credentials + + Login credentials - - setIsPasswordVisible(!isPasswordVisible) - }, - { - icon: "refresh", - onPress: generateRandomPassword - } - ]} - /> - - - Generate Random Alias - - - + + setIsPasswordVisible(!isPasswordVisible) + }, + { + icon: "refresh", + onPress: generateRandomPassword + } + ]} + /> + + + Generate Random Alias + + + - - Alias - - - - - - + + Alias + + + + + + - - Metadata + + Metadata - - {/* TODO: Add TOTP management */} - + + {/* TODO: Add TOTP management */} + - {isEditMode && ( - - Delete Credential - - )} - + {isEditMode && ( + + Delete Credential + + )} + )} diff --git a/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx b/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx index c4a92c246..c629e80cd 100644 --- a/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/autofill-credential-created.tsx @@ -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: () => ( - - - Dismiss - - - ), - }); - }, [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: () => ( + + Dismiss + + ), + }); + }, [navigation, colors.primary, styles.headerRightButton, handleStayInApp]); + return ( @@ -75,7 +91,7 @@ export default function AutofillCredentialCreatedScreen() { Your new credential has been added to your vault and is now available for password autofill. - + Switch back to your browser to continue. diff --git a/apps/mobile-app/app/(tabs)/credentials/email/[id].tsx b/apps/mobile-app/app/(tabs)/credentials/email/[id].tsx index d52947b4c..ed69ee392 100644 --- a/apps/mobile-app/app/(tabs)/credentials/email/[id].tsx +++ b/apps/mobile-app/app/(tabs)/credentials/email/[id].tsx @@ -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 ; } diff --git a/apps/mobile-app/app/(tabs)/credentials/index.tsx b/apps/mobile-app/app/(tabs)/credentials/index.tsx index df6d97eca..e0bd0d573 100644 --- a/apps/mobile-app/app/(tabs)/credentials/index.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/index.tsx @@ -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([]); + 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 => { + 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([]); - 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={ - + setSearchQuery('')} > - × + × )} @@ -278,7 +300,7 @@ export default function CredentialsScreen() { )} ListEmptyComponent={ - + {searchQuery ? 'No matching credentials found' : 'No credentials found'} } diff --git a/apps/mobile-app/app/(tabs)/emails/[id].tsx b/apps/mobile-app/app/(tabs)/emails/[id].tsx index 02d3902d3..5f4aeca2d 100644 --- a/apps/mobile-app/app/(tabs)/emails/[id].tsx +++ b/apps/mobile-app/app/(tabs)/emails/[id].tsx @@ -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(null); - useEffect(() => { - loadEmail(); - }, [id]); - - // Set navigation options - useEffect(() => { - navigation.setOptions({ - headerRight: () => ( - - setHtmlView(!isHtmlView)} - style={{ padding: 10, paddingRight: 0 }} - > - - - - - - - ), - }); - }, [isHtmlView, navigation]); - - const loadEmail = async () => { + /** + * Load the email. + */ + const loadEmail = useCallback(async () : Promise => { 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 => { 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 => { 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 => { 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: () => ( + + setHtmlView(!isHtmlView)} + style={styles.headerRightButton} + > + + + + + + + ), + }); + }, [isHtmlView, navigation, handleDelete, styles.headerRightButton, styles.headerRightContainer]); + if (isLoading) { return ( @@ -351,15 +387,15 @@ export default function EmailDetailsScreen() { {email.subject} {associatedCredential && ( - <> - - - - {associatedCredential.ServiceName} - - - - )} + <> + + + + {associatedCredential.ServiceName} + + + + )} diff --git a/apps/mobile-app/app/(tabs)/emails/_layout.tsx b/apps/mobile-app/app/(tabs)/emails/_layout.tsx index 5aa341fcd..2e31bec43 100644 --- a/apps/mobile-app/app/(tabs)/emails/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/emails/_layout.tsx @@ -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 ( diff --git a/apps/mobile-app/app/(tabs)/emails/index.tsx b/apps/mobile-app/app/(tabs)/emails/index.tsx index 3e4a14ad5..8436659de 100644 --- a/apps/mobile-app/app/(tabs)/emails/index.tsx +++ b/apps/mobile-app/app/(tabs)/emails/index.tsx @@ -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(null); + const timerRef = useRef(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 => { 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 => { 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 ( @@ -150,7 +207,7 @@ export default function EmailsScreen() { return ( - 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. ); @@ -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 ( (0); useEffect(() => { - const loadAutoLockTimeout = async () => { + /** + * Load the auto-lock timeout. + */ + const loadAutoLockTimeout = async () : Promise => { 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() { > - Choose how long the app can stay in the background before requiring re-authentication. You'll need to use Face ID or enter your password to unlock the vault again. + Choose how long the app can stay in the background before requiring re-authentication. You'll need to use Face ID or enter your password to unlock the vault again. {timeoutOptions.map((option) => ( diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 9f76d7559..f210d7953 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -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(null); const [autoLockDisplay, setAutoLockDisplay] = useState(''); const [authMethodDisplay, setAuthMethodDisplay] = useState(''); - const [biometricDisplayName, setBiometricDisplayName] = useState(''); useEffect(() => { - const loadAutoLockDisplay = async () => { + /** + * Load the auto-lock display. + */ + const loadAutoLockDisplay = async () : Promise => { 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 => { 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 => { 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} > Logged in as: {username} @@ -225,7 +238,7 @@ export default function SettingsScreen() { - + iOS Autofill {shouldShowIosAutofillReminder && ( @@ -244,7 +257,7 @@ export default function SettingsScreen() { - + Vault Unlock Method {authMethodDisplay} @@ -258,7 +271,7 @@ export default function SettingsScreen() { - + Auto-lock Timeout {autoLockDisplay} @@ -272,10 +285,10 @@ export default function SettingsScreen() { onPress={handleLogout} > - + - - Logout + + Logout diff --git a/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx b/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx index 3bbfb3684..44926cbe3 100644 --- a/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx +++ b/apps/mobile-app/app/(tabs)/settings/ios-autofill.tsx @@ -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 => { await markIosAutofillConfigured(); await Linking.openURL('App-Prefs:root'); router.back(); }; - const handleAlreadyConfigured = async () => { + /** + * Handle the already configured press. + */ + const handleAlreadyConfigured = async () : Promise => { 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() { 1. Open iOS Settings via the button below - - 2. Go to "General" - - - 3. Tap "AutoFill & Passwords" - - - 4. Enable "AliasVault" - - - 5. Disable other password providers (e.g. "iCloud Passwords") to avoid conflicts - - - Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill. - - - - - - Open Settings - - - {shouldShowIosAutofillReminder && ( + - - I already configured it + style={styles.configureButton} + onPress={handleConfigurePress} + > + + Open iOS Settings - )} + {shouldShowIosAutofillReminder && ( + + + I already configured it + + + )} + + + 2. Go to "General" + + + 3. Tap "AutoFill & Passwords" + + + 4. Enable "AliasVault" + + + 5. Disable other password providers (e.g. "iCloud Passwords") to avoid conflicts + + + Note: You'll need to authenticate with Face ID/Touch ID or your device passcode when using autofill. + diff --git a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx index e1dca2de0..b01339e66 100644 --- a/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx +++ b/apps/mobile-app/app/(tabs)/settings/vault-unlock.tsx @@ -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([]); useEffect(() => { - const initializeAuth = async () => { + /** + * Initialize the auth methods. + */ + const initializeAuth = async () : Promise => { 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 => { 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 => { 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 ( diff --git a/apps/mobile-app/app/+not-found.tsx b/apps/mobile-app/app/+not-found.tsx index 0aa54cef5..a6695d0b5 100644 --- a/apps/mobile-app/app/+not-found.tsx +++ b/apps/mobile-app/app/+not-found.tsx @@ -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 ( <> - This screen doesn't exist. + This screen doesn't exist. Go to home screen! @@ -20,8 +23,8 @@ export default function NotFoundScreen() { const styles = StyleSheet.create({ container: { - flex: 1, alignItems: 'center', + flex: 1, justifyContent: 'center', padding: 20, }, diff --git a/apps/mobile-app/app/_layout.tsx b/apps/mobile-app/app/_layout.tsx index 68b35d33a..5de3bb25e 100644 --- a/apps/mobile-app/app/_layout.tsx +++ b/apps/mobile-app/app/_layout.tsx @@ -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(() => { diff --git a/apps/mobile-app/app/index.tsx b/apps/mobile-app/app/index.tsx index 744720663..bb46d1202 100644 --- a/apps/mobile-app/app/index.tsx +++ b/apps/mobile-app/app/index.tsx @@ -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(); diff --git a/apps/mobile-app/app/login.tsx b/apps/mobile-app/app/login.tsx index 0776ab3a1..b57ea811f 100644 --- a/apps/mobile-app/app/login.tsx +++ b/apps/mobile-app/app/login.tsx @@ -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(AppInfo.DEFAULT_API_URL); - const loadApiUrl = async () => { + /** + * Load the API URL. + */ + const loadApiUrl = async () : Promise => { 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(null); const [twoFactorRequired, setTwoFactorRequired] = useState(false); const [twoFactorCode, setTwoFactorCode] = useState(''); - const [loginResponse, setLoginResponse] = useState(null); + const [loginResponse, setLoginResponse] = useState(null); const [passwordHashString, setPasswordHashString] = useState(null); const [passwordHashBase64, setPasswordHashBase64] = useState(null); const [loginStatus, setLoginStatus] = useState(null); @@ -82,9 +93,9 @@ export default function LoginScreen() { const processVaultResponse = async ( token: string, refreshToken: string, - vaultResponseJson: any, + vaultResponseJson: VaultResponse, passwordHashBase64: string - ) => { + ) : Promise => { 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 => { 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('Vault', { method: 'GET', headers: { + const vaultResponseJson = await webApi.authFetch('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 => { 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('Vault', { method: 'GET', headers: { + const vaultResponseJson = await webApi.authFetch('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() { {isLoading ? ( - + ) : ( <> @@ -425,7 +436,7 @@ export default function LoginScreen() { {twoFactorRequired ? ( - Authentication Code + Authentication Code Cancel - - 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. + + 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. ) : ( - Username or email + Username or email - Password + Password setRememberMe(!rememberMe)} > - Remember me + Remember me Login )} - + No account yet?{' '} (DEFAULT_OPTIONS[0].value); const [customUrl, setCustomUrl] = useState(''); @@ -26,7 +29,10 @@ export default function SettingsScreen() { loadStoredSettings(); }, []); - const loadStoredSettings = async () => { + /** + * Load the stored settings. + */ + const loadStoredSettings = async () : Promise => { 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 => { 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 => { 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', }, }); diff --git a/apps/mobile-app/app/sync.tsx b/apps/mobile-app/app/sync.tsx index 162f4c0ba..d199c0f0b 100644 --- a/apps/mobile-app/app/sync.tsx +++ b/apps/mobile-app/app/sync.tsx @@ -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 => { 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 ( - + {status ? : null} ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + justifyContent: 'center', + }, +}); \ No newline at end of file diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 4eaf4c8bf..0aaf7a8c8 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -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 => { const enabled = await isFaceIDEnabled(); setIsFaceIDAvailable(enabled); }; checkFaceIDStatus(); }, [isFaceIDEnabled]); - const handleUnlock = async () => { + /** + * Handle the unlock. + */ + const handleUnlock = async () : Promise => { 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 => { + /* + * 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 => { 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() { Unlock Vault {username} diff --git a/apps/mobile-app/assets.d.ts b/apps/mobile-app/assets.d.ts new file mode 100644 index 000000000..c9d51b1fe --- /dev/null +++ b/apps/mobile-app/assets.d.ts @@ -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; + } \ No newline at end of file diff --git a/apps/mobile-app/components/credentialDetails/AliasDetails.tsx b/apps/mobile-app/components/credentialDetails/AliasDetails.tsx index c04c18289..9908a9123 100644 --- a/apps/mobile-app/components/credentialDetails/AliasDetails.tsx +++ b/apps/mobile-app/components/credentialDetails/AliasDetails.tsx @@ -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 = ({ credential }) => { - const hasName = Boolean(credential.Alias?.FirstName?.trim() || credential.Alias?.LastName?.trim()); +/** + * Alias details component. + */ +export const AliasDetails: React.FC = ({ 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)) { diff --git a/apps/mobile-app/components/credentialDetails/EmailPreview.tsx b/apps/mobile-app/components/credentialDetails/EmailPreview.tsx index e989db85b..50e528455 100644 --- a/apps/mobile-app/components/credentialDetails/EmailPreview.tsx +++ b/apps/mobile-app/components/credentialDetails/EmailPreview.tsx @@ -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 = ({ email }) => { +/** + * Email preview component. + */ +export const EmailPreview: React.FC = ({ email }) : React.ReactNode => { const [emails, setEmails] = useState([]); const [loading, setLoading] = useState(true); const [lastEmailId, setLastEmailId] = useState(0); @@ -25,12 +29,10 @@ export const EmailPreview: React.FC = ({ 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 => { + /** + * Check if the email is a public domain. + */ + const isPublicDomain = useCallback(async (emailAddress: string): Promise => { // Get public domains from stored metadata const metadata = await dbContext?.sqliteClient?.getVaultMetadata(); if (!metadata) { @@ -38,11 +40,18 @@ export const EmailPreview: React.FC = ({ email }) => { } return metadata.publicEmailDomains.includes(emailAddress.split('@')[1]); - }; + }, [dbContext]); useEffect(() => { - const loadEmails = async () => { + /** + * Load the emails. + */ + const loadEmails = async () : Promise => { try { + if (!email) { + return; + } + const isPublic = await isPublicDomain(email); setIsSpamOk(isPublic); @@ -70,7 +79,9 @@ export const EmailPreview: React.FC = ({ 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 = ({ 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 ( diff --git a/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx b/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx index f9d1038ef..a8eb00170 100644 --- a/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx +++ b/apps/mobile-app/components/credentialDetails/LoginCredentials.tsx @@ -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 = ({ credential }) => { +/** + * Login credentials component. + */ +export const LoginCredentials: React.FC = ({ 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; diff --git a/apps/mobile-app/components/credentialDetails/NotesSection.tsx b/apps/mobile-app/components/credentialDetails/NotesSection.tsx index 2a8d2c325..c311197ee 100644 --- a/apps/mobile-app/components/credentialDetails/NotesSection.tsx +++ b/apps/mobile-app/components/credentialDetails/NotesSection.tsx @@ -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 = ({ credential }) => { +/** + * Notes section component. + */ +export const NotesSection: React.FC = ({ credential }) : React.ReactNode => { const colorScheme = useColorScheme(); const isDarkMode = colorScheme === 'dark'; @@ -65,7 +69,10 @@ export const NotesSection: React.FC = ({ 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 = ({ 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, + }, }); \ No newline at end of file diff --git a/apps/mobile-app/components/credentialDetails/TotpSection.tsx b/apps/mobile-app/components/credentialDetails/TotpSection.tsx index 8333f6257..2a86819bb 100644 --- a/apps/mobile-app/components/credentialDetails/TotpSection.tsx +++ b/apps/mobile-app/components/credentialDetails/TotpSection.tsx @@ -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 = ({ credential }) => { +/** + * Totp section component. + */ +export const TotpSection: React.FC = ({ credential }) : React.ReactNode => { const [totpCodes, setTotpCodes] = useState([]); const [currentCodes, setCurrentCodes] = useState>({}); 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 = ({ 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 = ({ credential }) => { } }; + /** + * Copy the totp code to the clipboard. + */ const copyToClipboard = async (code: string): Promise => { try { await Clipboard.setStringAsync(code); @@ -65,7 +81,10 @@ export const TotpSection: React.FC = ({ credential }) => { }; useEffect(() => { - const loadTotpCodes = async () => { + /** + * Load the totp codes. + */ + const loadTotpCodes = async () : Promise => { if (!dbContext?.sqliteClient) { return; } @@ -82,6 +101,9 @@ export const TotpSection: React.FC = ({ credential }) => { }, [credential.Id, dbContext?.sqliteClient]); useEffect(() => { + /** + * Update the totp codes. + */ const updateTotpCodes = (prevCodes: Record): Record => { const newCodes: Record = {}; totpCodes.forEach(code => { @@ -105,7 +127,7 @@ export const TotpSection: React.FC = ({ 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 = ({ 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', }, }); \ No newline at end of file diff --git a/apps/mobile-app/constants/Colors.ts b/apps/mobile-app/constants/Colors.ts index 3152b0c1e..2d9ef176d 100644 --- a/apps/mobile-app/constants/Colors.ts +++ b/apps/mobile-app/constants/Colors.ts @@ -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',