morethanadiagnosis-hub/mobile/app/(auth)/forgot-password.tsx
Claude eb04163b3b
feat: implement authentication system for mobile app
- 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
2025-11-18 19:32:16 +00:00

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',
},
});