mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-16 12:29:01 -04:00
Add settings page stack and add unlock method options (#771)
This commit is contained in:
27
mobile-app/app/(tabs)/(settings)/_layout.tsx
Normal file
27
mobile-app/app/(tabs)/(settings)/_layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
export default function SettingsLayout() {
|
||||
const colors = useColors();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="vault-unlock"
|
||||
options={{
|
||||
title: 'Vault Unlock Method',
|
||||
headerBackTitle: 'Settings',
|
||||
headerStyle: {
|
||||
backgroundColor: colors.headerBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
194
mobile-app/app/(tabs)/(settings)/index.tsx
Normal file
194
mobile-app/app/(tabs)/(settings)/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { StyleSheet, View, ScrollView, TouchableOpacity, Image, Animated } from 'react-native';
|
||||
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 } from 'react';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const webApi = useWebApi();
|
||||
const colors = useColors();
|
||||
const { username, getAuthMethodDisplay } = useAuth();
|
||||
const scrollY = useRef(new Animated.Value(0)).current;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await webApi.logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const handleVaultUnlockPress = () => {
|
||||
router.push('/(tabs)/(settings)/vault-unlock');
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
marginTop: 22,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
section: {
|
||||
marginTop: 20,
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
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,
|
||||
},
|
||||
settingItemContentWithBorder: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.accentBorder,
|
||||
},
|
||||
settingItemText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
settingItemValue: {
|
||||
fontSize: 16,
|
||||
color: colors.textMuted,
|
||||
marginRight: 8,
|
||||
},
|
||||
userInfoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 10,
|
||||
marginBottom: 20,
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
marginRight: 12,
|
||||
},
|
||||
usernameText: {
|
||||
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',
|
||||
paddingBottom: 16,
|
||||
},
|
||||
versionText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedSafeAreaView style={styles.container}>
|
||||
<CollapsibleHeader
|
||||
title="Settings"
|
||||
scrollY={scrollY}
|
||||
showNavigationHeader={true}
|
||||
/>
|
||||
<ThemedView style={styles.content}>
|
||||
<Animated.ScrollView
|
||||
ref={scrollViewRef}
|
||||
onScroll={Animated.event(
|
||||
[{ nativeEvent: { contentOffset: { y: scrollY } } }],
|
||||
{ useNativeDriver: true }
|
||||
)}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={{ paddingBottom: 40, paddingTop: 4 }}
|
||||
scrollIndicatorInsets={{ bottom: 40 }}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
<TitleContainer title="Settings" />
|
||||
<View style={styles.userInfoContainer}>
|
||||
<Image
|
||||
source={require('@/assets/images/avatar.webp')}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
<ThemedText style={styles.usernameText}>Logged in as: {username}</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={handleVaultUnlockPress}
|
||||
>
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="lock-closed" size={20} color={colors.text} />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<ThemedText style={styles.settingItemText}>Vault Unlock Method</ThemedText>
|
||||
<ThemedText style={styles.settingItemValue}>{getAuthMethodDisplay()}</ThemedText>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textMuted} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<View style={styles.settingItemIcon}>
|
||||
<Ionicons name="log-out" size={20} color="#FF3B30" />
|
||||
</View>
|
||||
<View style={[styles.settingItemContent]}>
|
||||
<ThemedText style={[styles.settingItemText, { color: '#FF3B30' }]}>Logout</ThemedText>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.versionContainer}>
|
||||
<ThemedText style={styles.versionText}>App version {AppInfo.VERSION}</ThemedText>
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
</ThemedView>
|
||||
</ThemedSafeAreaView>
|
||||
);
|
||||
}
|
||||
157
mobile-app/app/(tabs)/(settings)/vault-unlock.tsx
Normal file
157
mobile-app/app/(tabs)/(settings)/vault-unlock.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { StyleSheet, View, ScrollView, Alert, Platform, Linking, Switch } from 'react-native';
|
||||
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 { useAuth } from '@/context/AuthContext';
|
||||
|
||||
export default function VaultUnlockSettingsScreen() {
|
||||
const colors = useColors();
|
||||
const { setAuthMethods, enabledAuthMethods } = useAuth();
|
||||
const [hasFaceID, setHasFaceID] = useState(false);
|
||||
const [isFaceIDEnabled, setIsFaceIDEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkFaceIDAvailability = async () => {
|
||||
const compatible = await LocalAuthentication.hasHardwareAsync();
|
||||
const enrolled = await LocalAuthentication.isEnrolledAsync();
|
||||
setHasFaceID(compatible && enrolled);
|
||||
};
|
||||
checkFaceIDAvailability();
|
||||
|
||||
if (enabledAuthMethods.includes('faceid')) {
|
||||
setIsFaceIDEnabled(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFaceIDEnabled) {
|
||||
setAuthMethods(['faceid', 'password']);
|
||||
} else {
|
||||
setAuthMethods(['password']);
|
||||
}
|
||||
}, [isFaceIDEnabled, setAuthMethods]);
|
||||
|
||||
const handleFaceIDToggle = useCallback(async (value: boolean) => {
|
||||
if (value && !hasFaceID) {
|
||||
Alert.alert(
|
||||
'Face ID Not Available',
|
||||
'Face ID is not set up on this device. Please set up Face ID in your device settings to use this feature.',
|
||||
[
|
||||
{
|
||||
text: 'Open Settings',
|
||||
onPress: () => {
|
||||
if (Platform.OS === 'ios') {
|
||||
Linking.openURL('app-settings:');
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Cancel',
|
||||
style: 'cancel',
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFaceIDEnabled(value);
|
||||
setAuthMethods(value ? ['faceid', 'password'] : ['password']);
|
||||
}, [hasFaceID]);
|
||||
|
||||
const styles = useMemo(() => 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]);
|
||||
|
||||
return (
|
||||
<ThemedSafeAreaView style={styles.container}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.headerText}>
|
||||
Choose how you want to unlock your vault
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.optionContainer}>
|
||||
<View style={styles.option}>
|
||||
<View style={styles.optionHeader}>
|
||||
<ThemedText style={[styles.optionText, !hasFaceID && styles.disabledText]}>
|
||||
Face ID / Touch ID
|
||||
</ThemedText>
|
||||
<Switch
|
||||
value={isFaceIDEnabled}
|
||||
onValueChange={handleFaceIDToggle}
|
||||
disabled={!hasFaceID}
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={styles.helpText}>
|
||||
Your vault decryption key will be securely stored on your local device in the iOS Keychain and can only be accessed with your face or fingerprint.
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.option}>
|
||||
<View style={styles.optionHeader}>
|
||||
<ThemedText style={styles.optionText}>Password</ThemedText>
|
||||
<Switch
|
||||
value={true}
|
||||
disabled={true}
|
||||
/>
|
||||
</View>
|
||||
<ThemedText style={styles.helpText}>
|
||||
Re-enter your full master password to unlock your vault. This is always enabled as fallback option.
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedSafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export default function TabLayout() {
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
name="(settings)"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color }) => <IconSymbol size={28} name="gear" color={color} />,
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { StyleSheet, Button, SafeAreaView, TouchableOpacity } from 'react-native';
|
||||
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';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const webApi = useWebApi();
|
||||
const colors = useColors();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await webApi.logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
settingsContainer: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
logoutButton: {
|
||||
backgroundColor: '#FF3B30',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
},
|
||||
logoutButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
versionContainer: {
|
||||
marginTop: 'auto',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 16,
|
||||
},
|
||||
versionText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ThemedView style={styles.content}>
|
||||
<TitleContainer title="Settings" />
|
||||
<ThemedView style={styles.settingsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.logoutButton}
|
||||
onPress={handleLogout}
|
||||
>
|
||||
<ThemedText style={styles.logoutButtonText}>Logout</ThemedText>
|
||||
</TouchableOpacity>
|
||||
<ThemedText style={styles.versionText}>Version {AppInfo.VERSION}</ThemedText>
|
||||
</ThemedView>
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default function InitialLoadingScreen() {
|
||||
hasInitialized.current = true;
|
||||
|
||||
async function initialize() {
|
||||
const isLoggedIn = await authContext.initializeAuth();
|
||||
const { isLoggedIn, enabledAuthMethods } = await authContext.initializeAuth();
|
||||
|
||||
// If user is not logged in, navigate to login immediately
|
||||
if (!isLoggedIn) {
|
||||
@@ -60,6 +60,13 @@ export default function InitialLoadingScreen() {
|
||||
// as we're just checking if the file exists.
|
||||
const isInitialized = await NativeModules.CredentialManager.isVaultInitialized();
|
||||
if (isInitialized) {
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
console.log('FaceID is not enabled, navigating to unlock screen');
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to unlock the vault with FaceID.
|
||||
setStatus('Unlocking vault|');
|
||||
const isUnlocked = await NativeModules.CredentialManager.unlockVault();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SrpUtility } from '@/utils/SrpUtility';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
|
||||
export default function UnlockScreen() {
|
||||
const { isLoggedIn, username } = useAuth();
|
||||
const { isLoggedIn, username, isFaceIDEnabled } = useAuth();
|
||||
const { testDatabaseConnection } = useDb();
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -221,12 +221,14 @@ export default function UnlockScreen() {
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.faceIdButton}
|
||||
onPress={handleFaceIDRetry}
|
||||
>
|
||||
<ThemedText style={styles.faceIdButtonText}>Try Face ID Again</ThemedText>
|
||||
</TouchableOpacity>
|
||||
{isFaceIDEnabled() && (
|
||||
<TouchableOpacity
|
||||
style={styles.faceIdButton}
|
||||
onPress={handleFaceIDRetry}
|
||||
>
|
||||
<ThemedText style={styles.faceIdButtonText}>Try Face ID Again</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.logoutButton}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useDb } from './DbContext';
|
||||
import { NativeModules } from 'react-native';
|
||||
|
||||
export type AuthMethod = 'faceid' | 'password';
|
||||
|
||||
type AuthContextType = {
|
||||
isLoggedIn: boolean;
|
||||
isInitialized: boolean;
|
||||
username: string | null;
|
||||
enabledAuthMethods: AuthMethod[];
|
||||
isFaceIDEnabled: () => boolean;
|
||||
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
|
||||
initializeAuth: () => Promise<boolean>;
|
||||
initializeAuth: () => Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }>;
|
||||
login: () => Promise<void>;
|
||||
logout: (errorMessage?: string) => Promise<void>;
|
||||
globalMessage: string | null;
|
||||
clearGlobalMessage: () => void;
|
||||
setAuthMethods: (methods: AuthMethod[]) => Promise<void>;
|
||||
getAuthMethodDisplay: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,8 +34,16 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [globalMessage, setGlobalMessage] = useState<string | null>(null);
|
||||
const [enabledAuthMethods, setEnabledAuthMethods] = useState<AuthMethod[]>(['password']);
|
||||
const dbContext = useDb();
|
||||
|
||||
/**
|
||||
* Check if Face ID is enabled based on enabled auth methods
|
||||
*/
|
||||
const isFaceIDEnabled = useCallback(() : boolean => {
|
||||
return enabledAuthMethods.includes('faceid');
|
||||
}, [enabledAuthMethods]);
|
||||
|
||||
/**
|
||||
* Set auth tokens in storage as part of the login process. After db is initialized, the login method should be called as well.
|
||||
*/
|
||||
@@ -42,20 +57,35 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
/**
|
||||
* Initialize the authentication state, called on initial load by _layout.tsx.
|
||||
* @returns boolean indicating whether the user is logged in
|
||||
* @returns object containing whether the user is logged in and enabled auth methods
|
||||
*/
|
||||
const initializeAuth = useCallback(async () : Promise<boolean> => {
|
||||
const initializeAuth = useCallback(async () : Promise<{ isLoggedIn: boolean; enabledAuthMethods: AuthMethod[] }> => {
|
||||
const accessToken = await AsyncStorage.getItem('accessToken') as string;
|
||||
const refreshToken = await AsyncStorage.getItem('refreshToken') as string;
|
||||
const username = await AsyncStorage.getItem('username') as string;
|
||||
const savedAuthMethods = await AsyncStorage.getItem('authMethods');
|
||||
let isAuthenticated = false;
|
||||
let methods: AuthMethod[] = ['password'];
|
||||
|
||||
if (accessToken && refreshToken && username) {
|
||||
setUsername(username);
|
||||
setIsLoggedIn(true);
|
||||
isAuthenticated = true;
|
||||
if (savedAuthMethods) {
|
||||
try {
|
||||
const parsedMethods = JSON.parse(savedAuthMethods) as AuthMethod[];
|
||||
if (Array.isArray(parsedMethods) && parsedMethods.every(method => method === 'faceid' || method === 'password')) {
|
||||
methods = parsedMethods;
|
||||
setEnabledAuthMethods(parsedMethods);
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, use default
|
||||
setEnabledAuthMethods(['password']);
|
||||
}
|
||||
}
|
||||
}
|
||||
setIsInitialized(true);
|
||||
return isAuthenticated;
|
||||
return { isLoggedIn: isAuthenticated, enabledAuthMethods: methods };
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -72,6 +102,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
await AsyncStorage.removeItem('username');
|
||||
await AsyncStorage.removeItem('accessToken');
|
||||
await AsyncStorage.removeItem('refreshToken');
|
||||
await AsyncStorage.removeItem('authMethods');
|
||||
dbContext?.clearDatabase();
|
||||
|
||||
// Set local storage global message that will be shown on the login page.
|
||||
@@ -81,6 +112,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
setUsername(null);
|
||||
setIsLoggedIn(false);
|
||||
setEnabledAuthMethods(['password']);
|
||||
}, [dbContext]);
|
||||
|
||||
/**
|
||||
@@ -90,17 +122,57 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
setGlobalMessage(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set the authentication methods and save them to storage
|
||||
*/
|
||||
const setAuthMethods = useCallback(async (methods: AuthMethod[]) : Promise<void> => {
|
||||
// Ensure password is always included
|
||||
const methodsToSave = methods.includes('password') ? methods : [...methods, 'password'];
|
||||
|
||||
// Save to AsyncStorage
|
||||
await AsyncStorage.setItem('authMethods', JSON.stringify(methodsToSave));
|
||||
|
||||
// Update iOS credentials manager
|
||||
try {
|
||||
await NativeModules.CredentialManager.setAuthMethods(methodsToSave);
|
||||
} catch (error) {
|
||||
console.error('Failed to update iOS auth methods:', error);
|
||||
// Continue with the update even if iOS update fails
|
||||
}
|
||||
|
||||
// Use a state update function to ensure we're working with the latest state
|
||||
setEnabledAuthMethods(prevMethods => {
|
||||
// Only update if the methods have actually changed
|
||||
if (JSON.stringify(prevMethods) !== JSON.stringify(methodsToSave)) {
|
||||
return methodsToSave as AuthMethod[];
|
||||
}
|
||||
return prevMethods;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get the display label for the current auth method
|
||||
* Prefers Face ID if enabled, otherwise falls back to Password
|
||||
*/
|
||||
const getAuthMethodDisplay = useCallback(() : string => {
|
||||
return enabledAuthMethods.includes('faceid') ? 'Face ID' : 'Password';
|
||||
}, [enabledAuthMethods]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
isLoggedIn,
|
||||
isInitialized,
|
||||
username,
|
||||
enabledAuthMethods,
|
||||
isFaceIDEnabled,
|
||||
initializeAuth,
|
||||
setAuthTokens,
|
||||
login,
|
||||
logout,
|
||||
globalMessage,
|
||||
clearGlobalMessage,
|
||||
}), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage, initializeAuth]);
|
||||
setAuthMethods,
|
||||
getAuthMethodDisplay,
|
||||
}), [isLoggedIn, isInitialized, username, globalMessage, enabledAuthMethods, setAuthTokens, login, logout, clearGlobalMessage, initializeAuth, setAuthMethods, getAuthMethodDisplay, isFaceIDEnabled]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const useVaultSync = () => {
|
||||
console.log('syncVault called with initialSync:', initialSync);
|
||||
|
||||
try {
|
||||
const isLoggedIn = await authContext.initializeAuth();
|
||||
const { isLoggedIn } = await authContext.initializeAuth();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
console.log('Vault sync: Not authenticated');
|
||||
|
||||
@@ -18,6 +18,10 @@ RCT_EXTERN_METHOD(storeDatabase:(NSString *)base64EncryptedDb
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(setAuthMethods:(NSArray *)authMethods
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(storeEncryptionKey:(NSString *)base64EncryptionKey
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
@@ -23,22 +23,39 @@ class CredentialManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func setAuthMethods(_ authMethods: [String],
|
||||
resolver resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
do {
|
||||
var methods: AuthMethods = []
|
||||
|
||||
for method in authMethods {
|
||||
switch method.lowercased() {
|
||||
case "faceid":
|
||||
methods.insert(.faceID)
|
||||
case "password":
|
||||
methods.insert(.password)
|
||||
default:
|
||||
reject("INVALID_AUTH_METHOD", "Invalid authentication method: \(method)", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try credentialStore.setAuthMethods(methods)
|
||||
resolve(nil)
|
||||
} catch {
|
||||
reject("AUTH_METHOD_ERROR", "Failed to set authentication methods: \(error.localizedDescription)", error)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func storeEncryptionKey(_ base64EncryptionKey: String,
|
||||
resolver resolve: @escaping RCTPromiseResolveBlock,
|
||||
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
||||
do {
|
||||
// Store the encryption key in the keychain with biometric or PIN protection
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else {
|
||||
reject("AUTH_UNAVAILABLE", "Authentication not available: \(error?.localizedDescription ?? "Unknown error")", error)
|
||||
return
|
||||
}
|
||||
|
||||
// Store the key in the keychain with authentication protection
|
||||
try credentialStore.storeEncryptionKey(base64EncryptionKey)
|
||||
try credentialStore.storeEncryptionKey(base64Key: base64EncryptionKey)
|
||||
resolve(nil)
|
||||
} catch {
|
||||
reject("KEYCHAIN_ERROR", "Failed to store encryption key: \(error.localizedDescription)", error)
|
||||
|
||||
@@ -5,16 +5,38 @@ import LocalAuthentication
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
|
||||
struct AuthMethods: OptionSet {
|
||||
let rawValue: Int
|
||||
|
||||
static let faceID = AuthMethods(rawValue: 1 << 0)
|
||||
static let password = AuthMethods(rawValue: 1 << 1)
|
||||
|
||||
static let all: AuthMethods = [.faceID, .password]
|
||||
static let none: AuthMethods = []
|
||||
}
|
||||
|
||||
class SharedCredentialStore {
|
||||
static let shared = SharedCredentialStore()
|
||||
private let keychain = Keychain(service: "net.aliasvault.autofill", accessGroup: "group.net.aliasvault.autofill")
|
||||
.accessibility(.whenPasscodeSetThisDeviceOnly, authenticationPolicy: [.biometryAny])
|
||||
private let encryptionKeyKey = "aliasvault_encryption_key"
|
||||
private let encryptedDbFileName = "encrypted_db.sqlite"
|
||||
private let authMethodsKey = "aliasvault_auth_methods"
|
||||
private var db: Connection?
|
||||
private var encryptionKey: Data?
|
||||
private var enabledAuthMethods: AuthMethods = .none
|
||||
|
||||
public init() {}
|
||||
public init() {
|
||||
// Load saved auth methods from UserDefaults
|
||||
let savedRawValue = UserDefaults.standard.integer(forKey: authMethodsKey)
|
||||
enabledAuthMethods = AuthMethods(rawValue: savedRawValue)
|
||||
}
|
||||
|
||||
// MARK: - Auth Methods Management
|
||||
func setAuthMethods(_ methods: AuthMethods) throws {
|
||||
enabledAuthMethods = methods
|
||||
UserDefaults.standard.set(methods.rawValue, forKey: authMethodsKey)
|
||||
UserDefaults.standard.synchronize()
|
||||
}
|
||||
|
||||
// MARK: - Vault Status
|
||||
func isVaultInitialized() -> Bool {
|
||||
@@ -27,26 +49,33 @@ class SharedCredentialStore {
|
||||
// MARK: - Encryption Key Management
|
||||
private func getEncryptionKey() throws -> Data {
|
||||
if let key = encryptionKey {
|
||||
// print as base64 for debugging
|
||||
print("Key found in memory: \(key.base64EncodedString())")
|
||||
return key
|
||||
}
|
||||
|
||||
guard let keyData = try? keychain
|
||||
.authenticationPrompt("Authenticate to unlock your vault")
|
||||
.getData(encryptionKeyKey) else {
|
||||
throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"])
|
||||
// If Face ID is enabled, try to get the key from keychain
|
||||
if enabledAuthMethods.contains(.faceID) {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "Face ID not available: \(error?.localizedDescription ?? "Unknown error")"])
|
||||
}
|
||||
|
||||
guard let keyData = try? keychain
|
||||
.authenticationPrompt("Authenticate to unlock your vault")
|
||||
.getData(encryptionKeyKey) else {
|
||||
throw NSError(domain: "SharedCredentialStore", code: 2, userInfo: [NSLocalizedDescriptionKey: "No encryption key found"])
|
||||
}
|
||||
|
||||
encryptionKey = keyData
|
||||
return keyData
|
||||
}
|
||||
|
||||
encryptionKey = keyData
|
||||
// print as base64 for debugging
|
||||
print("Key found in keychain: \(keyData.base64EncodedString())")
|
||||
return keyData
|
||||
// If Face ID is not enabled and we don't have a key in memory, throw an error
|
||||
throw NSError(domain: "SharedCredentialStore", code: 3, userInfo: [NSLocalizedDescriptionKey: "No encryption key found in memory"])
|
||||
}
|
||||
|
||||
func storeEncryptionKey(_ base64Key: String) throws {
|
||||
print("Storing encryption key")
|
||||
|
||||
func storeEncryptionKey(base64Key: String) throws {
|
||||
// Convert base64 string to bytes
|
||||
guard let keyData = Data(base64Encoded: base64Key) else {
|
||||
throw NSError(domain: "SharedCredentialStore", code: 6, userInfo: [NSLocalizedDescriptionKey: "Invalid base64 key"])
|
||||
@@ -57,16 +86,19 @@ class SharedCredentialStore {
|
||||
throw NSError(domain: "SharedCredentialStore", code: 7, userInfo: [NSLocalizedDescriptionKey: "Invalid key length. Expected 32 bytes"])
|
||||
}
|
||||
|
||||
do {
|
||||
try keychain
|
||||
.authenticationPrompt("Authenticate to unlock your vault")
|
||||
.set(keyData, key: encryptionKeyKey)
|
||||
encryptionKey = keyData
|
||||
// print as base64 for debugging
|
||||
print("Key saved in keychain: \(keyData.base64EncodedString())")
|
||||
} catch {
|
||||
print("Failed to save key to keychain: \(error)")
|
||||
throw error
|
||||
// Store the key in memory
|
||||
encryptionKey = keyData
|
||||
|
||||
// Store the key in the keychain if Face ID is enabled
|
||||
if enabledAuthMethods.contains(.faceID) {
|
||||
do {
|
||||
try keychain
|
||||
.authenticationPrompt("Authenticate to unlock your vault")
|
||||
.set(keyData, key: encryptionKeyKey)
|
||||
} catch {
|
||||
print("Failed to save key to keychain: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +139,9 @@ class SharedCredentialStore {
|
||||
return UserDefaults.standard.string(forKey: "vault_metadata")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database.
|
||||
*/
|
||||
func initializeDatabase() throws {
|
||||
// Get the encrypted database
|
||||
guard let encryptedDbBase64 = getEncryptedDatabase() else {
|
||||
|
||||
@@ -234,6 +234,8 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoLinking (7.0.5):
|
||||
- ExpoModulesCore
|
||||
- ExpoLocalAuthentication (15.0.2):
|
||||
- ExpoModulesCore
|
||||
- ExpoModulesCore (2.2.3):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
@@ -2190,6 +2192,7 @@ DEPENDENCIES:
|
||||
- ExpoHead (from `../node_modules/expo-router/ios`)
|
||||
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoLocalAuthentication (from `../node_modules/expo-local-authentication/ios`)
|
||||
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
|
||||
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
|
||||
- ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`)
|
||||
@@ -2323,6 +2326,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/expo-keep-awake/ios"
|
||||
ExpoLinking:
|
||||
:path: "../node_modules/expo-linking/ios"
|
||||
ExpoLocalAuthentication:
|
||||
:path: "../node_modules/expo-local-authentication/ios"
|
||||
ExpoModulesCore:
|
||||
:path: "../node_modules/expo-modules-core"
|
||||
ExpoSharing:
|
||||
@@ -2508,6 +2513,7 @@ SPEC CHECKSUMS:
|
||||
ExpoHead: cee2d16ef197aaadb0ac481cf221a663636eb074
|
||||
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
|
||||
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
||||
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
||||
ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993
|
||||
ExpoSharing: 849a5ce9985c22598c16ec027e32969be8062e8e
|
||||
ExpoSplashScreen: 0f281e3c2ded4757d2309276c682d023c6299c77
|
||||
|
||||
13
mobile-app/package-lock.json
generated
13
mobile-app/package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"expo-font": "~13.0.4",
|
||||
"expo-haptics": "~14.0.1",
|
||||
"expo-linking": "~7.0.5",
|
||||
"expo-local-authentication": "~15.0.2",
|
||||
"expo-router": "~4.0.20",
|
||||
"expo-sharing": "~13.0.1",
|
||||
"expo-splash-screen": "~0.29.22",
|
||||
@@ -7584,6 +7585,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-local-authentication": {
|
||||
"version": "15.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-15.0.2.tgz",
|
||||
"integrity": "sha512-v7TOfovuivGWffA8B0FudEas+njMKTrjhaPpJYLASiTLTP3zUYqtumNgZ9pkaDB6Cagq0+DeNH39AI8tdEBUkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-manifests": {
|
||||
"version": "0.15.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-0.15.8.tgz",
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"react-native-toast-message": "^2.2.1",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-webview": "13.12.5",
|
||||
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0"
|
||||
"secure-remote-password": "github:LinusU/secure-remote-password#73e5f732b6ca0cdbdc19da1a0c5f48cdbad2cbf0",
|
||||
"expo-local-authentication": "~15.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
|
||||
Reference in New Issue
Block a user