import { Link, useRouter } from "expo-router"; import { useCallback, useEffect, useState } from "react"; import { Alert, Image, KeyboardAvoidingView, Platform, ScrollView, Text, TouchableOpacity, View, } from "react-native"; import ReactNativeModal from "react-native-modal"; import { CustomButton } from "@/components/custom-button"; import { InputField } from "@/components/input-field"; import { OAuth } from "@/components/oauth"; import { OtpField } from "@/components/otp-field"; import { icons, images } from "@/constants"; import { ApiError, fetchAPI } from "@/lib/fetch"; import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session"; const SignIn = () => { const router = useRouter(); const { setSession } = useSession(); const [form, setForm] = useState({ email: "", password: "", }); const [busy, setBusy] = useState(false); // On by default: a rider signing in on their own phone shouldn't have to // opt into staying signed in. Unchecking it shortens the session to 12h and // stops the address being prefilled next time. const [remember, setRemember] = useState(true); useEffect(() => { let cancelled = false; getRememberedEmail() .then((email) => { if (!email || cancelled) return; // SecureStore can resolve after the user has started typing, so only // fill a field that's still untouched. setForm((prevForm) => prevForm.email ? prevForm : { ...prevForm, email }, ); }) .catch(() => { // Nothing stored, or the keychain is unavailable: start blank. }); return () => { cancelled = true; }; }, []); // Forgot-password flow: "request" collects the email, "reset" collects the // emailed code and a new password. const [reset, setReset] = useState({ state: "closed" as "closed" | "request" | "reset", email: "", code: "", password: "", devCode: "", error: "", busy: false, }); const openReset = () => setReset({ state: "request", email: form.email, code: "", password: "", devCode: "", error: "", busy: false, }); const closeReset = () => setReset((prev) => ({ ...prev, state: "closed" })); const onRequestReset = async () => { if (!reset.email.trim()) { setReset((prev) => ({ ...prev, error: "Enter your email address." })); return; } setReset((prev) => ({ ...prev, busy: true, error: "" })); try { const response = await fetchAPI("/(api)/auth/forgot-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: reset.email.trim() }), }); setReset((prev) => ({ ...prev, state: "reset", busy: false, devCode: (response as { data?: { devCode?: string } })?.data?.devCode ?? "", })); } catch { // The endpoint hides whether the email exists, so move on regardless. setReset((prev) => ({ ...prev, state: "reset", busy: false })); } }; const onSubmitReset = async () => { if (!/^\d{6}$/.test(reset.code)) { setReset((prev) => ({ ...prev, error: "Enter the 6-digit code." })); return; } if (reset.password.length < 8) { setReset((prev) => ({ ...prev, error: "Password must be at least 8 characters.", })); return; } setReset((prev) => ({ ...prev, busy: true, error: "" })); try { const response = await fetchAPI("/(api)/auth/reset-password", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: reset.email.trim(), code: reset.code, password: reset.password, }), }); await setSession(response.data); await rememberEmail(remember ? reset.email.trim() : null); setReset((prev) => ({ ...prev, state: "closed", busy: false })); router.replace("/"); } catch (err: any) { setReset((prev) => ({ ...prev, busy: false, error: err instanceof ApiError && err.status < 500 ? err.message : "Could not reset your password. Please try again.", })); } }; const onSignInPress = useCallback(async () => { if (busy) return; // Catch the blank-field case here: the server answers 400 for it, which // otherwise surfaces as a generic "could not sign in". if (!form.email.trim() || !form.password) { Alert.alert( "Missing information", "Enter both your email and your password.", ); return; } setBusy(true); try { const response = await fetchAPI("/(api)/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: form.email, password: form.password, remember, }), }); await setSession(response.data); await rememberEmail(remember ? form.email.trim() : null); router.replace("/"); } catch (err: any) { const message = err instanceof ApiError && err.status < 500 ? err.message : "Could not sign in. Please try again."; Alert.alert("Error", message); // Only a rejected password is worth retyping. Clearing it after a // network blip or a 403 just makes the next attempt fail differently. if (err instanceof ApiError && err.status === 401) { setForm((prevForm) => ({ ...prevForm, password: "" })); } } finally { setBusy(false); } }, [busy, form.email, form.password, remember, setSession, router]); return ( Car Welcome 👋 setForm((prevForm) => ({ ...prevForm, email: value, })) } keyboardType="email-address" autoComplete="email" textContentType="username" /> setForm((prevForm) => ({ ...prevForm, password: value, })) } autoComplete="current-password" textContentType="password" /> setRemember((current) => !current)} activeOpacity={0.7} accessibilityRole="checkbox" accessibilityState={{ checked: remember }} className="flex-row items-center mt-4" > {remember ? ( ✓ ) : null} Keep me signed in Forgot password? Don't have an account? Sign up Reset password Enter your email and we'll send you a 6-digit reset code. setReset((prev) => ({ ...prev, email })) } /> {reset.error ? ( {reset.error} ) : null} Enter new password We've sent a reset code to {reset.email} {reset.devCode ? ( Email delivery is not configured on this server. Your reset code is {reset.devCode} ) : null} setReset((prev) => ({ ...prev, code, error: "" })) } /> setReset((prev) => ({ ...prev, password })) } autoComplete="new-password" textContentType="newPassword" /> {reset.error ? ( {reset.error} ) : null} ); }; export default SignIn;