From 3958ce94c10e22601cb46908b5dc16cdbfaca70d Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Sat, 10 May 2025 11:50:57 +0200 Subject: [PATCH] Add security settings nav scaffolding (#771) --- .../app/(tabs)/credentials/_layout.tsx | 7 - apps/mobile-app/app/(tabs)/emails/_layout.tsx | 1 - .../app/(tabs)/settings/_layout.tsx | 41 ++++- apps/mobile-app/app/(tabs)/settings/index.tsx | 16 ++ .../settings/security/active-sessions.tsx | 156 ++++++++++++++++ .../(tabs)/settings/security/auth-logs.tsx | 161 ++++++++++++++++ .../settings/security/change-password.tsx | 144 +++++++++++++++ .../settings/security/delete-account.tsx | 174 ++++++++++++++++++ .../app/(tabs)/settings/security/index.tsx | 146 +++++++++++++++ .../components/themed/ThemedButton.tsx | 72 ++++++++ .../components/themed/ThemedTextInput.tsx | 49 +++++ 11 files changed, 955 insertions(+), 12 deletions(-) create mode 100644 apps/mobile-app/app/(tabs)/settings/security/active-sessions.tsx create mode 100644 apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx create mode 100644 apps/mobile-app/app/(tabs)/settings/security/change-password.tsx create mode 100644 apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx create mode 100644 apps/mobile-app/app/(tabs)/settings/security/index.tsx create mode 100644 apps/mobile-app/components/themed/ThemedButton.tsx create mode 100644 apps/mobile-app/components/themed/ThemedTextInput.tsx diff --git a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx b/apps/mobile-app/app/(tabs)/credentials/_layout.tsx index 9094461af..1b18c6e83 100644 --- a/apps/mobile-app/app/(tabs)/credentials/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/credentials/_layout.tsx @@ -20,8 +20,6 @@ export default function CredentialsLayout(): React.ReactNode { options={{ title: 'Add Credential', presentation: Platform.OS === 'ios' ? 'modal' : 'card', - headerShown: true, - gestureEnabled: true, ...defaultHeaderOptions, }} /> @@ -29,8 +27,6 @@ export default function CredentialsLayout(): React.ReactNode { name="add-edit-page" options={{ title: 'Add Credential', - headerShown: true, - gestureEnabled: true, ...defaultHeaderOptions, }} /> @@ -39,7 +35,6 @@ export default function CredentialsLayout(): React.ReactNode { options={{ title: 'Credential Created', presentation: Platform.OS === 'ios' ? 'modal' : 'card', - headerShown: true, ...defaultHeaderOptions, }} /> @@ -47,7 +42,6 @@ export default function CredentialsLayout(): React.ReactNode { name="[id]" options={{ title: 'Credential Details', - headerShown: true, ...defaultHeaderOptions, }} /> @@ -55,7 +49,6 @@ export default function CredentialsLayout(): React.ReactNode { name="email/[id]" options={{ title: 'Email Preview', - headerShown: true, }} /> diff --git a/apps/mobile-app/app/(tabs)/emails/_layout.tsx b/apps/mobile-app/app/(tabs)/emails/_layout.tsx index 9f62fda9e..988148489 100644 --- a/apps/mobile-app/app/(tabs)/emails/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/emails/_layout.tsx @@ -16,7 +16,6 @@ export default function EmailsLayout(): React.ReactNode { name="[id]" options={{ title: 'Email', - headerShown: true, }} /> diff --git a/apps/mobile-app/app/(tabs)/settings/_layout.tsx b/apps/mobile-app/app/(tabs)/settings/_layout.tsx index e97f4e6f1..cb4f58f42 100644 --- a/apps/mobile-app/app/(tabs)/settings/_layout.tsx +++ b/apps/mobile-app/app/(tabs)/settings/_layout.tsx @@ -19,7 +19,6 @@ export default function SettingsLayout(): React.ReactNode { options={{ title: 'iOS Autofill', headerBackTitle: 'Settings', - headerShown: true, ...defaultHeaderOptions, }} /> @@ -28,16 +27,50 @@ export default function SettingsLayout(): React.ReactNode { options={{ title: 'Vault Unlock Method', headerBackTitle: 'Settings', - headerShown: true, ...defaultHeaderOptions, }} /> + + + + + diff --git a/apps/mobile-app/app/(tabs)/settings/index.tsx b/apps/mobile-app/app/(tabs)/settings/index.tsx index 45fc65b32..977495464 100644 --- a/apps/mobile-app/app/(tabs)/settings/index.tsx +++ b/apps/mobile-app/app/(tabs)/settings/index.tsx @@ -258,6 +258,7 @@ export default function SettingsScreen() : React.ReactNode { 1 )} + @@ -300,6 +301,21 @@ export default function SettingsScreen() : React.ReactNode { + + router.push('/(tabs)/settings/security')} + > + + + + + Security Settings + + + + + ([]); + const [isLoading, setIsLoading] = useState(true); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: insets.bottom, + paddingHorizontal: 14, + }, + section: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + marginTop: 20, + overflow: 'hidden', + }, + sessionItem: { + padding: 16, + }, + sessionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + deviceName: { + color: colors.text, + fontSize: 16, + fontWeight: '600', + }, + sessionDetails: { + marginBottom: 8, + }, + detailText: { + color: colors.textMuted, + fontSize: 14, + marginBottom: 4, + }, + revokeButton: { + color: colors.primary, + fontSize: 14, + fontWeight: '600', + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + emptyStateText: { + color: colors.textMuted, + fontSize: 16, + textAlign: 'center', + }, + }); + + const loadSessions = async () => { + try { + setIsLoading(true); + const response = await webApi.getActiveSessions(); + setSessions(response); + } catch (error) { + Alert.alert('Error', 'Failed to load active sessions'); + } finally { + setIsLoading(false); + } + }; + + const handleRevokeSession = async (sessionId: string) => { + Alert.alert( + 'Revoke Session', + 'Are you sure you want to revoke this session?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Revoke', + style: 'destructive', + onPress: async () => { + try { + await webApi.revokeSession(sessionId); + await loadSessions(); + } catch (error) { + Alert.alert('Error', 'Failed to revoke session'); + } + }, + }, + ] + ); + }; + + useEffect(() => { + loadSessions(); + }, []); + + const renderSession = ({ item }: { item: Session }) => ( + + + {item.deviceName} + handleRevokeSession(item.id)}> + Revoke + + + + Last active: {item.lastActive} + IP Address: {item.ipAddress} + + + ); + + return ( + + + {isLoading ? ( + + + + ) : sessions.length === 0 ? ( + + No active sessions + + ) : ( + item.id} + scrollEnabled={false} + /> + )} + + + ); +} \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx b/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx new file mode 100644 index 000000000..97ba582db --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx @@ -0,0 +1,161 @@ +import { StyleSheet, View, TouchableOpacity, Animated, FlatList } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useRef, useState, useEffect } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { useColors } from '@/hooks/useColorScheme'; +import { TitleContainer } from '@/components/ui/TitleContainer'; +import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; +import { useWebApi } from '@/context/WebApiContext'; +import { InlineSkeletonLoader } from '@/components/ui/InlineSkeletonLoader'; + +interface IAuthLog { + id: string; + timestamp: string; + eventType: string; + ipAddress: string; + deviceName: string; + success: boolean; +} + +/** + * Auth logs screen. + */ +export default function AuthLogsScreen() : React.ReactNode { + const colors = useColors(); + const insets = useSafeAreaInsets(); + const scrollY = useRef(new Animated.Value(0)).current; + const webApi = useWebApi(); + + const [logs, setLogs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: insets.bottom, + paddingHorizontal: 14, + paddingTop: insets.top, + }, + scrollContent: { + paddingBottom: 40, + paddingTop: 42, + }, + scrollView: { + flex: 1, + }, + section: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + marginTop: 20, + overflow: 'hidden', + }, + logItem: { + padding: 16, + }, + logHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + eventType: { + color: colors.text, + fontSize: 16, + fontWeight: '600', + }, + status: { + fontSize: 14, + fontWeight: '600', + }, + statusSuccess: { + color: colors.success, + }, + statusFailure: { + color: colors.error, + }, + logDetails: { + marginBottom: 8, + }, + detailText: { + color: colors.textMuted, + fontSize: 14, + marginBottom: 4, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + emptyStateText: { + color: colors.textMuted, + fontSize: 16, + textAlign: 'center', + }, + }); + + /** + * Loads the authentication logs from the server. + */ + const loadLogs = async () : Promise => { + try { + setIsLoading(true); + const response = await webApi.getAuthLogs(); + setLogs(response); + } catch (error) { + // Error handling is done by the WebApiService + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadLogs(); + }, []); + + const renderLog = ({ item }: { item: IAuthLog }) => ( + + + {item.eventType} + + {item.success ? 'Success' : 'Failed'} + + + + Time: {item.timestamp} + Device: {item.deviceName} + IP Address: {item.ipAddress} + + + ); + + return ( + + + + {isLoading ? ( + + + + ) : logs.length === 0 ? ( + + No auth logs found + + ) : ( + item.id} + scrollEnabled={false} + /> + )} + + + + ); +} \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/security/change-password.tsx b/apps/mobile-app/app/(tabs)/settings/security/change-password.tsx new file mode 100644 index 000000000..2e51911a6 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/security/change-password.tsx @@ -0,0 +1,144 @@ +import { StyleSheet, View, TouchableOpacity, Animated, Alert } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useRef, useState } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { useColors } from '@/hooks/useColorScheme'; +import { TitleContainer } from '@/components/ui/TitleContainer'; +import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; +import { ThemedTextInput } from '@/components/themed/ThemedTextInput'; +import { ThemedButton } from '@/components/themed/ThemedButton'; +import { useWebApi } from '@/context/WebApiContext'; + +/** + * Change password screen. + */ +export default function ChangePasswordScreen() : React.ReactNode { + const colors = useColors(); + const insets = useSafeAreaInsets(); + const scrollY = useRef(new Animated.Value(0)).current; + const webApi = useWebApi(); + + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: insets.bottom, + paddingHorizontal: 14, + paddingTop: insets.top, + }, + scrollContent: { + paddingBottom: 40, + paddingTop: 42, + }, + scrollView: { + flex: 1, + }, + form: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + marginTop: 20, + padding: 16, + }, + inputContainer: { + marginBottom: 16, + }, + label: { + color: colors.text, + fontSize: 16, + marginBottom: 8, + }, + button: { + marginTop: 8, + }, + }); + + const handleSubmit = async () => { + if (!currentPassword || !newPassword || !confirmPassword) { + Alert.alert('Error', 'Please fill in all fields'); + return; + } + + if (newPassword !== confirmPassword) { + Alert.alert('Error', 'New passwords do not match'); + return; + } + + try { + setIsLoading(true); + + // Verify current password + /*const isValid = await verifyPassword(currentPassword); + if (!isValid) { + Alert.alert('Error', 'Current password is incorrect'); + return; + } + + // Create new vault version with new password + await createNewVersion(newPassword); + + // Submit to server + await webApi.submitVaultVersion(vault.getCurrentVersion()); + + Alert.alert('Success', 'Password changed successfully', [ + { text: 'OK', onPress: () => router.back() } + ]);*/ + } catch (error) { + Alert.alert('Error', 'Failed to change password. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Current Password + + + + + New Password + + + + + Confirm New Password + + + + + + + + ); +} \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx b/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx new file mode 100644 index 000000000..b049e35bd --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/security/delete-account.tsx @@ -0,0 +1,174 @@ +import { StyleSheet, View, TouchableOpacity, Animated, Alert } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useRef, useState } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { useColors } from '@/hooks/useColorScheme'; +import { TitleContainer } from '@/components/ui/TitleContainer'; +import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; +import { ThemedTextInput } from '@/components/themed/ThemedTextInput'; +import { ThemedButton } from '@/components/themed/ThemedButton'; +import { useWebApi } from '@/context/WebApiContext'; +import { useAuth } from '@/context/AuthContext'; + +/** + * Delete account screen. + */ +export default function DeleteAccountScreen() : React.ReactNode { + const colors = useColors(); + const insets = useSafeAreaInsets(); + const scrollY = useRef(new Animated.Value(0)).current; + const webApi = useWebApi(); + const { username } = useAuth(); + + const [confirmUsername, setConfirmUsername] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [step, setStep] = useState<'username' | 'password'>('username'); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: insets.bottom, + paddingHorizontal: 14, + paddingTop: insets.top, + }, + scrollContent: { + paddingBottom: 40, + paddingTop: 42, + }, + scrollView: { + flex: 1, + }, + form: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + marginTop: 20, + padding: 16, + }, + inputContainer: { + marginBottom: 16, + }, + label: { + color: colors.text, + fontSize: 16, + marginBottom: 8, + }, + warningText: { + color: colors.error, + fontSize: 14, + marginBottom: 16, + textAlign: 'center', + }, + button: { + marginTop: 8, + }, + buttonDanger: { + backgroundColor: colors.error, + }, + }); + + const handleUsernameSubmit = () => { + if (confirmUsername !== username) { + Alert.alert('Error', 'Username does not match'); + return; + } + setStep('password'); + }; + + const handleDeleteAccount = async () => { + if (!password) { + Alert.alert('Error', 'Please enter your password'); + return; + } + + Alert.alert( + 'Delete Account', + 'Are you absolutely sure you want to delete your account? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete Account', + style: 'destructive', + onPress: async () => { + try { + setIsLoading(true); + + // Verify password + /*const isValid = await vault.verifyPassword(password); + if (!isValid) { + Alert.alert('Error', 'Password is incorrect'); + return; + } + + // Delete account + await webApi.deleteAccount();*/ + + // Log out and return to login + await webApi.logout(); + router.replace('/login'); + } catch (error) { + Alert.alert('Error', 'Failed to delete account. Please try again.'); + } finally { + setIsLoading(false); + } + }, + }, + ] + ); + }; + + return ( + + + + {step === 'username' ? ( + <> + + Warning: This action cannot be undone. All your data will be permanently deleted. + + + Enter your username to confirm + + + + + ) : ( + <> + + Please enter your password to confirm account deletion + + + Password + + + + + )} + + + + ); +} \ No newline at end of file diff --git a/apps/mobile-app/app/(tabs)/settings/security/index.tsx b/apps/mobile-app/app/(tabs)/settings/security/index.tsx new file mode 100644 index 000000000..72a35c272 --- /dev/null +++ b/apps/mobile-app/app/(tabs)/settings/security/index.tsx @@ -0,0 +1,146 @@ +import { StyleSheet, View, TouchableOpacity, Animated, ScrollView } from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useRef } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed/ThemedText'; +import { ThemedView } from '@/components/themed/ThemedView'; +import { useColors } from '@/hooks/useColorScheme'; +import { TitleContainer } from '@/components/ui/TitleContainer'; +import { CollapsibleHeader } from '@/components/ui/CollapsibleHeader'; + +/** + * Security settings screen. + */ +export default function SecuritySettingsScreen() : React.ReactNode { + const colors = useColors(); + const insets = useSafeAreaInsets(); + const scrollY = useRef(new Animated.Value(0)).current; + const scrollViewRef = useRef(null); + + const styles = StyleSheet.create({ + container: { + flex: 1, + paddingBottom: insets.bottom, + paddingHorizontal: 14, + paddingTop: insets.top, + }, + scrollContent: { + paddingBottom: 40, + paddingTop: 42, + }, + scrollView: { + flex: 1, + }, + section: { + backgroundColor: colors.accentBackground, + borderRadius: 10, + marginTop: 20, + overflow: 'hidden', + }, + separator: { + backgroundColor: colors.accentBorder, + height: StyleSheet.hairlineWidth, + marginLeft: 52, + }, + settingItem: { + alignItems: 'center', + backgroundColor: colors.accentBackground, + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 6, + }, + settingItemContent: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + paddingVertical: 10, + }, + settingItemIcon: { + alignItems: 'center', + height: 24, + justifyContent: 'center', + marginRight: 12, + width: 24, + }, + settingItemText: { + color: colors.text, + flex: 1, + fontSize: 16, + }, + }); + + return ( + + + + router.push('/settings/security/change-password')} + > + + + + + Change Master Password + + + + + + router.push('/settings/security/active-sessions')} + > + + + + + Active Sessions + + + + + + router.push('/settings/security/auth-logs')} + > + + + + + Recent Auth Logs + + + + + + router.push('/settings/security/delete-account')} + > + + + + + Delete Account + + + + + + + ); +} \ No newline at end of file diff --git a/apps/mobile-app/components/themed/ThemedButton.tsx b/apps/mobile-app/components/themed/ThemedButton.tsx new file mode 100644 index 000000000..f69ee6bac --- /dev/null +++ b/apps/mobile-app/components/themed/ThemedButton.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { TouchableOpacity, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native'; + +import { ThemedText } from '@/components/themed/ThemedText'; +import { useColors } from '@/hooks/useColorScheme'; + +type ThemedButtonProps = { + title: string; + onPress: () => void; + loading?: boolean; + disabled?: boolean; + style?: ViewStyle; + textStyle?: TextStyle; +}; + +/** + * Themed button component that matches the app's design system. + */ +export const ThemedButton: React.FC = ({ + title, + onPress, + loading = false, + disabled = false, + style, + textStyle, +}) => { + const colors = useColors(); + + const styles = StyleSheet.create({ + button: { + alignItems: 'center', + backgroundColor: colors.primary, + borderRadius: 6, + justifyContent: 'center', + padding: 12, + }, + buttonDisabled: { + backgroundColor: colors.textMuted, + opacity: 0.7, + }, + buttonText: { + color: colors.background, + fontSize: 16, + fontWeight: '600', + }, + loadingContainer: { + position: 'absolute', + }, + }); + + return ( + + + {title} + + {loading && ( + + )} + + ); +}; \ No newline at end of file diff --git a/apps/mobile-app/components/themed/ThemedTextInput.tsx b/apps/mobile-app/components/themed/ThemedTextInput.tsx new file mode 100644 index 000000000..efbe12b64 --- /dev/null +++ b/apps/mobile-app/components/themed/ThemedTextInput.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { TextInput, TextInputProps, StyleSheet, View, Text } from 'react-native'; +import { useColors } from '@/hooks/useColorScheme'; + +type ThemedTextInputProps = TextInputProps & { + error?: string; +}; + +/** + * Themed text input component that matches the app's design system. + */ +export const ThemedTextInput: React.FC = ({ error, style, ...props }) => { + const colors = useColors(); + + const styles = StyleSheet.create({ + errorText: { + color: 'red', + fontSize: 12, + marginTop: 4, + }, + input: { + color: colors.text, + fontSize: 16, + padding: 10, + }, + inputContainer: { + backgroundColor: colors.background, + borderColor: error ? 'red' : colors.accentBorder, + borderRadius: 6, + borderWidth: 1, + }, + }); + + return ( + + + + + {error && {error}} + + ); +}; \ No newline at end of file