Sign-in gains a "Keep me signed in" checkbox: checked issues a 30-day token and prefills the address next launch, unchecked drops the session to 12 hours and forgets the address. The TTL is chosen server-side in the login route. Emailed codes are now reachable without retyping. OtpField opts into the iOS one-time-code keyboard suggestion and raises a paste chip when the user returns from Gmail with a code on the clipboard. The mails put the code first in the subject and body, which is what makes Gmail render its "Copy code" notification action at all. Fixes found along the way: - Session was wiped on every launch. decodeJwtExp used atob, which neither RN 0.74 nor Expo SDK 51 defines, so it threw, returned null, and the caller read that as "expired" and deleted the token. Replaced with a dependency-free base64url decoder, and restore now only discards a session it can prove is expired. - Verification and reset codes counted attempts but never enforced them, leaving a 6-digit code open to unlimited guessing. Both routes now charge the attempt before comparing so concurrent guesses can't race past the cap of five, and compare in constant time. - A wrong verification code showed the "Verified" success screen: onModalHide fired unconditionally, so the failure state advanced the flow. Only an explicit "verified" state does that now. - fetchAPI discarded the server's error body, so the UI substring-matched synthetic status strings and showed "Could not sign in" for everything. It now throws ApiError carrying status and the server's message. - Login answered a missing account faster than a wrong password; it now runs the same scrypt work either way. - Blank email or password is caught client-side instead of surfacing as an opaque 400, and a failed attempt only clears the password on a 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
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 (
|
|
<KeyboardAvoidingView
|
|
className="flex-1 bg-white"
|
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
|
|
>
|
|
<ScrollView
|
|
className="flex-1 bg-white"
|
|
keyboardShouldPersistTaps="handled"
|
|
contentContainerStyle={{ flexGrow: 1 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View className="flex-1 bg-white">
|
|
<View className="relative w-full h-[250px]">
|
|
<Image
|
|
source={images.signUpCar}
|
|
alt="Car"
|
|
className="z-0 w-full h-[250px]"
|
|
resizeMode="contain"
|
|
/>
|
|
|
|
<Text className="text-2xl text-black font-JakartaSemiBold absolute bottom-5 left-5">
|
|
Welcome 👋
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="p-5">
|
|
<InputField
|
|
label="Email"
|
|
placeholder="karim@email.com"
|
|
icon={icons.email}
|
|
value={form.email}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
email: value,
|
|
}))
|
|
}
|
|
keyboardType="email-address"
|
|
autoComplete="email"
|
|
textContentType="username"
|
|
/>
|
|
|
|
<InputField
|
|
label="Password"
|
|
placeholder="••••••••"
|
|
icon={icons.lock}
|
|
secureTextEntry
|
|
value={form.password}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
password: value,
|
|
}))
|
|
}
|
|
autoComplete="current-password"
|
|
textContentType="password"
|
|
/>
|
|
|
|
<TouchableOpacity
|
|
onPress={() => setRemember((current) => !current)}
|
|
activeOpacity={0.7}
|
|
accessibilityRole="checkbox"
|
|
accessibilityState={{ checked: remember }}
|
|
className="flex-row items-center mt-4"
|
|
>
|
|
<View
|
|
className={`h-6 w-6 rounded-md items-center justify-center border-2 ${
|
|
remember
|
|
? "bg-primary-500 border-primary-500"
|
|
: "bg-white border-neutral-300"
|
|
}`}
|
|
>
|
|
{remember ? (
|
|
<Text className="text-white text-xs font-JakartaBold">✓</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<Text className="ml-3 font-JakartaMedium text-[15px] text-black">
|
|
Keep me signed in
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<CustomButton
|
|
title={busy ? "Signing in…" : "Sign In"}
|
|
onPress={onSignInPress}
|
|
disabled={busy}
|
|
className="mt-6"
|
|
/>
|
|
|
|
<TouchableOpacity onPress={openReset} className="mt-4">
|
|
<Text className="text-primary-500 text-center font-JakartaMedium">
|
|
Forgot password?
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<OAuth title="Sign in with Google" />
|
|
|
|
<Link
|
|
href="/sign-up"
|
|
className="text-base text-center text-general-200 mt-10"
|
|
>
|
|
<Text>Don't have an account? </Text>
|
|
<Text className="text-primary-500">Sign up</Text>
|
|
</Link>
|
|
</View>
|
|
|
|
<ReactNativeModal
|
|
isVisible={reset.state === "request"}
|
|
onBackdropPress={closeReset}
|
|
>
|
|
<View className="bg-white px-7 py-9 rounded-2xl min-h-[280px]">
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2">
|
|
Reset password
|
|
</Text>
|
|
|
|
<Text className="font-Jakarta mb-5">
|
|
Enter your email and we'll send you a 6-digit reset code.
|
|
</Text>
|
|
|
|
<InputField
|
|
label="Email"
|
|
placeholder="karim@email.com"
|
|
icon={icons.email}
|
|
value={reset.email}
|
|
keyboardType="email-address"
|
|
autoComplete="email"
|
|
textContentType="username"
|
|
onChangeText={(email) =>
|
|
setReset((prev) => ({ ...prev, email }))
|
|
}
|
|
/>
|
|
|
|
{reset.error ? (
|
|
<Text className="text-rose-500 text-sm mt-1">{reset.error}</Text>
|
|
) : null}
|
|
|
|
<CustomButton
|
|
title={reset.busy ? "Sending…" : "Send Code"}
|
|
onPress={onRequestReset}
|
|
disabled={reset.busy}
|
|
className="mt-5"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
|
|
<ReactNativeModal
|
|
isVisible={reset.state === "reset"}
|
|
onBackdropPress={closeReset}
|
|
>
|
|
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2">
|
|
Enter new password
|
|
</Text>
|
|
|
|
<Text className="font-Jakarta mb-5">
|
|
We've sent a reset code to {reset.email}
|
|
</Text>
|
|
|
|
{reset.devCode ? (
|
|
<View className="bg-amber-50 border border-amber-300 rounded-xl p-3 mb-5">
|
|
<Text className="text-sm text-amber-700 font-Jakarta">
|
|
Email delivery is not configured on this server. Your reset
|
|
code is <Text className="font-JakartaBold">{reset.devCode}</Text>
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<OtpField
|
|
value={reset.code}
|
|
onChange={(code) =>
|
|
setReset((prev) => ({ ...prev, code, error: "" }))
|
|
}
|
|
/>
|
|
|
|
<InputField
|
|
label="New password"
|
|
icon={icons.lock}
|
|
placeholder="••••••••"
|
|
secureTextEntry
|
|
value={reset.password}
|
|
onChangeText={(password) =>
|
|
setReset((prev) => ({ ...prev, password }))
|
|
}
|
|
autoComplete="new-password"
|
|
textContentType="newPassword"
|
|
/>
|
|
|
|
{reset.error ? (
|
|
<Text className="text-rose-500 text-sm mt-1">{reset.error}</Text>
|
|
) : null}
|
|
|
|
<CustomButton
|
|
title={reset.busy ? "Resetting…" : "Reset Password"}
|
|
onPress={onSubmitReset}
|
|
disabled={reset.busy}
|
|
className="mt-5 bg-emerald-500"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
};
|
|
|
|
export default SignIn;
|