diff --git a/mobile-app/app/(tabs)/(credentials)/index.tsx b/mobile-app/app/(tabs)/(credentials)/index.tsx
index 0a4a3c9bf..248904972 100644
--- a/mobile-app/app/(tabs)/(credentials)/index.tsx
+++ b/mobile-app/app/(tabs)/(credentials)/index.tsx
@@ -13,6 +13,7 @@ import { useColors } from '@/hooks/useColorScheme';
import { CredentialCard } from '@/components/CredentialCard';
import { TitleContainer } from '@/components/TitleContainer';
import emitter from '@/utils/EventEmitter';
+import Toast from 'react-native-toast-message';
export default function CredentialsScreen() {
const [searchQuery, setSearchQuery] = useState('');
@@ -82,8 +83,7 @@ export default function CredentialsScreen() {
// Sync vault and load credentials
await syncVault({
- forceCheck: true,
- onSuccess: async () => {
+ onSuccess: async (hasNewVault) => {
// Calculate remaining time needed to reach minimum duration
const elapsedTime = Date.now() - startTime;
const remainingDelay = Math.max(0, 350 - elapsedTime);
@@ -95,14 +95,31 @@ export default function CredentialsScreen() {
await loadCredentials();
setRefreshing(false);
+ Toast.show({
+ type: 'success',
+ text1: hasNewVault ? 'Vault synced successfully' : 'Vault is up-to-date',
+ position: 'top',
+ visibilityTime: 1200,
+ });
},
onError: (error) => {
console.error('Error syncing vault:', error);
- }
+ setRefreshing(false);
+ Toast.show({
+ type: 'error',
+ text1: 'Vault sync failed',
+ text2: error,
+ });
+ },
});
} catch (err) {
console.error('Error refreshing credentials:', err);
setRefreshing(false);
+ Toast.show({
+ type: 'error',
+ text1: 'Vault sync failed',
+ text2: err instanceof Error ? err.message : 'Unknown error',
+ });
}
}, [syncVault, loadCredentials]);
diff --git a/mobile-app/app/_layout.tsx b/mobile-app/app/_layout.tsx
index e58c4214d..75d6ef8ab 100644
--- a/mobile-app/app/_layout.tsx
+++ b/mobile-app/app/_layout.tsx
@@ -30,6 +30,60 @@ const toastConfig = {
borderRadius: 8,
marginHorizontal: 16,
marginBottom: 70,
+ marginTop: 30,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 2,
+ },
+ shadowOpacity: 0.25,
+ shadowRadius: 3.84,
+ elevation: 5,
+ }}
+ >
+
+ {props.text1}
+
+
+ ),
+ error: (props: any) => (
+
+
+ {props.text1}
+
+ {props.text2 && (
+
+ {props.text2}
+
+ )}
+
+ ),
+ info: (props: any) => (
+ {
+ setStatus(message);
+ }
+ });
// Navigate to appropriate screen
if (requireLoginOrUnlock) {
@@ -42,7 +49,7 @@ export default function InitialLoadingScreen() {
return (
-
+
);
}
\ No newline at end of file
diff --git a/mobile-app/app/login.tsx b/mobile-app/app/login.tsx
index ea53058e4..3ec3c31dc 100644
--- a/mobile-app/app/login.tsx
+++ b/mobile-app/app/login.tsx
@@ -1,3 +1,4 @@
+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';
@@ -15,6 +16,7 @@ import { useWebApi } from '@/context/WebApiContext';
import { useColors } from '@/hooks/useColorScheme';
import Logo from '@/assets/images/logo.svg';
import { AppInfo } from '@/utils/AppInfo';
+import LoadingIndicator from '@/components/LoadingIndicator';
export default function LoginScreen() {
@@ -196,6 +198,7 @@ export default function LoginScreen() {
const [loginResponse, setLoginResponse] = useState(null);
const [passwordHashString, setPasswordHashString] = useState(null);
const [passwordHashBase64, setPasswordHashBase64] = useState(null);
+ const [loginStatus, setLoginStatus] = useState(null);
const authContext = useAuth();
const dbContext = useDb();
@@ -207,6 +210,8 @@ export default function LoginScreen() {
setIsLoading(true);
setError(null);
+ setLoginStatus('Logging in');
+
try {
console.log('handleSubmit');
authContext.clearGlobalMessage();
@@ -229,6 +234,7 @@ export default function LoginScreen() {
console.log('passwordHashString', passwordHashString);
+ setLoginStatus('Validating credentials');
const validationResponse = await srpUtil.validateLogin(
credentials.username,
passwordHashString,
@@ -244,6 +250,7 @@ export default function LoginScreen() {
setPasswordHashBase64(passwordHashBase64);
setTwoFactorRequired(true);
setIsLoading(false);
+ setLoginStatus(null);
return;
}
@@ -253,6 +260,7 @@ export default function LoginScreen() {
console.log('validationResponse.token', validationResponse.token);
+ setLoginStatus('Syncing vault');
const vaultResponseJson = await webApi.authFetch('Vault', { method: 'GET', headers: {
'Authorization': `Bearer ${validationResponse.token.token}`
} });
@@ -264,6 +272,7 @@ export default function LoginScreen() {
console.error('vaultError', vaultError);
setError(vaultError);
setIsLoading(false);
+ setLoginStatus(null);
return;
}
@@ -277,6 +286,7 @@ export default function LoginScreen() {
router.replace('/(tabs)');
setIsLoading(false);
+ setLoginStatus(null);
} catch (err) {
if (err instanceof ApiAuthError) {
console.error('ApiAuthError error:', err);
@@ -286,6 +296,7 @@ export default function LoginScreen() {
setError('Could not reach AliasVault server. Please try again later or contact support if the problem persists.');
}
setIsLoading(false);
+ setLoginStatus(null);
}
};
@@ -360,118 +371,124 @@ export default function LoginScreen() {
-
- Log in
-
- Connecting to{' '}
- router.push('/settings')}
- >
- {getDisplayUrl()}
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {twoFactorRequired ? (
-
- Authentication Code
-
-
-
- {isLoading ? (
-
- ) : (
- Verify
- )}
-
- {
- setCredentials({ username: '', password: '' });
- setTwoFactorRequired(false);
- setTwoFactorCode('');
- setPasswordHashString(null);
- setPasswordHashBase64(null);
- setLoginResponse(null);
- setError(null);
- }}
- >
- 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.
-
-
+ {isLoading ? (
+
) : (
-
- Username or email
- setCredentials({ ...credentials, username: text })}
- placeholder="name / name@company.com"
- autoCapitalize="none"
- placeholderTextColor={colors.textMuted}
- />
- Password
- setCredentials({ ...credentials, password: text })}
- placeholder="Enter your password"
- secureTextEntry
- placeholderTextColor={colors.textMuted}
- />
-
- setRememberMe(!rememberMe)}
- >
-
-
- Remember me
-
-
- {isLoading ? (
-
- ) : (
- Login
- )}
-
-
- No account yet?{' '}
- Linking.openURL('https://app.aliasvault.net')}
- >
- Create new vault
+ <>
+
+ Log in
+
+ Connecting to{' '}
+ router.push('/settings')}
+ >
+ {getDisplayUrl()}
+
-
-
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {twoFactorRequired ? (
+
+ Authentication Code
+
+
+
+ {isLoading ? (
+
+ ) : (
+ Verify
+ )}
+
+ {
+ setCredentials({ username: '', password: '' });
+ setTwoFactorRequired(false);
+ setTwoFactorCode('');
+ setPasswordHashString(null);
+ setPasswordHashBase64(null);
+ setLoginResponse(null);
+ setError(null);
+ }}
+ >
+ 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.
+
+
+ ) : (
+
+ Username or email
+ setCredentials({ ...credentials, username: text })}
+ placeholder="name / name@company.com"
+ autoCapitalize="none"
+ placeholderTextColor={colors.textMuted}
+ />
+ Password
+ setCredentials({ ...credentials, password: text })}
+ placeholder="Enter your password"
+ secureTextEntry
+ placeholderTextColor={colors.textMuted}
+ />
+
+ setRememberMe(!rememberMe)}
+ >
+
+
+ Remember me
+
+
+ {isLoading ? (
+
+ ) : (
+ Login
+ )}
+
+
+ No account yet?{' '}
+ Linking.openURL('https://app.aliasvault.net')}
+ >
+ Create new vault
+
+
+
+ )}
+ >
)}
diff --git a/mobile-app/components/LoadingIndicator.tsx b/mobile-app/components/LoadingIndicator.tsx
new file mode 100644
index 000000000..03b39f653
--- /dev/null
+++ b/mobile-app/components/LoadingIndicator.tsx
@@ -0,0 +1,156 @@
+import { StyleSheet, View, Text, Animated } from 'react-native';
+import { useEffect, useRef, useState } from 'react';
+import { useColors } from '@/hooks/useColorScheme';
+
+interface LoadingIndicatorProps {
+ status: string;
+}
+
+export default function LoadingIndicator({ status }: LoadingIndicatorProps) {
+ const colors = useColors();
+ const dot1Anim = useRef(new Animated.Value(0)).current;
+ const dot2Anim = useRef(new Animated.Value(0)).current;
+ const dot3Anim = useRef(new Animated.Value(0)).current;
+ const dot4Anim = useRef(new Animated.Value(0)).current;
+ const [dots, setDots] = useState('');
+
+ useEffect(() => {
+ const createPulseAnimation = (anim: Animated.Value) => {
+ return Animated.sequence([
+ Animated.timing(anim, {
+ toValue: 1,
+ duration: 700,
+ useNativeDriver: true,
+ }),
+ Animated.timing(anim, {
+ toValue: 0,
+ duration: 700,
+ useNativeDriver: true,
+ }),
+ ]);
+ };
+
+ const animation = Animated.loop(
+ Animated.parallel([
+ createPulseAnimation(dot1Anim),
+ Animated.sequence([
+ Animated.delay(200),
+ createPulseAnimation(dot2Anim),
+ ]),
+ Animated.sequence([
+ Animated.delay(400),
+ createPulseAnimation(dot3Anim),
+ ]),
+ Animated.sequence([
+ Animated.delay(600),
+ createPulseAnimation(dot4Anim),
+ ]),
+ ])
+ );
+
+ const dotsInterval = setInterval(() => {
+ setDots(prevDots => {
+ if (prevDots.length >= 3) return '';
+ return prevDots + '.';
+ });
+ }, 400);
+
+ animation.start();
+
+ return () => {
+ animation.stop();
+ clearInterval(dotsInterval);
+ };
+ }, []);
+
+ const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ dotsContainer: {
+ flexDirection: 'row',
+ backgroundColor: 'transparent',
+ borderRadius: 20,
+ padding: 12,
+ paddingHorizontal: 24,
+ gap: 10,
+ borderWidth: 5,
+ borderColor: colors.tertiary,
+ shadowColor: '#000',
+ shadowOffset: {
+ width: 0,
+ height: 1,
+ },
+ shadowOpacity: 0.05,
+ shadowRadius: 2,
+ elevation: 1,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: colors.tertiary,
+ },
+ statusText: {
+ marginTop: 16,
+ fontSize: 16,
+ color: colors.text,
+ textAlign: 'center',
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+ {status}{dots}
+
+ );
+}
\ No newline at end of file
diff --git a/mobile-app/constants/Colors.ts b/mobile-app/constants/Colors.ts
index 15707f264..3152b0c1e 100644
--- a/mobile-app/constants/Colors.ts
+++ b/mobile-app/constants/Colors.ts
@@ -19,8 +19,9 @@ export const Colors = {
tabIconSelected: '#f49541',
headerBackground: '#fff',
tabBarBackground: '#fff',
- primary: '#f97316',
+ primary: '#f49541',
secondary: '#6b7280',
+ tertiary: '#eabf69',
loginHeader: '#f6dfc4',
},
dark: {
@@ -38,8 +39,9 @@ export const Colors = {
tabIconSelected: '#f49541',
headerBackground: '#1f2937',
tabBarBackground: '#1f2937',
- primary: '#f97316',
+ primary: '#f49541',
secondary: '#6b7280',
+ tertiary: '#eabf69',
loginHeader: '#5c4331',
},
} as const;
diff --git a/mobile-app/hooks/useVaultSync.ts b/mobile-app/hooks/useVaultSync.ts
index c1f5b2fea..67687fc30 100644
--- a/mobile-app/hooks/useVaultSync.ts
+++ b/mobile-app/hooks/useVaultSync.ts
@@ -5,10 +5,32 @@ import { useDb } from '@/context/DbContext';
import { useWebApi } from '@/context/WebApiContext';
import { VaultResponse } from '@/utils/types/webapi/VaultResponse';
+// Utility function to ensure a minimum time has elapsed for an operation
+const withMinimumDelay = async (
+ operation: () => Promise,
+ minDelayMs: number,
+ initialSync: boolean
+): Promise => {
+ if (!initialSync) {
+ return operation();
+ }
+
+ const startTime = Date.now();
+ const result = await operation();
+ const elapsedTime = Date.now() - startTime;
+
+ if (elapsedTime < minDelayMs) {
+ await new Promise(resolve => setTimeout(resolve, minDelayMs - elapsedTime));
+ }
+
+ return result;
+};
+
interface VaultSyncOptions {
- forceCheck?: boolean;
- onSuccess?: () => void;
+ initialSync?: boolean;
+ onSuccess?: (hasNewVault: boolean) => void;
onError?: (error: string) => void;
+ onStatus?: (message: string) => void;
}
export const useVaultSync = () => {
@@ -17,8 +39,8 @@ export const useVaultSync = () => {
const webApi = useWebApi();
const syncVault = useCallback(async (options: VaultSyncOptions = {}) => {
- const { forceCheck = false, onSuccess, onError } = options;
- console.log('syncVault called with forceCheck:', forceCheck);
+ const { initialSync = false, onSuccess, onError, onStatus } = options;
+ console.log('syncVault called with initialSync:', initialSync);
try {
const isLoggedIn = await authContext.initializeAuth();
@@ -28,31 +50,23 @@ export const useVaultSync = () => {
return false;
}
- // If not forcing a check, verify the time elapsed since last check
- if (!forceCheck) {
- const lastCheckStr = await AsyncStorage.getItem('lastVaultCheck');
- const lastCheck = lastCheckStr ? parseInt(lastCheckStr, 10) : 0;
- const now = Date.now();
-
- // Only check if more than 1 hour has passed since last check
- if (now - lastCheck < 3600000) {
- console.log('Vault sync skipped: Not enough time has passed since last check');
- return false;
- }
- }
-
console.log('Checking vault updates');
// Update last check time
await AsyncStorage.setItem('lastVaultCheck', Date.now().toString());
// Check app status and vault revision
- const statusResponse = await webApi.getStatus();
+ onStatus?.('Checking vault updates');
+ const statusResponse = await withMinimumDelay(
+ () => webApi.getStatus(),
+ 700,
+ initialSync
+ );
const statusError = webApi.validateStatusResponse(statusResponse);
if (statusError !== null) {
console.log('Vault sync error:', statusError);
await webApi.logout(statusError);
- onError?.(statusErrorr);
+ onError?.(statusError);
return false;
}
@@ -63,7 +77,12 @@ export const useVaultSync = () => {
console.log('Vault revision local:', vaultRevisionNumber);
console.log('Vault revision server:', statusResponse.vaultRevision);
if (statusResponse.vaultRevision > vaultRevisionNumber) {
- const vaultResponseJson = await webApi.get('Vault');
+ onStatus?.('Syncing updated vault');
+ const vaultResponseJson = await withMinimumDelay(
+ () => webApi.get('Vault'),
+ 1000,
+ initialSync
+ );
const vaultError = webApi.validateVaultResponse(vaultResponseJson as VaultResponse);
if (vaultError) {
@@ -74,13 +93,15 @@ export const useVaultSync = () => {
}
console.log('Re-initializing database with new vault');
- await dbContext.initializeDatabase(vaultResponseJson as VaultResponse, null);
- onSuccess?.();
+ dbContext.initializeDatabase(vaultResponseJson as VaultResponse, null);
+ onSuccess?.(true);
return true;
}
console.log('Vault sync finished: No updates needed');
- onSuccess?.();
+ onStatus?.('Decrypting vault');
+ await new Promise(resolve => setTimeout(resolve, 300));
+ onSuccess?.(false);
return false;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error during vault sync';