WellNuo/app/(auth)/verify-otp.tsx
Sergei 0b0b46ab3e Refactor auth to Email + OTP flow with dev bypass
- 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>
2025-12-24 10:09:10 -08:00

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