Add account creation scaffolding (#962)
This commit is contained in:
@@ -203,6 +203,39 @@ export default function CredentialsScreen() : React.ReactNode {
|
||||
loadCredentials();
|
||||
}, [isAuthenticated, isDatabaseAvailable, loadCredentials, setIsLoadingCredentials]);
|
||||
|
||||
/**
|
||||
* Check if tutorial should be shown (no credentials and tutorial not done)
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Check tutorial status and show welcome screen if needed
|
||||
*/
|
||||
const checkTutorialStatus = async (): Promise<void> => {
|
||||
if (!isAuthenticated || !isDatabaseAvailable || isLoadingCredentials) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if user has any credentials
|
||||
const hasCredentials = credentialsList.length > 0;
|
||||
|
||||
if (!hasCredentials) {
|
||||
// Check if tutorial has been completed
|
||||
const tutorialDone = await dbContext.sqliteClient?.getSetting('TutorialDone', 'false');
|
||||
if (tutorialDone.toLowerCase() !== 'true') {
|
||||
// Show tutorial
|
||||
router.replace('/welcome');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking tutorial status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
checkTutorialStatus();
|
||||
}, [isAuthenticated, isDatabaseAvailable, isLoadingCredentials, credentialsList.length, dbContext.sqliteClient, router]);
|
||||
|
||||
const filteredCredentials = credentialsList.filter(credential => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useVaultSync } from '@/hooks/useVaultSync';
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import { InAppBrowserView } from '@/components/ui/InAppBrowserView';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
@@ -688,11 +687,12 @@ export default function LoginScreen() : React.ReactNode {
|
||||
</TouchableOpacity>
|
||||
<View style={styles.createNewVaultContainer}>
|
||||
<Text style={styles.textMuted}>No account yet? </Text>
|
||||
<InAppBrowserView
|
||||
url="https://app.aliasvault.net/user/setup"
|
||||
title="Create new vault"
|
||||
textStyle={styles.clickableLink}
|
||||
/>
|
||||
<Text
|
||||
style={styles.clickableLink}
|
||||
onPress={() => router.push('/setup')}
|
||||
>
|
||||
Create new vault
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Dimensions,
|
||||
BackHandler,
|
||||
Alert
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import CreatingStep from '@/components/setup/CreatingStep';
|
||||
import PasswordStep from '@/components/setup/PasswordStep';
|
||||
import TermsAndConditionsStep from '@/components/setup/TermsAndConditionsStep';
|
||||
import UsernameStep from '@/components/setup/UsernameStep';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
|
||||
enum SetupStep {
|
||||
TermsAndConditions = 0,
|
||||
Username = 1,
|
||||
Password = 2,
|
||||
Creating = 3,
|
||||
}
|
||||
|
||||
type SetupData = {
|
||||
agreedToTerms: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account setup/registration screen.
|
||||
* Provides a 4-step wizard for creating new AliasVault accounts.
|
||||
*/
|
||||
export default function SetupScreen(): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [currentStep, setCurrentStep] = useState<SetupStep>(SetupStep.TermsAndConditions);
|
||||
const [setupData, setSetupData] = useState<SetupData>({
|
||||
agreedToTerms: false,
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const totalSteps = 4;
|
||||
const progressPercentage = ((currentStep + 1) / totalSteps) * 100;
|
||||
|
||||
/**
|
||||
* Handle back button press
|
||||
*/
|
||||
const handleBackPress = (): boolean => {
|
||||
if (currentStep === SetupStep.TermsAndConditions) {
|
||||
// On first step, show confirmation to exit
|
||||
Alert.alert(
|
||||
'Exit Setup',
|
||||
'Are you sure you want to exit the account creation process?',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Exit',
|
||||
style: 'destructive',
|
||||
onPress: (): void => router.replace('/login')
|
||||
}
|
||||
]
|
||||
);
|
||||
return true;
|
||||
} else if (currentStep === SetupStep.Creating) {
|
||||
// Don't allow back during account creation
|
||||
return true;
|
||||
} else {
|
||||
// Go back to previous step
|
||||
setCurrentStep(currentStep - 1);
|
||||
setError(null);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle navigation to next step
|
||||
*/
|
||||
const handleNext = (): void => {
|
||||
setError(null);
|
||||
|
||||
if (currentStep < SetupStep.Creating) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update setup data
|
||||
*/
|
||||
const updateSetupData = (updates: Partial<SetupData>): void => {
|
||||
setSetupData(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle setup completion (redirect to tutorial)
|
||||
*/
|
||||
const handleSetupComplete = (): void => {
|
||||
// Navigate to credentials screen which will show tutorial if needed
|
||||
router.replace('/(tabs)/credentials');
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the current step component
|
||||
*/
|
||||
const renderCurrentStep = (): React.ReactNode => {
|
||||
switch (currentStep) {
|
||||
case SetupStep.TermsAndConditions:
|
||||
return (
|
||||
<TermsAndConditionsStep
|
||||
agreedToTerms={setupData.agreedToTerms}
|
||||
onAgreementChange={(agreed) => updateSetupData({ agreedToTerms: agreed })}
|
||||
onNext={handleNext}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
case SetupStep.Username:
|
||||
return (
|
||||
<UsernameStep
|
||||
username={setupData.username}
|
||||
onUsernameChange={(username) => updateSetupData({ username })}
|
||||
onNext={handleNext}
|
||||
error={error}
|
||||
setError={setError}
|
||||
/>
|
||||
);
|
||||
case SetupStep.Password:
|
||||
return (
|
||||
<PasswordStep
|
||||
password={setupData.password}
|
||||
confirmPassword={setupData.confirmPassword}
|
||||
onPasswordChange={(password) => updateSetupData({ password })}
|
||||
onConfirmPasswordChange={(confirmPassword) => updateSetupData({ confirmPassword })}
|
||||
onNext={handleNext}
|
||||
error={error}
|
||||
setError={setError}
|
||||
/>
|
||||
);
|
||||
case SetupStep.Creating:
|
||||
return (
|
||||
<CreatingStep
|
||||
setupData={setupData}
|
||||
onComplete={handleSetupComplete}
|
||||
error={error}
|
||||
setError={setError}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appName: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.secondary,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
},
|
||||
backButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
gradientContainer: {
|
||||
height: Dimensions.get('window').height * 0.3,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
headerSection: {
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
navigationContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
},
|
||||
progressBar: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 4,
|
||||
height: 8,
|
||||
width: '100%',
|
||||
},
|
||||
progressContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
progressFill: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 4,
|
||||
height: '100%',
|
||||
},
|
||||
progressText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
spacer: {
|
||||
flex: 1,
|
||||
},
|
||||
stepContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Set up hardware back button handler for Android
|
||||
*/
|
||||
React.useEffect((): (() => void) => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
||||
return () => backHandler.remove();
|
||||
}, [currentStep, handleBackPress]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[colors.loginHeader, colors.background]}
|
||||
style={styles.gradientContainer}
|
||||
/>
|
||||
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Logo width={60} height={60} />
|
||||
<Text style={styles.appName}>AliasVault</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ThemedView style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.progressText}>
|
||||
Step {currentStep + 1} of {totalSteps}
|
||||
</Text>
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${progressPercentage}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepContainer}>
|
||||
{renderCurrentStep()}
|
||||
</View>
|
||||
|
||||
{currentStep > SetupStep.TermsAndConditions && currentStep < SetupStep.Creating && (
|
||||
<View style={styles.navigationContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBackPress}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={20} color={colors.primarySurfaceText} />
|
||||
<Text style={styles.backButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.spacer} />
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
SafeAreaView,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Dimensions,
|
||||
BackHandler
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
import Logo from '@/assets/images/logo.svg';
|
||||
import { ThemedView } from '@/components/themed/ThemedView';
|
||||
import WelcomeStep from '@/components/tutorial/WelcomeStep';
|
||||
import HowItWorksStep from '@/components/tutorial/HowItWorksStep';
|
||||
import TipsStep from '@/components/tutorial/TipsStep';
|
||||
import CreateFirstIdentityStep from '@/components/tutorial/CreateFirstIdentityStep';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
|
||||
enum TutorialStep {
|
||||
Welcome = 0,
|
||||
HowAliasVaultWorks = 1,
|
||||
Tips = 2,
|
||||
CreateFirstIdentity = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Welcome/Tutorial screen.
|
||||
* Shown after successful account creation to guide new users.
|
||||
*/
|
||||
export default function WelcomeScreen(): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const dbContext = useDb();
|
||||
const [currentStep, setCurrentStep] = useState<TutorialStep>(TutorialStep.Welcome);
|
||||
|
||||
const totalSteps = 4;
|
||||
const progressPercentage = ((currentStep + 1) / totalSteps) * 100;
|
||||
|
||||
/**
|
||||
* Handle navigation to next step
|
||||
*/
|
||||
const handleNext = (): void => {
|
||||
if (currentStep < TutorialStep.CreateFirstIdentity) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle navigation to previous step
|
||||
*/
|
||||
const handleBack = (): void => {
|
||||
if (currentStep > TutorialStep.Welcome) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Finish the tutorial and mark it as completed
|
||||
*/
|
||||
const finishTutorial = async (): Promise<void> => {
|
||||
try {
|
||||
// Mark tutorial as done in database
|
||||
if (dbContext.sqliteClient) {
|
||||
await dbContext.sqliteClient.setSetting('TutorialDone', 'true');
|
||||
}
|
||||
|
||||
// Navigate to credentials screen
|
||||
router.replace('/(tabs)/credentials');
|
||||
} catch (error) {
|
||||
console.error('Failed to finish tutorial:', error);
|
||||
// Navigate anyway
|
||||
router.replace('/(tabs)/credentials');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip tutorial and go directly to credentials
|
||||
*/
|
||||
const skipTutorial = async (): Promise<void> => {
|
||||
await finishTutorial();
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the current step component
|
||||
*/
|
||||
const renderCurrentStep = (): React.ReactNode => {
|
||||
switch (currentStep) {
|
||||
case TutorialStep.Welcome:
|
||||
return (
|
||||
<WelcomeStep
|
||||
onNext={handleNext}
|
||||
onSkip={skipTutorial}
|
||||
/>
|
||||
);
|
||||
case TutorialStep.HowAliasVaultWorks:
|
||||
return (
|
||||
<HowItWorksStep
|
||||
onNext={handleNext}
|
||||
onSkip={skipTutorial}
|
||||
/>
|
||||
);
|
||||
case TutorialStep.Tips:
|
||||
return (
|
||||
<TipsStep
|
||||
onNext={handleNext}
|
||||
onSkip={skipTutorial}
|
||||
/>
|
||||
);
|
||||
case TutorialStep.CreateFirstIdentity:
|
||||
return (
|
||||
<CreateFirstIdentityStep
|
||||
onGetStarted={finishTutorial}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
appName: {
|
||||
color: colors.text,
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
backButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.secondary,
|
||||
borderRadius: 8,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
},
|
||||
backButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: colors.background,
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
},
|
||||
gradientContainer: {
|
||||
height: Dimensions.get('window').height * 0.3,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
headerSection: {
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
paddingBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 24,
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
navigationContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
},
|
||||
progressBar: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 4,
|
||||
height: 8,
|
||||
width: '100%',
|
||||
},
|
||||
progressContainer: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
progressFill: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 4,
|
||||
height: '100%',
|
||||
},
|
||||
progressText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
stepContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Disable hardware back button on Android
|
||||
React.useEffect(() => {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => true);
|
||||
return () => backHandler.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[colors.loginHeader, colors.background]}
|
||||
style={styles.gradientContainer}
|
||||
/>
|
||||
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Logo width={60} height={60} />
|
||||
<Text style={styles.appName}>AliasVault</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ThemedView style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<Text style={styles.progressText}>
|
||||
Step {currentStep + 1} of {totalSteps}
|
||||
</Text>
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${progressPercentage}%` }
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.stepContainer}>
|
||||
{renderCurrentStep()}
|
||||
</View>
|
||||
|
||||
{currentStep > TutorialStep.Welcome && currentStep < TutorialStep.CreateFirstIdentity && (
|
||||
<View style={styles.navigationContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBack}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={20} color={colors.primarySurfaceText} />
|
||||
<Text style={styles.backButtonText}>Back</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Buffer } from 'buffer';
|
||||
import srp from 'secure-remote-password/client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useApiUrl } from '@/utils/ApiUrlUtility';
|
||||
import ConversionUtility from '@/utils/ConversionUtility';
|
||||
import type { EncryptionKeyDerivationParams } from '@/utils/dist/shared/models/metadata';
|
||||
import type { VaultResponse } from '@/utils/dist/shared/models/webapi';
|
||||
import EncryptionUtility from '@/utils/EncryptionUtility';
|
||||
import { ApiAuthError } from '@/utils/types/errors/ApiAuthError';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
import LoadingIndicator from '@/components/LoadingIndicator';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
|
||||
type SetupData = {
|
||||
agreedToTerms: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
type CreatingStepProps = {
|
||||
setupData: SetupData;
|
||||
onComplete: () => void;
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
type RegisterRequest = {
|
||||
username: string;
|
||||
salt: string;
|
||||
verifier: string;
|
||||
encryptionType: string;
|
||||
encryptionSettings: string;
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourth step of setup: Account creation process
|
||||
*/
|
||||
export default function CreatingStep({
|
||||
setupData,
|
||||
onComplete,
|
||||
error,
|
||||
setError
|
||||
}: CreatingStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const authContext = useAuth();
|
||||
const dbContext = useDb();
|
||||
const webApi = useWebApi();
|
||||
const { loadApiUrl } = useApiUrl();
|
||||
const [status, setStatus] = useState<string>('Preparing account creation...');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
/**
|
||||
* Create user account using SRP protocol and AliasVault registration API
|
||||
*/
|
||||
const createAccount = async (): Promise<void> => {
|
||||
if (isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await loadApiUrl();
|
||||
|
||||
setStatus('Generating encryption parameters...');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Step 1: Generate SRP salt
|
||||
const srpSalt = srp.generateSalt();
|
||||
|
||||
// Step 2: Set up encryption parameters (matching server defaults)
|
||||
const encryptionType = 'Argon2Id';
|
||||
const encryptionSettings = JSON.stringify({
|
||||
DegreeOfParallelism: 1,
|
||||
MemorySize: 19456,
|
||||
Iterations: 2
|
||||
});
|
||||
|
||||
setStatus('Deriving encryption key...');
|
||||
|
||||
// Step 3: Derive password hash using the same parameters the server would use
|
||||
const passwordHash = await EncryptionUtility.deriveKeyFromPassword(
|
||||
setupData.password,
|
||||
srpSalt,
|
||||
encryptionType,
|
||||
encryptionSettings
|
||||
);
|
||||
|
||||
const passwordHashString = Buffer.from(passwordHash).toString('hex').toUpperCase();
|
||||
const passwordHashBase64 = Buffer.from(passwordHash).toString('base64');
|
||||
|
||||
setStatus('Generating secure credentials...');
|
||||
|
||||
// Step 4: Generate SRP verifier
|
||||
const normalizedUsername = ConversionUtility.normalizeUsername(setupData.username);
|
||||
const privateKey = srp.derivePrivateKey(srpSalt, normalizedUsername, passwordHashString);
|
||||
const verifier = srp.deriveVerifier(privateKey);
|
||||
|
||||
setStatus('Creating account...');
|
||||
|
||||
// Step 5: Register user with server
|
||||
const registrationRequest: RegisterRequest = {
|
||||
username: normalizedUsername,
|
||||
salt: srpSalt,
|
||||
verifier: verifier,
|
||||
encryptionType: encryptionType,
|
||||
encryptionSettings: encryptionSettings,
|
||||
};
|
||||
|
||||
const response = await webApi.rawFetch('Auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(registrationRequest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const errorData = await response.json();
|
||||
throw new ApiAuthError(errorData.title || 'Registration failed');
|
||||
}
|
||||
throw new ApiAuthError('Registration failed. Please try again.');
|
||||
}
|
||||
|
||||
const tokenResponse = await response.json() as TokenResponse;
|
||||
|
||||
if (tokenResponse.token == null || tokenResponse.refreshToken == null) {
|
||||
throw new Error('Registration succeeded but no tokens returned');
|
||||
}
|
||||
|
||||
setStatus('Setting up your vault...');
|
||||
|
||||
// Step 6: Set up authentication and encryption
|
||||
const encryptionKeyDerivationParams: EncryptionKeyDerivationParams = {
|
||||
encryptionType: encryptionType,
|
||||
encryptionSettings: encryptionSettings,
|
||||
salt: srpSalt,
|
||||
};
|
||||
|
||||
// Store authentication tokens and encryption key
|
||||
await authContext.setAuthTokens(
|
||||
normalizedUsername,
|
||||
tokenResponse.token,
|
||||
tokenResponse.refreshToken
|
||||
);
|
||||
await dbContext.storeEncryptionKey(passwordHashBase64);
|
||||
await dbContext.storeEncryptionKeyDerivationParams(encryptionKeyDerivationParams);
|
||||
|
||||
setStatus('Initializing vault...');
|
||||
|
||||
// Step 7: Get vault from server and initialize local database
|
||||
const vaultResponse = await webApi.authFetch<VaultResponse>('Vault', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${tokenResponse.token}`
|
||||
}
|
||||
});
|
||||
|
||||
const vaultError = webApi.validateVaultResponse(vaultResponse);
|
||||
if (vaultError) {
|
||||
throw new Error(vaultError);
|
||||
}
|
||||
|
||||
await dbContext.initializeDatabase(vaultResponse);
|
||||
|
||||
setStatus('Finalizing setup...');
|
||||
|
||||
// Step 8: Complete authentication setup
|
||||
await authContext.login();
|
||||
authContext.setOfflineMode(false);
|
||||
|
||||
setStatus('Account created successfully!');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
onComplete();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Account creation error:', err);
|
||||
if (err instanceof ApiAuthError) {
|
||||
setError(err.message);
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('An error occurred during account creation. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Start account creation when component mounts
|
||||
useEffect((): void => {
|
||||
createAccount();
|
||||
}, [createAccount]);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 20,
|
||||
marginTop: 20,
|
||||
padding: 16,
|
||||
width: '100%',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Creating Your Account</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Please wait while we set up your secure AliasVault account. This may take a few moments.
|
||||
</Text>
|
||||
|
||||
<LoadingIndicator status={status} />
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type PasswordStepProps = {
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
onPasswordChange: (password: string) => void;
|
||||
onConfirmPasswordChange: (confirmPassword: string) => void;
|
||||
onNext: () => void;
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Third step of setup: Master password creation
|
||||
*/
|
||||
export default function PasswordStep({
|
||||
password,
|
||||
confirmPassword,
|
||||
onPasswordChange,
|
||||
onConfirmPasswordChange,
|
||||
onNext,
|
||||
error,
|
||||
setError
|
||||
}: PasswordStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
/**
|
||||
* Check if passwords match
|
||||
*/
|
||||
const passwordsMatch = password === confirmPassword;
|
||||
const hasMinLength = password.length >= 10;
|
||||
const isValidForm = hasMinLength && passwordsMatch && password.length > 0 && confirmPassword.length > 0;
|
||||
|
||||
/**
|
||||
* Handle password input change
|
||||
*/
|
||||
const handlePasswordChange = (text: string): void => {
|
||||
onPasswordChange(text);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle confirm password input change
|
||||
*/
|
||||
const handleConfirmPasswordChange = (text: string): void => {
|
||||
onConfirmPasswordChange(text);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle continue button press
|
||||
*/
|
||||
const handleContinue = async (): Promise<void> => {
|
||||
if (!isValidForm) {
|
||||
if (!hasMinLength) {
|
||||
setError('Password must be at least 10 characters long');
|
||||
} else if (!passwordsMatch) {
|
||||
setError('Passwords do not match');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Add small delay for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
setIsLoading(false);
|
||||
onNext();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
buttonEnabled: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonTextDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
buttonTextEnabled: {
|
||||
color: colors.primarySurfaceText,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
},
|
||||
eyeButton: {
|
||||
padding: 12,
|
||||
},
|
||||
input: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 50,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
width: '100%',
|
||||
},
|
||||
inputContainerError: {
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
inputContainerValid: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 12,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
passwordField: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
securityNote: {
|
||||
backgroundColor: colors.warningBackground,
|
||||
borderColor: colors.warningBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 20,
|
||||
padding: 12,
|
||||
},
|
||||
securityNoteText: {
|
||||
color: colors.warningText,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
validationContainer: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
validationItem: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 4,
|
||||
},
|
||||
validationText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
validationTextError: {
|
||||
color: colors.errorText,
|
||||
},
|
||||
validationTextValid: {
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Create Master Password</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Your master password protects all your data
|
||||
</Text>
|
||||
|
||||
<View style={styles.securityNote}>
|
||||
<Text style={styles.securityNoteText}>
|
||||
⚠️ Important: Your master password cannot be recovered if forgotten.
|
||||
AliasVault uses zero-knowledge encryption, which means we never have access to your password or data.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.passwordField}>
|
||||
<Text style={styles.label}>Master Password</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
password.length > 0 && !hasMinLength && styles.inputContainerError,
|
||||
hasMinLength && password.length > 0 && styles.inputContainerValid
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="lock"
|
||||
size={24}
|
||||
color={colors.textMuted}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={handlePasswordChange}
|
||||
placeholder="Enter your master password"
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="new-password"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.eyeButton}
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={showPassword ? "visibility-off" : "visibility"}
|
||||
size={24}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.validationContainer}>
|
||||
<View style={styles.validationItem}>
|
||||
<MaterialIcons
|
||||
name={hasMinLength ? "check-circle" : "cancel"}
|
||||
size={16}
|
||||
color={hasMinLength ? colors.primary : colors.errorText}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.validationText,
|
||||
hasMinLength ? styles.validationTextValid : styles.validationTextError
|
||||
]}
|
||||
>
|
||||
At least 10 characters
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.passwordField}>
|
||||
<Text style={styles.label}>Confirm Master Password</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
confirmPassword.length > 0 && !passwordsMatch && styles.inputContainerError,
|
||||
confirmPassword.length > 0 && passwordsMatch && styles.inputContainerValid
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="lock"
|
||||
size={24}
|
||||
color={colors.textMuted}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={confirmPassword}
|
||||
onChangeText={handleConfirmPasswordChange}
|
||||
placeholder="Confirm your master password"
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
autoComplete="new-password"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.eyeButton}
|
||||
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={showConfirmPassword ? "visibility-off" : "visibility"}
|
||||
size={24}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{confirmPassword.length > 0 && (
|
||||
<View style={styles.validationContainer}>
|
||||
<View style={styles.validationItem}>
|
||||
<MaterialIcons
|
||||
name={passwordsMatch ? "check-circle" : "cancel"}
|
||||
size={16}
|
||||
color={passwordsMatch ? colors.primary : colors.errorText}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.validationText,
|
||||
passwordsMatch ? styles.validationTextValid : styles.validationTextError
|
||||
]}
|
||||
>
|
||||
Passwords match
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
isValidForm ? styles.buttonEnabled : styles.buttonDisabled
|
||||
]}
|
||||
onPress={handleContinue}
|
||||
disabled={!isValidForm || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.primarySurfaceText} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
isValidForm ? styles.buttonTextEnabled : styles.buttonTextDisabled
|
||||
]}
|
||||
>
|
||||
Continue
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
ActivityIndicator
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
import { InAppBrowserView } from '@/components/ui/InAppBrowserView';
|
||||
|
||||
type TermsAndConditionsStepProps = {
|
||||
agreedToTerms: boolean;
|
||||
onAgreementChange: (agreed: boolean) => void;
|
||||
onNext: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* First step of setup: Terms and Conditions agreement
|
||||
*/
|
||||
export default function TermsAndConditionsStep({
|
||||
agreedToTerms,
|
||||
onAgreementChange,
|
||||
onNext,
|
||||
error
|
||||
}: TermsAndConditionsStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Handle continue button press
|
||||
*/
|
||||
const handleContinue = async (): Promise<void> => {
|
||||
if (!agreedToTerms) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
// Add small delay for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
setIsLoading(false);
|
||||
onNext();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
agreementContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
},
|
||||
agreementText: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
buttonEnabled: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonTextDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
buttonTextEnabled: {
|
||||
color: colors.primarySurfaceText,
|
||||
},
|
||||
checkbox: {
|
||||
alignItems: 'center',
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 4,
|
||||
borderWidth: 2,
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
width: 24,
|
||||
},
|
||||
checkboxChecked: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
},
|
||||
link: {
|
||||
color: colors.primary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
termsContainer: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
height: 200,
|
||||
marginBottom: 20,
|
||||
padding: 16,
|
||||
},
|
||||
termsContent: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Welcome to AliasVault</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Please review and accept our Terms and Conditions to continue
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.termsContainer}>
|
||||
<ScrollView showsVerticalScrollIndicator>
|
||||
<Text style={styles.termsContent}>
|
||||
{`By creating an AliasVault account, you agree to our Terms of Service and Privacy Policy.
|
||||
|
||||
AliasVault is a privacy-first password and email alias manager with full end-to-end encryption. Your data is encrypted on your device before being sent to our servers, ensuring that we never have access to your unencrypted information.
|
||||
|
||||
Key points:
|
||||
• Your master password is never transmitted to our servers
|
||||
• All sensitive data is encrypted client-side using zero-knowledge encryption
|
||||
• We cannot recover your data if you forget your master password
|
||||
• You are responsible for keeping your master password secure
|
||||
• Our service is provided "as-is" without warranties
|
||||
• You must not use the service for illegal activities
|
||||
• We reserve the right to terminate accounts that violate our terms
|
||||
|
||||
For the complete Terms of Service and Privacy Policy, please visit our website.
|
||||
|
||||
By checking the box below, you acknowledge that you have read, understood, and agree to be bound by these terms.`}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
<View style={styles.agreementContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.checkbox, agreedToTerms && styles.checkboxChecked]}
|
||||
onPress={() => onAgreementChange(!agreedToTerms)}
|
||||
>
|
||||
{agreedToTerms && (
|
||||
<MaterialIcons
|
||||
name="check"
|
||||
size={16}
|
||||
color={colors.primarySurfaceText}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.agreementText}>
|
||||
I agree to the{' '}
|
||||
<InAppBrowserView
|
||||
url="https://aliasvault.net/terms"
|
||||
title="Terms of Service"
|
||||
textStyle={styles.link}
|
||||
/>
|
||||
{' '}and{' '}
|
||||
<InAppBrowserView
|
||||
url="https://aliasvault.net/privacy"
|
||||
title="Privacy Policy"
|
||||
textStyle={styles.link}
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
agreedToTerms ? styles.buttonEnabled : styles.buttonDisabled
|
||||
]}
|
||||
onPress={handleContinue}
|
||||
disabled={!agreedToTerms || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.primarySurfaceText} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
agreedToTerms ? styles.buttonTextEnabled : styles.buttonTextDisabled
|
||||
]}
|
||||
>
|
||||
Continue
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
import { useWebApi } from '@/context/WebApiContext';
|
||||
|
||||
type ValidationState = 'idle' | 'validating' | 'valid' | 'error';
|
||||
|
||||
type UsernameStepProps = {
|
||||
username: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onNext: () => void;
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step of setup: Username selection and validation
|
||||
*/
|
||||
export default function UsernameStep({
|
||||
username,
|
||||
onUsernameChange,
|
||||
onNext,
|
||||
error,
|
||||
setError
|
||||
}: UsernameStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
const webApi = useWebApi();
|
||||
const [validationState, setValidationState] = useState<ValidationState>('idle');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* Validate username with server
|
||||
*/
|
||||
const validateUsername = useCallback(async (usernameToValidate: string): Promise<void> => {
|
||||
if (!usernameToValidate.trim()) {
|
||||
setValidationState('idle');
|
||||
setValidationError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationState('validating');
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const response = await webApi.rawFetch('Auth/validate-username', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username: usernameToValidate.toLowerCase().trim() }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setValidationState('valid');
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setValidationState('error');
|
||||
setValidationError(errorData.title ?? 'Username is not available');
|
||||
}
|
||||
} catch (_err) {
|
||||
setValidationState('error');
|
||||
setValidationError('Could not validate username. Please try again.');
|
||||
}
|
||||
}, [webApi]);
|
||||
|
||||
/**
|
||||
* Debounced username validation
|
||||
*/
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (username.length > 0) {
|
||||
validateUsername(username);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [username, validateUsername]);
|
||||
|
||||
/**
|
||||
* Handle continue button press
|
||||
*/
|
||||
const handleContinue = async (): Promise<void> => {
|
||||
if (validationState !== 'valid') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Re-validate username before continuing
|
||||
try {
|
||||
await validateUsername(username);
|
||||
if (validationState === 'valid') {
|
||||
onNext();
|
||||
}
|
||||
} catch (_err) {
|
||||
setError('Failed to validate username. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle username input change
|
||||
*/
|
||||
const handleUsernameChange = (text: string): void => {
|
||||
// Clear any previous errors
|
||||
setError(null);
|
||||
setValidationError(null);
|
||||
|
||||
// Update username
|
||||
onUsernameChange(text);
|
||||
|
||||
// Reset validation state
|
||||
if (text.trim() === '') {
|
||||
setValidationState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const isValidUsername = validationState === 'valid';
|
||||
const showValidationIcon = username.length > 0 && validationState !== 'idle';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
assistantContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
assistantText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonDisabled: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
},
|
||||
buttonEnabled: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
buttonTextDisabled: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
buttonTextEnabled: {
|
||||
color: colors.primarySurfaceText,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
errorContainer: {
|
||||
backgroundColor: colors.errorBackground,
|
||||
borderColor: colors.errorBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.errorText,
|
||||
fontSize: 14,
|
||||
},
|
||||
input: {
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: 50,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
width: '100%',
|
||||
},
|
||||
inputContainerError: {
|
||||
borderColor: colors.errorBorder,
|
||||
},
|
||||
inputContainerValid: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
inputIcon: {
|
||||
padding: 12,
|
||||
},
|
||||
label: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
validationContainer: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
validationMessage: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
},
|
||||
validationMessageError: {
|
||||
color: colors.errorText,
|
||||
},
|
||||
validationMessageValid: {
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Choose a Username</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This will be your unique identifier for logging in
|
||||
</Text>
|
||||
|
||||
<View style={styles.assistantContainer}>
|
||||
<Text style={styles.assistantText}>
|
||||
Your username can be an email address or a custom name. It will be used to log into your vault.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.validationContainer}>
|
||||
<Text style={styles.label}>Username</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.inputContainer,
|
||||
validationState === 'error' && styles.inputContainerError,
|
||||
validationState === 'valid' && styles.inputContainerValid
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="person"
|
||||
size={24}
|
||||
color={colors.textMuted}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={username}
|
||||
onChangeText={handleUsernameChange}
|
||||
placeholder="username or email@company.com"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoComplete="username"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoFocus
|
||||
/>
|
||||
{showValidationIcon && (
|
||||
<View style={styles.inputIcon}>
|
||||
{validationState === 'validating' && (
|
||||
<ActivityIndicator size="small" color={colors.textMuted} />
|
||||
)}
|
||||
{validationState === 'valid' && (
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
/>
|
||||
)}
|
||||
{validationState === 'error' && (
|
||||
<MaterialIcons
|
||||
name="error"
|
||||
size={24}
|
||||
color={colors.errorText}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{validationError && (
|
||||
<Text style={[styles.validationMessage, styles.validationMessageError]}>
|
||||
{validationError}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{validationState === 'valid' && (
|
||||
<Text style={[styles.validationMessage, styles.validationMessageValid]}>
|
||||
✓ Username is available
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.button,
|
||||
isValidUsername ? styles.buttonEnabled : styles.buttonDisabled
|
||||
]}
|
||||
onPress={handleContinue}
|
||||
disabled={!isValidUsername || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.primarySurfaceText} />
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
isValidUsername ? styles.buttonTextEnabled : styles.buttonTextDisabled
|
||||
]}
|
||||
>
|
||||
Continue
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type CreateFirstIdentityStepProps = {
|
||||
onGetStarted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourth step of tutorial: Call to action to create first identity
|
||||
*/
|
||||
export default function CreateFirstIdentityStep({
|
||||
onGetStarted
|
||||
}: CreateFirstIdentityStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
ctaButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
ctaButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
featureContainer: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 20,
|
||||
},
|
||||
featureDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center',
|
||||
},
|
||||
featureIcon: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 30,
|
||||
height: 60,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 12,
|
||||
width: 60,
|
||||
},
|
||||
featureTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
featuresContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
readyIcon: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 50,
|
||||
height: 100,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
width: 100,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
});
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: 'email',
|
||||
title: 'Email Aliases',
|
||||
description: 'Create unique email addresses for each service'
|
||||
},
|
||||
{
|
||||
icon: 'password',
|
||||
title: 'Secure Passwords',
|
||||
description: 'Generate and store strong, unique passwords'
|
||||
},
|
||||
{
|
||||
icon: 'security',
|
||||
title: 'Privacy Protection',
|
||||
description: 'Keep your real email address private'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.titleContainer}>
|
||||
<View style={styles.readyIcon}>
|
||||
<MaterialIcons
|
||||
name="rocket-launch"
|
||||
size={50}
|
||||
color={colors.primarySurfaceText}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.title}>You're All Set!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Your secure vault is ready to protect your privacy
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.description}>
|
||||
You now have access to all of AliasVault's powerful privacy features.
|
||||
Start by creating your first identity to begin protecting your email address online.
|
||||
</Text>
|
||||
|
||||
<View style={styles.featuresContainer}>
|
||||
{features.map((feature, index) => (
|
||||
<View key={index} style={styles.featureContainer}>
|
||||
<View style={styles.featureIcon}>
|
||||
<MaterialIcons
|
||||
name={feature.icon as any}
|
||||
size={30}
|
||||
color={colors.primarySurfaceText}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.featureTitle}>{feature.title}</Text>
|
||||
<Text style={styles.featureDescription}>{feature.description}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.ctaButton]}
|
||||
onPress={onGetStarted}
|
||||
>
|
||||
<Text style={styles.ctaButtonText}>Get Started</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type HowItWorksStepProps = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step of tutorial: How AliasVault Works
|
||||
*/
|
||||
export default function HowItWorksStep({
|
||||
onNext,
|
||||
onSkip
|
||||
}: HowItWorksStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'person-add',
|
||||
title: 'Create an Identity',
|
||||
description: 'Generate a new identity with a unique email alias for each service you sign up for.'
|
||||
},
|
||||
{
|
||||
icon: 'email',
|
||||
title: 'Use the Alias',
|
||||
description: 'Sign up for services using your alias email instead of your real email address.'
|
||||
},
|
||||
{
|
||||
icon: 'security',
|
||||
title: 'Stay Protected',
|
||||
description: 'Your real email stays private, and you can disable aliases if they get compromised.'
|
||||
},
|
||||
{
|
||||
icon: 'password',
|
||||
title: 'Manage Passwords',
|
||||
description: 'Store unique, secure passwords for each account with our built-in password manager.'
|
||||
}
|
||||
];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
stepContainer: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
stepContent: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
stepDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
stepIcon: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 30,
|
||||
height: 60,
|
||||
justifyContent: 'center',
|
||||
width: 60,
|
||||
},
|
||||
stepNumber: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderRadius: 12,
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
height: 24,
|
||||
lineHeight: 24,
|
||||
position: 'absolute',
|
||||
right: -8,
|
||||
textAlign: 'center',
|
||||
top: -8,
|
||||
width: 24,
|
||||
},
|
||||
stepTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
stepsContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>How AliasVault Works</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Protect your privacy with email aliases and secure password management
|
||||
</Text>
|
||||
|
||||
<View style={styles.stepsContainer}>
|
||||
{steps.map((step, index) => (
|
||||
<View key={index} style={styles.stepContainer}>
|
||||
<View style={styles.stepIcon}>
|
||||
<MaterialIcons
|
||||
name={step.icon as any}
|
||||
size={30}
|
||||
color={colors.primarySurfaceText}
|
||||
/>
|
||||
<Text style={styles.stepNumber}>{index + 1}</Text>
|
||||
</View>
|
||||
<View style={styles.stepContent}>
|
||||
<Text style={styles.stepTitle}>{step.title}</Text>
|
||||
<Text style={styles.stepDescription}>{step.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={onNext}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Continue</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={onSkip}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Skip Tour</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Platform,
|
||||
Linking
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type TipsStepProps = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Third step of tutorial: Security tips and app downloads
|
||||
*/
|
||||
export default function TipsStep({
|
||||
onNext,
|
||||
onSkip
|
||||
}: TipsStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
/**
|
||||
* Open app store for browser extension download
|
||||
*/
|
||||
const openBrowserExtension = (): void => {
|
||||
if (Platform.OS === 'ios') {
|
||||
// iOS Safari Extensions
|
||||
Linking.openURL('https://apps.apple.com/app/aliasvault/id1234567890');
|
||||
} else {
|
||||
// Android Chrome Web Store
|
||||
Linking.openURL('https://chrome.google.com/webstore/detail/aliasvault/abcdefghijklmnop');
|
||||
}
|
||||
};
|
||||
|
||||
const tips = [
|
||||
{
|
||||
icon: 'security',
|
||||
title: 'Keep Your Master Password Safe',
|
||||
description: 'Your master password cannot be recovered. Write it down and store it securely.',
|
||||
color: colors.errorText
|
||||
},
|
||||
{
|
||||
icon: 'verified-user',
|
||||
title: 'Enable Two-Factor Authentication',
|
||||
description: 'Add an extra layer of security to your account in Settings > Security.',
|
||||
color: colors.primary
|
||||
},
|
||||
{
|
||||
icon: 'extension',
|
||||
title: 'Install Browser Extension',
|
||||
description: 'Get the AliasVault browser extension for seamless auto-fill on websites.',
|
||||
color: colors.primary,
|
||||
action: openBrowserExtension,
|
||||
actionText: 'Download'
|
||||
}
|
||||
];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
actionButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
marginTop: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
},
|
||||
actionButtonText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tipContainer: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 16,
|
||||
padding: 16,
|
||||
},
|
||||
tipContent: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
},
|
||||
tipDescription: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
tipHeader: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
tipIcon: {
|
||||
marginRight: 4,
|
||||
},
|
||||
tipTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
tipsContainer: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>Tips & Recommendations</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Follow these tips to get the most out of AliasVault
|
||||
</Text>
|
||||
|
||||
<View style={styles.tipsContainer}>
|
||||
{tips.map((tip, index) => (
|
||||
<View key={index} style={styles.tipContainer}>
|
||||
<View style={styles.tipHeader}>
|
||||
<MaterialIcons
|
||||
name={tip.icon as any}
|
||||
size={24}
|
||||
color={tip.color}
|
||||
style={styles.tipIcon}
|
||||
/>
|
||||
<View style={styles.tipContent}>
|
||||
<Text style={styles.tipTitle}>{tip.title}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.tipDescription}>{tip.description}</Text>
|
||||
{tip.action && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={tip.action}
|
||||
>
|
||||
<Text style={styles.actionButtonText}>{tip.actionText}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={onNext}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Continue</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={onSkip}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Skip Tour</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
|
||||
type WelcomeStepProps = {
|
||||
onNext: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* First step of tutorial: Welcome message
|
||||
*/
|
||||
export default function WelcomeStep({
|
||||
onNext,
|
||||
onSkip
|
||||
}: WelcomeStepProps): React.ReactNode {
|
||||
const colors = useColors();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
},
|
||||
buttonContainer: {
|
||||
gap: 12,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
congratsIcon: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 40,
|
||||
height: 80,
|
||||
justifyContent: 'center',
|
||||
marginBottom: 24,
|
||||
width: 80,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 24,
|
||||
},
|
||||
description: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 32,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.secondary,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.primarySurfaceText,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
subtitle: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView style={styles.scrollContainer}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.headerContainer}>
|
||||
<View style={styles.congratsIcon}>
|
||||
<MaterialIcons
|
||||
name="check"
|
||||
size={40}
|
||||
color={colors.primarySurfaceText}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.title}>Congratulations!</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Your AliasVault has been created successfully
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.description}>
|
||||
Welcome to AliasVault, your privacy-first password and email alias manager.
|
||||
Your vault is now secured with end-to-end encryption, ensuring that only you
|
||||
can access your data.
|
||||
{'\n\n'}
|
||||
Let's take a quick tour to help you get started with protecting your privacy online.
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.primaryButton]}
|
||||
onPress={onNext}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Start Tour</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.secondaryButton]}
|
||||
onPress={onSkip}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Skip Tour</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -417,6 +417,32 @@ class SqliteClient {
|
||||
return results.length > 0 ? results[0].Value : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting in the database. Creates or updates the setting.
|
||||
* @param key The setting key
|
||||
* @param value The setting value
|
||||
*/
|
||||
public async setSetting(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
const currentDateTime = new Date().toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace('Z', '')
|
||||
.substring(0, 23);
|
||||
|
||||
// Use INSERT OR REPLACE to handle both create and update cases
|
||||
const query = `
|
||||
INSERT OR REPLACE INTO Settings (Key, Value, CreatedAt, UpdatedAt)
|
||||
VALUES (?, ?,
|
||||
COALESCE((SELECT CreatedAt FROM Settings WHERE Key = ?), ?),
|
||||
?)`;
|
||||
|
||||
await this.executeUpdate(query, [key, value, key, currentDateTime, currentDateTime]);
|
||||
} catch (error) {
|
||||
console.error('Error setting setting:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default identity language from the database.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user