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
This commit is contained in:
parent
2e24918e90
commit
eb04163b3b
10 changed files with 2909 additions and 17 deletions
565
mobile/app/(auth)/forgot-password.tsx
Normal file
565
mobile/app/(auth)/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,565 @@
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
354
mobile/app/(auth)/login.tsx
Normal file
354
mobile/app/(auth)/login.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useAuthStore } from '../lib/auth-store';
|
||||||
|
|
||||||
|
export default function LoginScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<{
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: { email?: string; password?: string } = {};
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
errors.email = 'Email is required';
|
||||||
|
} else if (!validateEmail(email)) {
|
||||||
|
errors.email = 'Please enter a valid email';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
} else if (password.length < 6) {
|
||||||
|
errors.password = 'Password must be at least 6 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle login
|
||||||
|
const handleLogin = async () => {
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login({ email: email.trim().toLowerCase(), password });
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate to signup
|
||||||
|
const handleSignup = () => {
|
||||||
|
clearError();
|
||||||
|
router.push('/(auth)/signup');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate to forgot password
|
||||||
|
const handleForgotPassword = () => {
|
||||||
|
clearError();
|
||||||
|
router.push('/(auth)/forgot-password');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Welcome Back</Text>
|
||||||
|
<Text style={styles.subtitle}>Sign in to your account</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<View style={styles.form}>
|
||||||
|
{/* Email Input */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Password</Text>
|
||||||
|
<View style={styles.passwordContainer}>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
styles.passwordInput,
|
||||||
|
validationErrors.password && styles.inputError,
|
||||||
|
]}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
if (validationErrors.password) {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, password: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="password"
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.showPasswordButton}
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.showPasswordText}>
|
||||||
|
{showPassword ? 'Hide' : 'Show'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{validationErrors.password && (
|
||||||
|
<Text style={styles.errorText}>{validationErrors.password}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Forgot Password Link */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.forgotPassword}
|
||||||
|
onPress={handleForgotPassword}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.forgotPasswordText}>Forgot Password?</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* API Error */}
|
||||||
|
{error && (
|
||||||
|
<View style={styles.apiError}>
|
||||||
|
<Text style={styles.apiErrorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sign In Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleLogin}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Sign In</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Sign Up Link */}
|
||||||
|
<View style={styles.signupContainer}>
|
||||||
|
<Text style={styles.signupText}>Don't have an account? </Text>
|
||||||
|
<TouchableOpacity onPress={handleSignup} disabled={isLoading}>
|
||||||
|
<Text style={styles.signupLink}>Sign Up</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>MoreThanADiagnosis</Text>
|
||||||
|
<Text style={styles.footerSubtext}>
|
||||||
|
Supporting the kidney disease community
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 40,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333333',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
forgotPassword: {
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
forgotPasswordText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#007AFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
backgroundColor: '#FFF0F0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FF3B30',
|
||||||
|
},
|
||||||
|
apiErrorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#FF3B30',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#B0B0B0',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
signupContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
signupText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
signupLink: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#007AFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: 40,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333333',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
footerSubtext: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#999999',
|
||||||
|
},
|
||||||
|
});
|
||||||
475
mobile/app/(auth)/signup.tsx
Normal file
475
mobile/app/(auth)/signup.tsx
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useAuthStore } from '../lib/auth-store';
|
||||||
|
|
||||||
|
export default function SignupScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { signup, isLoading, error, clearError } = useAuthStore();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<{
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const validateEmail = (email: string): boolean => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate password strength
|
||||||
|
const getPasswordStrength = (password: string): { level: number; text: string; color: string } => {
|
||||||
|
if (!password) {
|
||||||
|
return { level: 0, text: '', color: '#E0E0E0' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||||
|
if (/\d/.test(password)) strength++;
|
||||||
|
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ level: 1, text: 'Weak', color: '#FF3B30' },
|
||||||
|
{ level: 2, text: 'Fair', color: '#FF9500' },
|
||||||
|
{ level: 3, text: 'Good', color: '#FFCC00' },
|
||||||
|
{ level: 4, text: 'Strong', color: '#34C759' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return levels[Math.min(strength, 4) - 1] || { level: 0, text: 'Too short', color: '#FF3B30' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const errors: {
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
confirmPassword?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (!name.trim()) {
|
||||||
|
errors.name = 'Name is required';
|
||||||
|
} else if (name.trim().length < 2) {
|
||||||
|
errors.name = 'Name must be at least 2 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!email.trim()) {
|
||||||
|
errors.email = 'Email is required';
|
||||||
|
} else if (!validateEmail(email)) {
|
||||||
|
errors.email = 'Please enter a valid email';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
errors.password = 'Password is required';
|
||||||
|
} else if (password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Please confirm your password';
|
||||||
|
} else if (password !== confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match';
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle signup
|
||||||
|
const handleSignup = async () => {
|
||||||
|
clearError();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signup({
|
||||||
|
name: name.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
} catch {
|
||||||
|
// Error is handled by the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate to login
|
||||||
|
const handleLogin = () => {
|
||||||
|
clearError();
|
||||||
|
router.push('/(auth)/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(password);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.container}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Create Account</Text>
|
||||||
|
<Text style={styles.subtitle}>Join our community today</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<View style={styles.form}>
|
||||||
|
{/* Name Input */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Name</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, validationErrors.name && styles.inputError]}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={name}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setName(text);
|
||||||
|
if (validationErrors.name) {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, name: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoCapitalize="words"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="name"
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
{validationErrors.name && (
|
||||||
|
<Text style={styles.errorText}>{validationErrors.name}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Email Input */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Password Input */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Password</Text>
|
||||||
|
<View style={styles.passwordContainer}>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
styles.passwordInput,
|
||||||
|
validationErrors.password && styles.inputError,
|
||||||
|
]}
|
||||||
|
placeholder="Create a password"
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={password}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setPassword(text);
|
||||||
|
if (validationErrors.password) {
|
||||||
|
setValidationErrors((prev) => ({ ...prev, password: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="password-new"
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.showPasswordButton}
|
||||||
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Text style={styles.showPasswordText}>
|
||||||
|
{showPassword ? 'Hide' : 'Show'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{validationErrors.password && (
|
||||||
|
<Text style={styles.errorText}>{validationErrors.password}</Text>
|
||||||
|
)}
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{password.length > 0 && (
|
||||||
|
<View style={styles.strengthContainer}>
|
||||||
|
<View style={styles.strengthBars}>
|
||||||
|
{[1, 2, 3, 4].map((level) => (
|
||||||
|
<View
|
||||||
|
key={level}
|
||||||
|
style={[
|
||||||
|
styles.strengthBar,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
level <= passwordStrength.level
|
||||||
|
? passwordStrength.color
|
||||||
|
: '#E0E0E0',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.strengthText, { color: passwordStrength.color }]}>
|
||||||
|
{passwordStrength.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Confirm Password Input */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.label}>Confirm Password</Text>
|
||||||
|
<TextInput
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
validationErrors.confirmPassword && styles.inputError,
|
||||||
|
]}
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
placeholderTextColor="#999"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setConfirmPassword(text);
|
||||||
|
if (validationErrors.confirmPassword) {
|
||||||
|
setValidationErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
confirmPassword: undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
secureTextEntry={!showPassword}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoComplete="password-new"
|
||||||
|
editable={!isLoading}
|
||||||
|
/>
|
||||||
|
{validationErrors.confirmPassword && (
|
||||||
|
<Text style={styles.errorText}>{validationErrors.confirmPassword}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* API Error */}
|
||||||
|
{error && (
|
||||||
|
<View style={styles.apiError}>
|
||||||
|
<Text style={styles.apiErrorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Account Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, isLoading && styles.buttonDisabled]}
|
||||||
|
onPress={handleSignup}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ActivityIndicator color="#FFFFFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.buttonText}>Create Account</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Terms */}
|
||||||
|
<Text style={styles.termsText}>
|
||||||
|
By creating an account, you agree to our{' '}
|
||||||
|
<Text style={styles.termsLink}>Terms of Service</Text> and{' '}
|
||||||
|
<Text style={styles.termsLink}>Privacy Policy</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Sign In Link */}
|
||||||
|
<View style={styles.loginContainer}>
|
||||||
|
<Text style={styles.loginText}>Already have an account? </Text>
|
||||||
|
<TouchableOpacity onPress={handleLogin} disabled={isLoading}>
|
||||||
|
<Text style={styles.loginLink}>Sign In</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 60,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#333333',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
strengthContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
strengthBars: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
strengthBar: {
|
||||||
|
flex: 1,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
strengthText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 8,
|
||||||
|
minWidth: 50,
|
||||||
|
},
|
||||||
|
apiError: {
|
||||||
|
backgroundColor: '#FFF0F0',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#FF3B30',
|
||||||
|
},
|
||||||
|
apiErrorText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#FF3B30',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
height: 50,
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
borderRadius: 8,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
backgroundColor: '#B0B0B0',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
termsText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#666666',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
termsLink: {
|
||||||
|
color: '#007AFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
loginContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loginText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#666666',
|
||||||
|
},
|
||||||
|
loginLink: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#007AFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
|
@ -7,8 +7,11 @@ import {
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Alert,
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useAuthStore } from '../lib/auth-store';
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
|
|
@ -111,21 +114,38 @@ const styles = StyleSheet.create({
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const router = useRouter();
|
||||||
|
const { user, isAuthenticated, isLoading, logout } = useAuthStore();
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
Alert.alert('Sign Out', 'Are you sure you want to sign out?', [
|
||||||
{ text: 'Cancel', onPress: () => {} },
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
{
|
{
|
||||||
text: 'Sign Out',
|
text: 'Sign Out',
|
||||||
onPress: () => {
|
style: 'destructive',
|
||||||
setIsLoggedIn(false);
|
onPress: async () => {
|
||||||
|
await logout();
|
||||||
|
router.replace('/(auth)/login');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
const handleSignIn = () => {
|
||||||
|
router.push('/(auth)/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user initials for avatar
|
||||||
|
const getInitials = () => {
|
||||||
|
if (!user?.name) return '?';
|
||||||
|
const names = user.name.split(' ');
|
||||||
|
if (names.length >= 2) {
|
||||||
|
return `${names[0][0]}${names[1][0]}`.toUpperCase();
|
||||||
|
}
|
||||||
|
return names[0][0].toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<View style={styles.profileHeader}>
|
<View style={styles.profileHeader}>
|
||||||
|
|
@ -139,7 +159,7 @@ export default function ProfileScreen() {
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.menuItem, styles.menuItemFirst, styles.menuItemLast]}
|
style={[styles.menuItem, styles.menuItemFirst, styles.menuItemLast]}
|
||||||
onPress={() => setIsLoggedIn(true)}
|
onPress={handleSignIn}
|
||||||
>
|
>
|
||||||
<View style={styles.menuIcon}>
|
<View style={styles.menuIcon}>
|
||||||
<Ionicons name="log-in" size={20} color="#0066cc" />
|
<Ionicons name="log-in" size={20} color="#0066cc" />
|
||||||
|
|
@ -182,10 +202,10 @@ export default function ProfileScreen() {
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView showsVerticalScrollIndicator={false}>
|
||||||
<View style={styles.profileHeader}>
|
<View style={styles.profileHeader}>
|
||||||
<View style={styles.avatar}>
|
<View style={styles.avatar}>
|
||||||
<Text style={styles.avatarText}>👤</Text>
|
<Text style={styles.avatarText}>{getInitials()}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.profileName}>John Doe</Text>
|
<Text style={styles.profileName}>{user?.name || 'User'}</Text>
|
||||||
<Text style={styles.profileStatus}>Active Member</Text>
|
<Text style={styles.profileStatus}>{user?.email || 'Active Member'}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,57 @@
|
||||||
import { Stack } from 'expo-router';
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { View, ActivityIndicator, StyleSheet } from 'react-native';
|
||||||
|
import { useAuthStore } from './lib/auth-store';
|
||||||
|
|
||||||
export const unstable_settings = {
|
export const unstable_settings = {
|
||||||
initialRouteName: '(tabs)',
|
initialRouteName: '(tabs)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout() {
|
// Auth protection hook
|
||||||
|
function useProtectedRoute() {
|
||||||
|
const { isAuthenticated, isInitialized } = useAuthStore();
|
||||||
|
const segments = useSegments();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Setup any global configuration here
|
if (!isInitialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inAuthGroup = segments[0] === '(auth)';
|
||||||
|
|
||||||
|
if (!isAuthenticated && !inAuthGroup) {
|
||||||
|
// Redirect to login if not authenticated and not in auth group
|
||||||
|
router.replace('/(auth)/login');
|
||||||
|
} else if (isAuthenticated && inAuthGroup) {
|
||||||
|
// Redirect to tabs if authenticated and in auth group
|
||||||
|
router.replace('/(tabs)');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isInitialized, segments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
const { initialize, isInitialized } = useAuthStore();
|
||||||
|
|
||||||
|
// Initialize auth state on app start
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Use protected route hook
|
||||||
|
useProtectedRoute();
|
||||||
|
|
||||||
|
// Show loading screen while initializing
|
||||||
|
if (!isInitialized) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<StatusBar style="dark" />
|
||||||
|
<ActivityIndicator size="large" color="#007AFF" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
|
|
@ -21,3 +62,12 @@ export default function RootLayout() {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
284
mobile/app/lib/api.ts
Normal file
284
mobile/app/lib/api.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import {
|
||||||
|
LoginRequest,
|
||||||
|
SignupRequest,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
AuthResponse,
|
||||||
|
User,
|
||||||
|
Resource,
|
||||||
|
ResourceCategory,
|
||||||
|
CommunitySpace,
|
||||||
|
UserPreferences,
|
||||||
|
ApiError,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
const TOKEN_KEY = 'auth_token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
|
||||||
|
// API base URL - update this for your environment
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://api.morethanadiagnosis.org/api/v1';
|
||||||
|
|
||||||
|
// Create axios instance
|
||||||
|
const api: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token to requests
|
||||||
|
api.interceptors.request.use(
|
||||||
|
async (config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = await AsyncStorage.getItem(TOKEN_KEY);
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle token refresh
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<ApiError>) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// If 401 and we haven't tried to refresh yet
|
||||||
|
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await axios.post<AuthResponse>(`${API_BASE_URL}/auth/refresh`, {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||||
|
|
||||||
|
// Store new tokens
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||||
|
|
||||||
|
// Retry original request with new token
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return api(originalRequest);
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
// Refresh failed - clear tokens and redirect to login
|
||||||
|
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||||
|
// The auth store should handle navigation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extend AxiosRequestConfig to include _retry property
|
||||||
|
declare module 'axios' {
|
||||||
|
export interface InternalAxiosRequestConfig {
|
||||||
|
_retry?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Authentication API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (data: LoginRequest): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/login', data);
|
||||||
|
const { accessToken, refreshToken } = response.data;
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
signup: async (data: SignupRequest): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/signup', data);
|
||||||
|
const { accessToken, refreshToken } = response.data;
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
forgotPassword: async (data: ForgotPasswordRequest): Promise<{ message: string }> => {
|
||||||
|
const response = await api.post<{ message: string }>('/auth/forgot-password', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetPassword: async (data: ResetPasswordRequest): Promise<{ message: string }> => {
|
||||||
|
const response = await api.post<{ message: string }>('/auth/reset-password', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} catch {
|
||||||
|
// Ignore errors on logout
|
||||||
|
} finally {
|
||||||
|
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshToken: async (): Promise<AuthResponse> => {
|
||||||
|
const refreshToken = await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.post<AuthResponse>('/auth/refresh', { refreshToken });
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = response.data;
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(TOKEN_KEY, accessToken);
|
||||||
|
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, newRefreshToken);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStoredToken: async (): Promise<string | null> => {
|
||||||
|
return AsyncStorage.getItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// User API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const userApi = {
|
||||||
|
getMe: async (): Promise<User> => {
|
||||||
|
const response = await api.get<User>('/user/me');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMe: async (data: Partial<User>): Promise<User> => {
|
||||||
|
const response = await api.put<User>('/user/me', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPreferences: async (): Promise<UserPreferences> => {
|
||||||
|
const response = await api.get<UserPreferences>('/user/preferences');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePreferences: async (data: Partial<UserPreferences>): Promise<UserPreferences> => {
|
||||||
|
const response = await api.put<UserPreferences>('/user/preferences', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Resources API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const resourcesApi = {
|
||||||
|
getCategories: async (): Promise<ResourceCategory[]> => {
|
||||||
|
const response = await api.get<ResourceCategory[]>('/resources/categories');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAll: async (): Promise<Resource[]> => {
|
||||||
|
const response = await api.get<Resource[]>('/resources');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string): Promise<Resource> => {
|
||||||
|
const response = await api.get<Resource>(`/resources/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getByCategory: async (categoryId: string): Promise<Resource[]> => {
|
||||||
|
const response = await api.get<Resource[]>(`/resources/category/${categoryId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Community API
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const communityApi = {
|
||||||
|
getSpaces: async (): Promise<CommunitySpace[]> => {
|
||||||
|
const response = await api.get<CommunitySpace[]>('/community');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSpaceById: async (id: string): Promise<CommunitySpace> => {
|
||||||
|
const response = await api.get<CommunitySpace>(`/community/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
joinSpace: async (id: string): Promise<{ message: string }> => {
|
||||||
|
const response = await api.post<{ message: string }>(`/community/${id}/join`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
leaveSpace: async (id: string): Promise<{ message: string }> => {
|
||||||
|
const response = await api.post<{ message: string }>(`/community/${id}/leave`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helper functions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export const handleApiError = (error: unknown): string => {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
const axiosError = error as AxiosError<ApiError>;
|
||||||
|
|
||||||
|
// Network error
|
||||||
|
if (!axiosError.response) {
|
||||||
|
return 'Network error. Please check your connection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// API error response
|
||||||
|
const apiError = axiosError.response.data;
|
||||||
|
if (apiError?.message) {
|
||||||
|
return apiError.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP status errors
|
||||||
|
switch (axiosError.response.status) {
|
||||||
|
case 400:
|
||||||
|
return 'Invalid request. Please check your input.';
|
||||||
|
case 401:
|
||||||
|
return 'Invalid credentials. Please try again.';
|
||||||
|
case 403:
|
||||||
|
return 'You do not have permission to perform this action.';
|
||||||
|
case 404:
|
||||||
|
return 'Resource not found.';
|
||||||
|
case 422:
|
||||||
|
return 'Validation error. Please check your input.';
|
||||||
|
case 500:
|
||||||
|
return 'Server error. Please try again later.';
|
||||||
|
default:
|
||||||
|
return 'An unexpected error occurred.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'An unexpected error occurred.';
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
220
mobile/app/lib/auth-store.ts
Normal file
220
mobile/app/lib/auth-store.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { authApi, userApi, handleApiError } from './api';
|
||||||
|
import { User, LoginRequest, SignupRequest } from './types';
|
||||||
|
|
||||||
|
// Storage keys
|
||||||
|
const USER_KEY = 'auth_user';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
// State
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitialized: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initialize: () => Promise<void>;
|
||||||
|
login: (data: LoginRequest) => Promise<void>;
|
||||||
|
signup: (data: SignupRequest) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
updateUser: (data: Partial<User>) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
|
// Initial state
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
isInitialized: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
// Initialize auth state on app start
|
||||||
|
initialize: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
// Check for stored token
|
||||||
|
const token = await authApi.getStoredToken();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
// Try to fetch current user
|
||||||
|
try {
|
||||||
|
const user = await userApi.getMe();
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitialized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Token invalid or expired - try to refresh
|
||||||
|
try {
|
||||||
|
await authApi.refreshToken();
|
||||||
|
const user = await userApi.getMe();
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isInitialized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Refresh failed - clear auth state
|
||||||
|
await authApi.logout();
|
||||||
|
await AsyncStorage.removeItem(USER_KEY);
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitialized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No token - check for cached user data for offline display
|
||||||
|
const cachedUser = await AsyncStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: cachedUser ? JSON.parse(cachedUser) : null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitialized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isInitialized: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: handleApiError(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Login
|
||||||
|
login: async (data: LoginRequest) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const response = await authApi.login(data);
|
||||||
|
const { user } = response;
|
||||||
|
|
||||||
|
// Cache user data
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: handleApiError(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Signup
|
||||||
|
signup: async (data: SignupRequest) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const response = await authApi.signup(data);
|
||||||
|
const { user } = response;
|
||||||
|
|
||||||
|
// Cache user data
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
|
set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: handleApiError(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true });
|
||||||
|
|
||||||
|
await authApi.logout();
|
||||||
|
await AsyncStorage.removeItem(USER_KEY);
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Still clear local state even if API call fails
|
||||||
|
await AsyncStorage.removeItem(USER_KEY);
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear error
|
||||||
|
clearError: () => {
|
||||||
|
set({ error: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set user (for updates from other parts of the app)
|
||||||
|
setUser: (user: User) => {
|
||||||
|
set({ user });
|
||||||
|
AsyncStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
updateUser: async (data: Partial<User>) => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
const updatedUser = await userApi.updateMe(data);
|
||||||
|
await AsyncStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
|
||||||
|
|
||||||
|
set({
|
||||||
|
user: updatedUser,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
error: handleApiError(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Selector hooks for specific state slices
|
||||||
|
export const useUser = () => useAuthStore((state) => state.user);
|
||||||
|
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
||||||
|
export const useIsLoading = () => useAuthStore((state) => state.isLoading);
|
||||||
|
export const useAuthError = () => useAuthStore((state) => state.error);
|
||||||
|
export const useIsInitialized = () => useAuthStore((state) => state.isInitialized);
|
||||||
93
mobile/app/lib/types.ts
Normal file
93
mobile/app/lib/types.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// User types
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth types
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignupRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
token: string;
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRequest {
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource types
|
||||||
|
export interface Resource {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
url?: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
resourceCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Community types
|
||||||
|
export interface CommunitySpace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
memberCount: number;
|
||||||
|
isPrivate: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User preferences
|
||||||
|
export interface UserPreferences {
|
||||||
|
notifications: boolean;
|
||||||
|
emailUpdates: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Error response
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic API response wrapper
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
837
mobile/package-lock.json
generated
837
mobile/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,12 +16,14 @@
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@tanstack/react-query": "^5.25.0",
|
"@tanstack/react-query": "^5.25.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"expo": "^51.0.0",
|
"expo": "^51.0.0",
|
||||||
"expo-constants": "~15.4.0",
|
"expo-constants": "~15.4.0",
|
||||||
"expo-linking": "~6.0.0",
|
"expo-linking": "~6.0.0",
|
||||||
"expo-router": "^3.4.0",
|
"expo-router": "^3.4.0",
|
||||||
|
"expo-secure-store": "^15.0.7",
|
||||||
"expo-splash-screen": "^31.0.11",
|
"expo-splash-screen": "^31.0.11",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue