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>
386 lines
11 KiB
TypeScript
386 lines
11 KiB
TypeScript
import { Link, router } from "expo-router";
|
|
import { useCallback, 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 { useSession } from "@/lib/session";
|
|
|
|
const ROLES = [
|
|
{
|
|
value: "rider",
|
|
title: "I need a ride",
|
|
description: "Book rides and get where you're going",
|
|
},
|
|
{
|
|
value: "driver",
|
|
title: "I want to drive",
|
|
description: "Offer rides and earn money with your car",
|
|
},
|
|
] as const;
|
|
|
|
type Role = (typeof ROLES)[number]["value"];
|
|
|
|
const SignUp = () => {
|
|
const { setSession } = useSession();
|
|
|
|
const [role, setRole] = useState<Role>("rider");
|
|
const [form, setForm] = useState({
|
|
name: "",
|
|
email: "",
|
|
phone: "",
|
|
password: "",
|
|
});
|
|
|
|
// "verified" is a hand-off state: it hides the code modal so its onModalHide
|
|
// can bring up the success modal, since two modals can't cross-fade.
|
|
const [verification, setVerification] = useState({
|
|
state: "default" as "default" | "pending" | "verified" | "success",
|
|
error: "",
|
|
code: "",
|
|
devCode: "",
|
|
busy: false,
|
|
});
|
|
|
|
const onSignUpPress = async () => {
|
|
if (!form.name.trim() || !form.email.trim() || !form.password) {
|
|
Alert.alert(
|
|
"Missing information",
|
|
"Please fill in your name, email and password.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (form.phone.trim() && !/^[0-9\s\-()+.]+$/.test(form.phone)) {
|
|
Alert.alert(
|
|
"Invalid phone number",
|
|
"Enter a valid Lebanese number, e.g. 70 123 456.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetchAPI("/(api)/auth/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: form.name,
|
|
email: form.email,
|
|
phone: form.phone.trim(),
|
|
password: form.password,
|
|
role,
|
|
}),
|
|
});
|
|
|
|
setVerification({
|
|
state: "pending",
|
|
error: "",
|
|
code: "",
|
|
busy: false,
|
|
devCode:
|
|
(response as { data?: { devCode?: string } })?.data?.devCode ?? "",
|
|
});
|
|
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
password: "",
|
|
}));
|
|
} catch (err: any) {
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
password: "",
|
|
}));
|
|
Alert.alert(
|
|
"Error",
|
|
err instanceof ApiError && err.status < 500
|
|
? err.message
|
|
: "Could not create your account.",
|
|
);
|
|
}
|
|
};
|
|
|
|
const onPressVerify = useCallback(
|
|
async (code: string) => {
|
|
if (!/^\d{6}$/.test(code)) {
|
|
setVerification((prevVerification) => ({
|
|
...prevVerification,
|
|
error: "Enter the 6-digit code.",
|
|
}));
|
|
return;
|
|
}
|
|
|
|
setVerification((prevVerification) =>
|
|
// Guard the double submit that auto-verify + a button tap would cause.
|
|
prevVerification.busy
|
|
? prevVerification
|
|
: { ...prevVerification, busy: true, error: "" },
|
|
);
|
|
|
|
try {
|
|
const response = await fetchAPI("/(api)/auth/verify", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: form.email, code }),
|
|
});
|
|
|
|
await setSession(response.data);
|
|
setVerification((prevVerification) => ({
|
|
...prevVerification,
|
|
state: "verified",
|
|
busy: false,
|
|
}));
|
|
} catch (err: any) {
|
|
// Stay on the code modal so the user can retry; only a real success
|
|
// advances the flow.
|
|
setVerification((prevVerification) => ({
|
|
...prevVerification,
|
|
code: "",
|
|
busy: false,
|
|
error:
|
|
err instanceof ApiError && err.status < 500
|
|
? err.message
|
|
: "Verification failed. Please try again.",
|
|
}));
|
|
}
|
|
},
|
|
[form.email, setSession],
|
|
);
|
|
|
|
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">
|
|
Create Your Account
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="p-5">
|
|
<Text className="text-lg font-JakartaSemiBold mb-3">
|
|
How will you use Waseel?
|
|
</Text>
|
|
<View className="flex-row gap-3 mb-4">
|
|
{ROLES.map((option) => {
|
|
const selected = role === option.value;
|
|
return (
|
|
<TouchableOpacity
|
|
key={option.value}
|
|
onPress={() => setRole(option.value)}
|
|
activeOpacity={0.8}
|
|
className={`flex-1 justify-center rounded-2xl border p-4 ${
|
|
selected
|
|
? "border-primary-500 bg-primary-500/10"
|
|
: "border-neutral-100 bg-neutral-100"
|
|
}`}
|
|
>
|
|
<Image
|
|
source={option.value === "driver" ? icons.dollar : icons.map}
|
|
alt={`${option.title} icon`}
|
|
className="h-7 w-7 mb-2"
|
|
resizeMode="contain"
|
|
/>
|
|
<Text
|
|
className={`text-[15px] font-JakartaBold ${
|
|
selected ? "text-primary-500" : "text-black"
|
|
}`}
|
|
>
|
|
{option.title}
|
|
</Text>
|
|
<Text className="text-xs text-neutral-400 font-Jakarta mt-1">
|
|
{option.description}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
<InputField
|
|
label="Name"
|
|
placeholder="Karim Haddad"
|
|
icon={icons.person}
|
|
value={form.name}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
name: value,
|
|
}))
|
|
}
|
|
autoCapitalize="words"
|
|
/>
|
|
|
|
<InputField
|
|
label="Email"
|
|
placeholder="karim@email.com"
|
|
icon={icons.email}
|
|
value={form.email}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
email: value,
|
|
}))
|
|
}
|
|
keyboardType="email-address"
|
|
/>
|
|
|
|
<InputField
|
|
label="Phone (optional)"
|
|
placeholder="70 123 456"
|
|
icon={icons.chat}
|
|
value={form.phone}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
phone: value,
|
|
}))
|
|
}
|
|
keyboardType="phone-pad"
|
|
/>
|
|
|
|
<InputField
|
|
label="Password"
|
|
placeholder="••••••••"
|
|
icon={icons.lock}
|
|
secureTextEntry
|
|
value={form.password}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
password: value,
|
|
}))
|
|
}
|
|
/>
|
|
|
|
<CustomButton
|
|
title="Sign Up"
|
|
onPress={onSignUpPress}
|
|
className="mt-6"
|
|
/>
|
|
|
|
<OAuth title="Sign up with Google" />
|
|
|
|
<Link
|
|
href="/sign-in"
|
|
className="text-base text-center text-general-200 mt-10"
|
|
>
|
|
<Text>Already have an account? </Text>
|
|
<Text className="text-primary-500">Sign in</Text>
|
|
</Link>
|
|
</View>
|
|
|
|
<ReactNativeModal
|
|
onModalHide={() =>
|
|
setVerification((prevVerification) =>
|
|
prevVerification.state === "verified"
|
|
? { ...prevVerification, state: "success" }
|
|
: prevVerification,
|
|
)
|
|
}
|
|
isVisible={verification.state === "pending"}
|
|
>
|
|
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2">
|
|
Verification
|
|
</Text>
|
|
|
|
<Text className="font-Jakarta mb-5">
|
|
We've sent a verification code to {form.email}
|
|
</Text>
|
|
|
|
{verification.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
|
|
verification code is{" "}
|
|
<Text className="font-JakartaBold">{verification.devCode}</Text>
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<OtpField
|
|
value={verification.code}
|
|
onChange={(code) =>
|
|
setVerification((prevVerification) => ({
|
|
...prevVerification,
|
|
code,
|
|
error: "",
|
|
}))
|
|
}
|
|
onComplete={onPressVerify}
|
|
/>
|
|
|
|
{verification.error ? (
|
|
<Text className="text-rose-500 text-sm mt-1">
|
|
{verification.error}
|
|
</Text>
|
|
) : null}
|
|
|
|
<CustomButton
|
|
title={verification.busy ? "Verifying…" : "Verify Email"}
|
|
onPress={() => onPressVerify(verification.code)}
|
|
disabled={verification.busy}
|
|
className="mt-5 bg-emerald-500"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
<ReactNativeModal isVisible={verification.state === "success"}>
|
|
<View className="bg-white px-7 py-9 rounded-2xl min-h-[300px]">
|
|
<Image
|
|
source={images.check}
|
|
alt="Check"
|
|
className="w-[110px] h-[110px] mx-auto my-5"
|
|
/>
|
|
|
|
<Text className="text-3xl font-JakartaBold text-center">
|
|
Verified
|
|
</Text>
|
|
|
|
<Text className="text-base text-gray-400 font-Jakarta text-center mt-2">
|
|
You've succesfully verified your account.
|
|
</Text>
|
|
|
|
<CustomButton
|
|
title="Browse Home"
|
|
onPress={() => router.push("/")}
|
|
className="mt-5"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
};
|
|
|
|
export default SignUp;
|