- Add API integration layer with axios and token management - Create Zustand auth store for state management - Implement login screen with validation - Implement signup screen with password strength indicator - Implement forgot password flow with multi-step UI - Update root layout with auth state protection - Integrate profile screen with auth store - Install AsyncStorage and expo-secure-store dependencies
565 lines
15 KiB
TypeScript
565 lines
15 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
TouchableOpacity,
|
|
StyleSheet,
|
|
ScrollView,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { useRouter } from 'expo-router';
|
|
import { authApi, handleApiError } from '../lib/api';
|
|
|
|
type Step = 'email' | 'sent' | 'reset';
|
|
|
|
export default function ForgotPasswordScreen() {
|
|
const router = useRouter();
|
|
|
|
const [step, setStep] = useState<Step>('email');
|
|
const [email, setEmail] = useState('');
|
|
const [resetToken, setResetToken] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [validationErrors, setValidationErrors] = useState<{
|
|
email?: string;
|
|
resetToken?: string;
|
|
newPassword?: string;
|
|
confirmPassword?: string;
|
|
}>({});
|
|
|
|
// Validate email format
|
|
const validateEmail = (email: string): boolean => {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
};
|
|
|
|
// Validate email step
|
|
const validateEmailStep = (): boolean => {
|
|
const errors: { email?: string } = {};
|
|
|
|
if (!email.trim()) {
|
|
errors.email = 'Email is required';
|
|
} else if (!validateEmail(email)) {
|
|
errors.email = 'Please enter a valid email';
|
|
}
|
|
|
|
setValidationErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
// Validate reset step
|
|
const validateResetStep = (): boolean => {
|
|
const errors: {
|
|
resetToken?: string;
|
|
newPassword?: string;
|
|
confirmPassword?: string;
|
|
} = {};
|
|
|
|
if (!resetToken.trim()) {
|
|
errors.resetToken = 'Reset code is required';
|
|
}
|
|
|
|
if (!newPassword) {
|
|
errors.newPassword = 'New password is required';
|
|
} else if (newPassword.length < 8) {
|
|
errors.newPassword = 'Password must be at least 8 characters';
|
|
}
|
|
|
|
if (!confirmPassword) {
|
|
errors.confirmPassword = 'Please confirm your password';
|
|
} else if (newPassword !== confirmPassword) {
|
|
errors.confirmPassword = 'Passwords do not match';
|
|
}
|
|
|
|
setValidationErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
// Handle send reset email
|
|
const handleSendReset = async () => {
|
|
setError(null);
|
|
|
|
if (!validateEmailStep()) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await authApi.forgotPassword({ email: email.trim().toLowerCase() });
|
|
setStep('sent');
|
|
} catch (err) {
|
|
setError(handleApiError(err));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Handle reset password
|
|
const handleResetPassword = async () => {
|
|
setError(null);
|
|
|
|
if (!validateResetStep()) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
await authApi.resetPassword({
|
|
token: resetToken.trim(),
|
|
newPassword,
|
|
});
|
|
// Navigate to login with success message
|
|
router.replace('/(auth)/login');
|
|
} catch (err) {
|
|
setError(handleApiError(err));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Navigate back to login
|
|
const handleBackToLogin = () => {
|
|
router.push('/(auth)/login');
|
|
};
|
|
|
|
// Render email step
|
|
const renderEmailStep = () => (
|
|
<>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Forgot Password</Text>
|
|
<Text style={styles.subtitle}>
|
|
Enter your email address and we'll send you a link to reset your password.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.form}>
|
|
<View style={styles.inputContainer}>
|
|
<Text style={styles.label}>Email</Text>
|
|
<TextInput
|
|
style={[styles.input, validationErrors.email && styles.inputError]}
|
|
placeholder="Enter your email"
|
|
placeholderTextColor="#999"
|
|
value={email}
|
|
onChangeText={(text) => {
|
|
setEmail(text);
|
|
if (validationErrors.email) {
|
|
setValidationErrors((prev) => ({ ...prev, email: undefined }));
|
|
}
|
|
}}
|
|
keyboardType="email-address"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoComplete="email"
|
|
editable={!isLoading}
|
|
/>
|
|
{validationErrors.email && (
|
|
<Text style={styles.errorText}>{validationErrors.email}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{error && (
|
|
<View style={styles.apiError}>
|
|
<Text style={styles.apiErrorText}>{error}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
|
onPress={handleSendReset}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator color="#FFFFFF" />
|
|
) : (
|
|
<Text style={styles.buttonText}>Send Reset Link</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={handleBackToLogin}
|
|
disabled={isLoading}
|
|
>
|
|
<Text style={styles.backButtonText}>Back to Sign In</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
// Render sent confirmation step
|
|
const renderSentStep = () => (
|
|
<>
|
|
<View style={styles.header}>
|
|
<View style={styles.iconContainer}>
|
|
<Text style={styles.checkIcon}>✓</Text>
|
|
</View>
|
|
<Text style={styles.title}>Check Your Email</Text>
|
|
<Text style={styles.subtitle}>
|
|
We've sent a password reset link to{'\n'}
|
|
<Text style={styles.emailHighlight}>{email}</Text>
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.form}>
|
|
<View style={styles.instructionsContainer}>
|
|
<Text style={styles.instructionsTitle}>What's next?</Text>
|
|
<Text style={styles.instructionsText}>
|
|
1. Check your email inbox{'\n'}
|
|
2. Click the reset link in the email{'\n'}
|
|
3. Enter your reset code below
|
|
</Text>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.button}
|
|
onPress={() => setStep('reset')}
|
|
>
|
|
<Text style={styles.buttonText}>I Have My Reset Code</Text>
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.resendButton}
|
|
onPress={handleSendReset}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator color="#007AFF" />
|
|
) : (
|
|
<Text style={styles.resendButtonText}>Resend Email</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={handleBackToLogin}
|
|
disabled={isLoading}
|
|
>
|
|
<Text style={styles.backButtonText}>Back to Sign In</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
// Render reset password step
|
|
const renderResetStep = () => (
|
|
<>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Reset Password</Text>
|
|
<Text style={styles.subtitle}>
|
|
Enter the code from your email and create a new password.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.form}>
|
|
{/* Reset Token Input */}
|
|
<View style={styles.inputContainer}>
|
|
<Text style={styles.label}>Reset Code</Text>
|
|
<TextInput
|
|
style={[styles.input, validationErrors.resetToken && styles.inputError]}
|
|
placeholder="Enter reset code"
|
|
placeholderTextColor="#999"
|
|
value={resetToken}
|
|
onChangeText={(text) => {
|
|
setResetToken(text);
|
|
if (validationErrors.resetToken) {
|
|
setValidationErrors((prev) => ({ ...prev, resetToken: undefined }));
|
|
}
|
|
}}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
editable={!isLoading}
|
|
/>
|
|
{validationErrors.resetToken && (
|
|
<Text style={styles.errorText}>{validationErrors.resetToken}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* New Password Input */}
|
|
<View style={styles.inputContainer}>
|
|
<Text style={styles.label}>New Password</Text>
|
|
<View style={styles.passwordContainer}>
|
|
<TextInput
|
|
style={[
|
|
styles.input,
|
|
styles.passwordInput,
|
|
validationErrors.newPassword && styles.inputError,
|
|
]}
|
|
placeholder="Create new password"
|
|
placeholderTextColor="#999"
|
|
value={newPassword}
|
|
onChangeText={(text) => {
|
|
setNewPassword(text);
|
|
if (validationErrors.newPassword) {
|
|
setValidationErrors((prev) => ({ ...prev, newPassword: undefined }));
|
|
}
|
|
}}
|
|
secureTextEntry={!showPassword}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
editable={!isLoading}
|
|
/>
|
|
<TouchableOpacity
|
|
style={styles.showPasswordButton}
|
|
onPress={() => setShowPassword(!showPassword)}
|
|
disabled={isLoading}
|
|
>
|
|
<Text style={styles.showPasswordText}>
|
|
{showPassword ? 'Hide' : 'Show'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{validationErrors.newPassword && (
|
|
<Text style={styles.errorText}>{validationErrors.newPassword}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Confirm Password Input */}
|
|
<View style={styles.inputContainer}>
|
|
<Text style={styles.label}>Confirm New Password</Text>
|
|
<TextInput
|
|
style={[
|
|
styles.input,
|
|
validationErrors.confirmPassword && styles.inputError,
|
|
]}
|
|
placeholder="Confirm new password"
|
|
placeholderTextColor="#999"
|
|
value={confirmPassword}
|
|
onChangeText={(text) => {
|
|
setConfirmPassword(text);
|
|
if (validationErrors.confirmPassword) {
|
|
setValidationErrors((prev) => ({
|
|
...prev,
|
|
confirmPassword: undefined,
|
|
}));
|
|
}
|
|
}}
|
|
secureTextEntry={!showPassword}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
editable={!isLoading}
|
|
/>
|
|
{validationErrors.confirmPassword && (
|
|
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
|
|
)}
|
|
</View>
|
|
|
|
{error && (
|
|
<View style={styles.apiError}>
|
|
<Text style={styles.apiErrorText}>{error}</Text>
|
|
</View>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
|
onPress={handleResetPassword}
|
|
disabled={isLoading}
|
|
>
|
|
{isLoading ? (
|
|
<ActivityIndicator color="#FFFFFF" />
|
|
) : (
|
|
<Text style={styles.buttonText}>Reset Password</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
|
|
<TouchableOpacity
|
|
style={styles.backButton}
|
|
onPress={() => setStep('sent')}
|
|
disabled={isLoading}
|
|
>
|
|
<Text style={styles.backButtonText}>Go Back</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={styles.container}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{step === 'email' && renderEmailStep()}
|
|
{step === 'sent' && renderSentStep()}
|
|
{step === 'reset' && renderResetStep()}
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#FFFFFF',
|
|
},
|
|
scrollContent: {
|
|
flexGrow: 1,
|
|
paddingHorizontal: 24,
|
|
paddingTop: 60,
|
|
paddingBottom: 40,
|
|
},
|
|
header: {
|
|
marginBottom: 32,
|
|
alignItems: 'center',
|
|
},
|
|
iconContainer: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: '#E8F5E9',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: 24,
|
|
},
|
|
checkIcon: {
|
|
fontSize: 32,
|
|
color: '#34C759',
|
|
},
|
|
title: {
|
|
fontSize: 28,
|
|
fontWeight: 'bold',
|
|
color: '#333333',
|
|
marginBottom: 12,
|
|
textAlign: 'center',
|
|
},
|
|
subtitle: {
|
|
fontSize: 16,
|
|
color: '#666666',
|
|
textAlign: 'center',
|
|
lineHeight: 24,
|
|
},
|
|
emailHighlight: {
|
|
fontWeight: '600',
|
|
color: '#007AFF',
|
|
},
|
|
form: {
|
|
flex: 1,
|
|
},
|
|
inputContainer: {
|
|
marginBottom: 20,
|
|
},
|
|
label: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#333333',
|
|
marginBottom: 8,
|
|
},
|
|
input: {
|
|
height: 50,
|
|
borderWidth: 1,
|
|
borderColor: '#E0E0E0',
|
|
borderRadius: 8,
|
|
paddingHorizontal: 16,
|
|
fontSize: 16,
|
|
color: '#333333',
|
|
backgroundColor: '#FAFAFA',
|
|
},
|
|
inputError: {
|
|
borderColor: '#FF3B30',
|
|
},
|
|
passwordContainer: {
|
|
position: 'relative',
|
|
},
|
|
passwordInput: {
|
|
paddingRight: 60,
|
|
},
|
|
showPasswordButton: {
|
|
position: 'absolute',
|
|
right: 16,
|
|
top: 0,
|
|
bottom: 0,
|
|
justifyContent: 'center',
|
|
},
|
|
showPasswordText: {
|
|
fontSize: 14,
|
|
color: '#007AFF',
|
|
fontWeight: '600',
|
|
},
|
|
errorText: {
|
|
fontSize: 12,
|
|
color: '#FF3B30',
|
|
marginTop: 4,
|
|
},
|
|
apiError: {
|
|
backgroundColor: '#FFF0F0',
|
|
borderRadius: 8,
|
|
padding: 12,
|
|
marginBottom: 16,
|
|
borderWidth: 1,
|
|
borderColor: '#FF3B30',
|
|
},
|
|
apiErrorText: {
|
|
fontSize: 14,
|
|
color: '#FF3B30',
|
|
textAlign: 'center',
|
|
},
|
|
instructionsContainer: {
|
|
backgroundColor: '#F5F5F5',
|
|
borderRadius: 8,
|
|
padding: 16,
|
|
marginBottom: 24,
|
|
},
|
|
instructionsTitle: {
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
color: '#333333',
|
|
marginBottom: 8,
|
|
},
|
|
instructionsText: {
|
|
fontSize: 14,
|
|
color: '#666666',
|
|
lineHeight: 22,
|
|
},
|
|
button: {
|
|
height: 50,
|
|
backgroundColor: '#007AFF',
|
|
borderRadius: 8,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
buttonDisabled: {
|
|
backgroundColor: '#B0B0B0',
|
|
},
|
|
buttonText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#FFFFFF',
|
|
},
|
|
resendButton: {
|
|
height: 50,
|
|
backgroundColor: '#FFFFFF',
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
borderColor: '#007AFF',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: 16,
|
|
},
|
|
resendButtonText: {
|
|
fontSize: 16,
|
|
fontWeight: '600',
|
|
color: '#007AFF',
|
|
},
|
|
backButton: {
|
|
height: 50,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
backButtonText: {
|
|
fontSize: 14,
|
|
color: '#666666',
|
|
fontWeight: '600',
|
|
},
|
|
});
|