- Convert login from username/password to email input - Add OTP verification screen with auto-login for dev email - Add dev email bypass (serter2069@gmail.com) using legacy anandk credentials - Add saveEmail/getStoredEmail methods to API service - Add email field to User type - Clean up logout to also clear stored email 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
388 lines
9.9 KiB
TypeScript
388 lines
9.9 KiB
TypeScript
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
TextInput,
|
|
ActivityIndicator,
|
|
} from 'react-native';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { ErrorMessage } from '@/components/ui/ErrorMessage';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
|
|
const CODE_LENGTH = 6;
|
|
|
|
export default function VerifyOTPScreen() {
|
|
const { email, skipOtp } = useLocalSearchParams<{ email: string; skipOtp: string }>();
|
|
const { verifyOtp, requestOtp, isLoading: authLoading, error: authError, clearError } = useAuth();
|
|
|
|
const [code, setCode] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isResending, setIsResending] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [resendCountdown, setResendCountdown] = useState(0);
|
|
|
|
const inputRef = useRef<TextInput>(null);
|
|
|
|
// Handle skip OTP (dev mode)
|
|
useEffect(() => {
|
|
if (skipOtp === '1' && email) {
|
|
handleAutoLogin();
|
|
}
|
|
}, [skipOtp, email]);
|
|
|
|
const handleAutoLogin = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const success = await verifyOtp(email!, '000000'); // Bypass code
|
|
if (success) {
|
|
router.replace('/(tabs)');
|
|
} else {
|
|
setError('Auto-login failed');
|
|
}
|
|
} catch (err) {
|
|
setError('Auto-login failed');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Focus input on mount (only if not skipping OTP)
|
|
useEffect(() => {
|
|
if (skipOtp !== '1') {
|
|
setTimeout(() => {
|
|
inputRef.current?.focus();
|
|
}, 100);
|
|
}
|
|
}, [skipOtp]);
|
|
|
|
// Countdown timer for resend
|
|
useEffect(() => {
|
|
if (resendCountdown > 0) {
|
|
const timer = setTimeout(() => setResendCountdown(resendCountdown - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [resendCountdown]);
|
|
|
|
const handleCodeChange = (text: string) => {
|
|
// Only allow digits
|
|
const digits = text.replace(/\D/g, '').slice(0, CODE_LENGTH);
|
|
setCode(digits);
|
|
setError(null);
|
|
|
|
// Auto-submit when code is complete
|
|
if (digits.length === CODE_LENGTH) {
|
|
handleVerify(digits);
|
|
}
|
|
};
|
|
|
|
const handleVerify = useCallback(async (verifyCode?: string) => {
|
|
const codeToVerify = verifyCode || code;
|
|
|
|
if (codeToVerify.length !== CODE_LENGTH) {
|
|
setError('Please enter the 6-digit code');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const success = await verifyOtp(email!, codeToVerify);
|
|
|
|
if (success) {
|
|
router.replace('/(tabs)');
|
|
} else {
|
|
setError(authError?.message || 'Invalid code. Please try again.');
|
|
setCode('');
|
|
}
|
|
} catch (err) {
|
|
setError('Something went wrong. Please try again.');
|
|
setCode('');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [code, email, verifyOtp, authError]);
|
|
|
|
const handleResend = useCallback(async () => {
|
|
if (resendCountdown > 0) return;
|
|
|
|
setIsResending(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await requestOtp(email!);
|
|
|
|
if (result.success) {
|
|
setResendCountdown(60); // 60 seconds cooldown
|
|
setCode('');
|
|
} else {
|
|
setError('Failed to resend code');
|
|
}
|
|
} catch (err) {
|
|
setError('Failed to resend code. Please try again.');
|
|
} finally {
|
|
setIsResending(false);
|
|
}
|
|
}, [email, resendCountdown, requestOtp]);
|
|
|
|
// Render code input boxes
|
|
const renderCodeBoxes = () => {
|
|
const boxes = [];
|
|
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
const isActive = i === code.length;
|
|
const isFilled = i < code.length;
|
|
|
|
boxes.push(
|
|
<View
|
|
key={i}
|
|
style={[
|
|
styles.codeBox,
|
|
isActive && styles.codeBoxActive,
|
|
isFilled && styles.codeBoxFilled,
|
|
]}
|
|
>
|
|
<Text style={styles.codeDigit}>
|
|
{code[i] || ''}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
return boxes;
|
|
};
|
|
|
|
// Show loading screen for auto-login
|
|
if (skipOtp === '1') {
|
|
return (
|
|
<View style={styles.autoLoginContainer}>
|
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
|
<Text style={styles.autoLoginText}>Signing in...</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={styles.container}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={styles.scrollContent}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Back Button */}
|
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<View style={styles.iconContainer}>
|
|
<Ionicons name="mail-open-outline" size={48} color={AppColors.primary} />
|
|
</View>
|
|
<Text style={styles.title}>Check your email</Text>
|
|
<Text style={styles.subtitle}>
|
|
We sent a verification code to
|
|
</Text>
|
|
<Text style={styles.email}>{email}</Text>
|
|
</View>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<ErrorMessage
|
|
message={error}
|
|
onDismiss={() => setError(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Code Input */}
|
|
<TouchableOpacity
|
|
style={styles.codeContainer}
|
|
activeOpacity={1}
|
|
onPress={() => inputRef.current?.focus()}
|
|
>
|
|
{renderCodeBoxes()}
|
|
</TouchableOpacity>
|
|
|
|
{/* Hidden actual input */}
|
|
<TextInput
|
|
ref={inputRef}
|
|
style={styles.hiddenInput}
|
|
value={code}
|
|
onChangeText={handleCodeChange}
|
|
keyboardType="number-pad"
|
|
maxLength={CODE_LENGTH}
|
|
autoComplete="one-time-code"
|
|
textContentType="oneTimeCode"
|
|
/>
|
|
|
|
{/* Verify Button */}
|
|
<Button
|
|
title="Verify"
|
|
onPress={() => handleVerify()}
|
|
loading={isLoading}
|
|
disabled={code.length !== CODE_LENGTH}
|
|
fullWidth
|
|
size="lg"
|
|
/>
|
|
|
|
{/* Resend */}
|
|
<View style={styles.resendContainer}>
|
|
<Text style={styles.resendText}>Didn't receive the code? </Text>
|
|
{resendCountdown > 0 ? (
|
|
<Text style={styles.resendCountdown}>
|
|
Resend in {resendCountdown}s
|
|
</Text>
|
|
) : (
|
|
<TouchableOpacity onPress={handleResend} disabled={isResending}>
|
|
<Text style={[styles.resendLink, isResending && styles.resendLinkDisabled]}>
|
|
{isResending ? 'Sending...' : 'Resend'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Change email */}
|
|
<TouchableOpacity style={styles.changeEmailButton} onPress={() => router.back()}>
|
|
<Text style={styles.changeEmailText}>Use a different email</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
scrollContent: {
|
|
flexGrow: 1,
|
|
paddingHorizontal: Spacing.lg,
|
|
paddingTop: Spacing.xl,
|
|
paddingBottom: Spacing.xl,
|
|
},
|
|
autoLoginContainer: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: Spacing.md,
|
|
},
|
|
autoLoginText: {
|
|
fontSize: FontSizes.lg,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
backButton: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: AppColors.surface,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
header: {
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.xl,
|
|
},
|
|
iconContainer: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: `${AppColors.primary}15`,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
title: {
|
|
fontSize: FontSizes['2xl'],
|
|
fontWeight: '700',
|
|
color: AppColors.textPrimary,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
subtitle: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
email: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
marginTop: Spacing.xs,
|
|
},
|
|
codeContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
gap: Spacing.sm,
|
|
marginBottom: Spacing.xl,
|
|
},
|
|
codeBox: {
|
|
width: 48,
|
|
height: 56,
|
|
borderRadius: BorderRadius.md,
|
|
borderWidth: 2,
|
|
borderColor: AppColors.border,
|
|
backgroundColor: AppColors.surface,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
codeBoxActive: {
|
|
borderColor: AppColors.primary,
|
|
},
|
|
codeBoxFilled: {
|
|
borderColor: AppColors.primary,
|
|
backgroundColor: `${AppColors.primary}10`,
|
|
},
|
|
codeDigit: {
|
|
fontSize: FontSizes['2xl'],
|
|
fontWeight: '700',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
hiddenInput: {
|
|
position: 'absolute',
|
|
opacity: 0,
|
|
height: 0,
|
|
width: 0,
|
|
},
|
|
resendContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginTop: Spacing.xl,
|
|
},
|
|
resendText: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
resendLink: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.primary,
|
|
fontWeight: '600',
|
|
},
|
|
resendLinkDisabled: {
|
|
opacity: 0.5,
|
|
},
|
|
resendCountdown: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textMuted,
|
|
},
|
|
changeEmailButton: {
|
|
alignItems: 'center',
|
|
marginTop: Spacing.lg,
|
|
},
|
|
changeEmailText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
textDecorationLine: 'underline',
|
|
},
|
|
});
|