Add account creation scaffolding (#962)

This commit is contained in:
Leendert de Borst
2025-06-26 12:53:21 +02:00
parent d0bbf3ac9f
commit 9ddb15dad0
13 changed files with 2625 additions and 6 deletions
@@ -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();
+6 -6
View File
@@ -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>
)}
+314
View File
@@ -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>
);
}
+271
View File
@@ -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&apos;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>
);
}
+26
View File
@@ -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.
*/