mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 21:40:41 -04:00
Add explicit offline mode override button during app sync flow (#1267)
This commit is contained in:
committed by
Leendert de Borst
parent
836e33f821
commit
7cb7c02bb2
@@ -1,5 +1,5 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, StyleSheet } from 'react-native';
|
||||
|
||||
@@ -18,13 +18,90 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
|
||||
export default function Initialize() : React.ReactNode {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState('');
|
||||
const [showOfflineButton, setShowOfflineButton] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
const offlineButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const { initializeAuth } = useAuth();
|
||||
const { syncVault } = useVaultSync();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
|
||||
/**
|
||||
* Handle offline scenario - show alert with options to open local vault or retry sync.
|
||||
*/
|
||||
const handleOfflineFlow = useCallback((): void => {
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
setStatus(t('app.status.openingVaultReadOnly'));
|
||||
const { enabledAuthMethods } = await initializeAuth();
|
||||
|
||||
try {
|
||||
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
|
||||
|
||||
// No encrypted database
|
||||
if (!hasEncryptedDatabase) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// FaceID not enabled
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to unlock vault
|
||||
setStatus(t('app.status.unlockingVault'));
|
||||
const isUnlocked = await dbContext.unlockVault();
|
||||
|
||||
// Vault couldn't be unlocked
|
||||
if (!isUnlocked) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault successfully unlocked - proceed with decryption
|
||||
await new Promise(resolve => setTimeout(resolve, 750));
|
||||
setStatus(t('app.status.decryptingVault'));
|
||||
await new Promise(resolve => setTimeout(resolve, 750));
|
||||
|
||||
// Migrations pending
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - navigate to credentials
|
||||
router.replace('/(tabs)/credentials');
|
||||
} catch {
|
||||
router.replace('/unlock');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
// Re-trigger initialization
|
||||
hasInitialized.current = false;
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}, [dbContext, router, initializeAuth, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure this only runs once.
|
||||
if (hasInitialized.current) {
|
||||
@@ -100,6 +177,20 @@ export default function Initialize() : React.ReactNode {
|
||||
*/
|
||||
onStatus: (message) => {
|
||||
setStatus(message);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Show offline button after 2 seconds if we're checking vault updates
|
||||
if (message === t('vault.checkingVaultUpdates')) {
|
||||
offlineButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowOfflineButton(true);
|
||||
}, 2000) as NodeJS.Timeout;
|
||||
} else {
|
||||
setShowOfflineButton(false);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle successful vault sync and continue with vault unlock flow.
|
||||
@@ -111,33 +202,8 @@ export default function Initialize() : React.ReactNode {
|
||||
/**
|
||||
* Handle offline state and prompt user for action.
|
||||
*/
|
||||
onOffline: async () => {
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
setStatus(t('app.status.openingVaultReadOnly'));
|
||||
await handleVaultUnlock();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setStatus(t('app.status.retryingConnection'));
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
onOffline: () => {
|
||||
handleOfflineFlow();
|
||||
},
|
||||
/**
|
||||
* Handle error during vault sync.
|
||||
@@ -163,7 +229,28 @@ export default function Initialize() : React.ReactNode {
|
||||
};
|
||||
|
||||
initializeApp();
|
||||
}, [dbContext, syncVault, initializeAuth, webApi, router, t]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return (): void => {
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [dbContext, syncVault, initializeAuth, webApi, router, t, handleOfflineFlow]);
|
||||
|
||||
/**
|
||||
* Handle offline button press by calling the stored offline handler.
|
||||
*/
|
||||
const handleOfflinePress = (): void => {
|
||||
// Clear any existing timeout
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
setShowOfflineButton(false);
|
||||
|
||||
handleOfflineFlow();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -175,7 +262,13 @@ export default function Initialize() : React.ReactNode {
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
{status ? <LoadingIndicator status={status} /> : null}
|
||||
{status ? (
|
||||
<LoadingIndicator
|
||||
status={status}
|
||||
showOfflineButton={showOfflineButton}
|
||||
onOfflinePress={handleOfflinePress}
|
||||
/>
|
||||
) : null}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,117 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
const dbContext = useDb();
|
||||
const { syncVault } = useVaultSync();
|
||||
const [status, setStatus] = useState('');
|
||||
const [showOfflineButton, setShowOfflineButton] = useState(false);
|
||||
const hasInitialized = useRef(false);
|
||||
const offlineButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Handle offline scenario - show alert with options to open local vault or retry sync.
|
||||
*/
|
||||
const handleOfflineFlow = (): void => {
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
setStatus(t('app.status.openingVaultReadOnly'));
|
||||
const { enabledAuthMethods } = await authContext.initializeAuth();
|
||||
|
||||
try {
|
||||
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
|
||||
|
||||
// Guard clause: No encrypted database
|
||||
if (!hasEncryptedDatabase) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard clause: FaceID not enabled
|
||||
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
|
||||
if (!isFaceIDEnabled) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to unlock vault
|
||||
setStatus(t('app.status.unlockingVault'));
|
||||
const isUnlocked = await dbContext.unlockVault();
|
||||
|
||||
// Guard clause: Vault couldn't be unlocked
|
||||
if (!isUnlocked) {
|
||||
router.replace('/unlock');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vault successfully unlocked - proceed with decryption
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setStatus(t('app.status.decryptingVault'));
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Guard clause: Migrations pending
|
||||
if (await dbContext.hasPendingMigrations()) {
|
||||
router.replace('/upgrade');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation based on return URL
|
||||
if (!authContext.returnUrl?.path) {
|
||||
router.replace('/(tabs)/credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to return URL
|
||||
const path = authContext.returnUrl.path as string;
|
||||
const isDetailRoute = path.includes('credentials/');
|
||||
|
||||
if (!isDetailRoute) {
|
||||
router.replace({
|
||||
pathname: path as '/',
|
||||
params: authContext.returnUrl.params as Record<string, string>
|
||||
});
|
||||
authContext.setReturnUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle detail routes
|
||||
const params = authContext.returnUrl.params as Record<string, string>;
|
||||
router.replace('/(tabs)/credentials');
|
||||
setTimeout(() => {
|
||||
if (params.serviceUrl) {
|
||||
router.push(path + '?serviceUrl=' + params.serviceUrl);
|
||||
} else if (params.id) {
|
||||
router.push(path + '?id=' + params.id);
|
||||
} else {
|
||||
router.push(path);
|
||||
}
|
||||
}, 0);
|
||||
authContext.setReturnUrl(null);
|
||||
} catch {
|
||||
router.replace('/unlock');
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
// Re-trigger initialization
|
||||
hasInitialized.current = false;
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasInitialized.current) {
|
||||
return;
|
||||
@@ -141,6 +248,20 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
*/
|
||||
onStatus: (message) => {
|
||||
setStatus(message);
|
||||
|
||||
// Clear any existing timeout
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Show offline button after 2 seconds if we're checking vault updates
|
||||
if (message === t('vault.checkingVaultUpdates')) {
|
||||
offlineButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowOfflineButton(true);
|
||||
}, 2000) as NodeJS.Timeout;
|
||||
} else {
|
||||
setShowOfflineButton(false);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle successful vault sync and continue with vault unlock flow.
|
||||
@@ -152,32 +273,7 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
* Handle offline state and prompt user for action.
|
||||
*/
|
||||
onOffline: () => {
|
||||
Alert.alert(
|
||||
t('app.alerts.syncIssue'),
|
||||
t('app.alerts.syncIssueMessage'),
|
||||
[
|
||||
{
|
||||
text: t('app.alerts.openLocalVault'),
|
||||
/**
|
||||
* Handle opening vault in read-only mode.
|
||||
*/
|
||||
onPress: async () : Promise<void> => {
|
||||
setStatus(t('app.status.openingVaultReadOnly'));
|
||||
await handleVaultUnlock();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: t('app.alerts.retrySync'),
|
||||
/**
|
||||
* Handle retrying the connection.
|
||||
*/
|
||||
onPress: () : void => {
|
||||
setStatus(t('app.status.retryingConnection'));
|
||||
initialize();
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
handleOfflineFlow();
|
||||
},
|
||||
/**
|
||||
* On upgrade required.
|
||||
@@ -189,7 +285,28 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
};
|
||||
|
||||
initialize();
|
||||
}, [syncVault, authContext, dbContext, t]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
return (): void => {
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [syncVault, authContext, dbContext, t, handleOfflineFlow]);
|
||||
|
||||
/**
|
||||
* Handle offline button press by calling the stored offline handler.
|
||||
*/
|
||||
const handleOfflinePress = (): void => {
|
||||
// Clear any existing timeout
|
||||
if (offlineButtonTimeoutRef.current) {
|
||||
clearTimeout(offlineButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
setShowOfflineButton(false);
|
||||
|
||||
handleOfflineFlow();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -216,7 +333,13 @@ export default function ReinitializeScreen() : React.ReactNode {
|
||||
<View style={styles.messageContainer}>
|
||||
<ThemedText style={styles.message1}>{t('app.reinitialize.vaultAutoLockedMessage')}</ThemedText>
|
||||
<ThemedText style={styles.message2}>{t('app.reinitialize.attemptingToUnlockMessage')}</ThemedText>
|
||||
{status ? <LoadingIndicator status={status} /> : null}
|
||||
{status ? (
|
||||
<LoadingIndicator
|
||||
status={status}
|
||||
showOfflineButton={showOfflineButton}
|
||||
onOfflinePress={handleOfflinePress}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, View, Text, Animated, useColorScheme } from 'react-native';
|
||||
import { StyleSheet, View, Text, Animated, useColorScheme, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type LoadingIndicatorProps = {
|
||||
status: string;
|
||||
showOfflineButton?: boolean;
|
||||
onOfflinePress?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading indicator component.
|
||||
*/
|
||||
export default function LoadingIndicator({ status }: LoadingIndicatorProps): React.ReactNode {
|
||||
export default function LoadingIndicator({ status, showOfflineButton, onOfflinePress }: LoadingIndicatorProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const dot1Anim = useRef(new Animated.Value(0)).current;
|
||||
const dot2Anim = useRef(new Animated.Value(0)).current;
|
||||
@@ -100,6 +103,24 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
closeIconContainer: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
top: 60,
|
||||
zIndex: 10,
|
||||
},
|
||||
closeIcon: {
|
||||
padding: 12,
|
||||
borderRadius: 32,
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.accentBorder,
|
||||
},
|
||||
contentContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dot: {
|
||||
backgroundColor: colors.tertiary,
|
||||
@@ -135,7 +156,15 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.dotsContainer}>
|
||||
{showOfflineButton && (
|
||||
<View style={styles.closeIconContainer}>
|
||||
<TouchableOpacity style={styles.closeIcon} onPress={onOfflinePress}>
|
||||
<Ionicons name="close" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.dotsContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dot,
|
||||
@@ -181,10 +210,11 @@ export default function LoadingIndicator({ status }: LoadingIndicatorProps): Rea
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.statusText}>
|
||||
{statusTrimmed}
|
||||
{shouldShowDots && dots}
|
||||
</Text>
|
||||
<Text style={styles.statusText}>
|
||||
{statusTrimmed}
|
||||
{shouldShowDots && dots}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,11 +433,11 @@
|
||||
"VAULT_ERROR": "The local vault is not up-to-date. Please synchronize your vault by refreshing the page and try again."
|
||||
},
|
||||
"app": {
|
||||
"openReadOnlyMode": "Open in read-only mode",
|
||||
"status": {
|
||||
"unlockingVault": "Unlocking vault",
|
||||
"decryptingVault": "Decrypting vault",
|
||||
"openingVaultReadOnly": "Opening vault in read-only mode",
|
||||
"retryingConnection": "Retrying connection..."
|
||||
"openingVaultReadOnly": "Opening vault in read-only mode"
|
||||
},
|
||||
"offline": {
|
||||
"banner": "Offline mode (read-only)",
|
||||
|
||||
Reference in New Issue
Block a user