import React, { useCallback, useEffect, useRef, useState } from "react"; import { Platform } from "react-native"; import { useLocalSearchParams } from "expo-router"; import { useNavigation } from "../../context/NavigationContext"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import * as Haptics from "expo-haptics"; import Toast from "react-native-toast-message"; import { MutedText, Text } from "../../components/text"; import { Button, IconButton } from "../../components/button"; import { withClickTracking } from "../../services/telemetry"; import { Input } from "../../components/input"; import AuthService from "../../services/auth.service"; import { useAuth } from "../../hooks/useAuth"; import { useAppConfig } from "../../context/AppConfigContext"; import { getErrorMessage } from "../../utils/error"; import { AuthPage, type AuthPageLayout } from "./AuthPage"; export interface VerifyEmailScreenProps { /** See `AuthPageLayout`. Defaults to the card, as it always was. */ layout?: AuthPageLayout; } export function VerifyEmailScreen({ layout }: VerifyEmailScreenProps = {}) { const { refreshUser, logout, user } = useAuth(); const { routes } = useAppConfig(); const router = useNavigation(); const verifiedRef = useRef(false); const autoSubmittingRef = useRef(false); const proceedAfterVerification = useCallback(async () => { const freshUser = await AuthService.refreshUserData(); await refreshUser(); if (freshUser?.onboarding_step === "mfa_enrollment") { router.replace(routes.mfaEnroll); } else if (freshUser?.onboarding_step === "import") { router.replace(routes.importData); } else { // `complete`, null, an unknown step, or a missing freshUser // record — fall through to home rather than leave the user // stuck on the verify-email screen after the server has // accepted their code. The consumer's route guard re-evaluates // and bounces if onboarding actually isn't done. router.replace(routes.home); } }, [refreshUser, router, routes]); const params = useLocalSearchParams<{ email?: string; code?: string }>(); const [code, setCode] = useState(""); const [error, setError] = useState(""); const [autoSubmitCode, setAutoSubmitCode] = useState(null); useEffect(() => { let urlCode: string | null = null; if (params.code) { urlCode = params.code; } else if (Platform.OS === "web") { const urlParams = new URLSearchParams(window.location.search); urlCode = urlParams.get("code"); } if (urlCode) { setCode(urlCode); setAutoSubmitCode(urlCode); } }, [params.code]); const [isLoading, setIsLoading] = useState(false); const [resendCooldown, setResendCooldown] = useState(0); const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; useEffect(() => { // `cancelled` guards the async hop inside the tick — without it, // a `refreshUserData()` that resolves after this effect's // cleanup fired would still call Toast / Haptics / refreshUser // on a stale closure (and the cleanup-cleared interval can't // help once the await is already in flight). let cancelled = false; const interval = setInterval(async () => { if (verifiedRef.current) { return; } try { const freshUser = await AuthService.refreshUserData(); if (cancelled) { return; } if (freshUser?.email_verified) { verifiedRef.current = true; clearInterval(interval); Toast.show({ type: "success", text1: "Email verified!", text2: "Your email has been verified successfully.", visibilityTime: 3000, }); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); if (cancelled) { return; } await refreshUser(); if (cancelled) { return; } void proceedAfterVerification(); } } catch { // Silently ignore polling errors } }, 5000); return () => { cancelled = true; clearInterval(interval); }; }, [refreshUser, proceedAfterVerification]); useEffect(() => { if (user?.email_verified && !verifiedRef.current) { verifiedRef.current = true; void proceedAfterVerification(); } }, [user?.email_verified, proceedAfterVerification]); useEffect(() => { if (resendCooldown <= 0) { return; } const timer = setTimeout(() => setResendCooldown((c) => c - 1), 1000); return () => clearTimeout(timer); }, [resendCooldown]); const handleVerify = async () => { if (!code.trim()) { setError("Verification code is required"); return; } setIsLoading(true); try { await AuthService.verifyEmail(code.trim()); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Toast.show({ type: "success", text1: "Email verified!", text2: "Your email has been verified successfully.", visibilityTime: 3000, }); await refreshUser(); void proceedAfterVerification(); } catch (e: unknown) { setError(getErrorMessage(e, "Invalid verification code")); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } }; useEffect(() => { if (autoSubmitCode && !isLoading && !autoSubmittingRef.current) { autoSubmittingRef.current = true; setAutoSubmitCode(null); (async () => { setIsLoading(true); try { await AuthService.verifyEmail(autoSubmitCode.trim()); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Toast.show({ type: "success", text1: "Email verified!", text2: "Your email has been verified successfully.", visibilityTime: 3000, }); await refreshUser(); void proceedAfterVerification(); } catch (e: unknown) { setError(getErrorMessage(e, "Invalid verification code")); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); } finally { setIsLoading(false); } })(); } }, [autoSubmitCode, isLoading, refreshUser, proceedAfterVerification]); const handleResend = async () => { if (resendCooldown > 0) { return; } try { await AuthService.resendVerification(); setResendCooldown(30); await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Toast.show({ type: "success", text1: "Verification email sent", text2: "Check your inbox.", visibilityTime: 3000, }); } catch (e: unknown) { await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); Toast.show({ type: "error", text1: "Couldn't resend", text2: getErrorMessage(e, "Please try again in a moment."), visibilityTime: 4000, }); } }; return ( logout()} accessibilityLabel="Sign out" accessibilityRole="button" > } footer={ <> } > { setCode(text); if (error) { setError(""); } }} error={error} placeholder="Enter the 6-digit code" autoCapitalize="none" keyboardType="number-pad" autoFocus returnKeyType="done" onSubmitEditing={handleVerify} accessibilityLabel="Verification code" testID="verify-code-input" /> Didn't receive the code?{" "} {resendCooldown > 0 ? ( Resend ({resendCooldown}) ) : ( Resend )} ); } export default VerifyEmailScreen;