411 lines
13 KiB
TypeScript
411 lines
13 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 { useT } from "@/lib/i18n";
|
|
import { getRememberedEmail, rememberEmail, useSession } from "@/lib/session";
|
|
|
|
const SignIn = () => {
|
|
const router = useRouter();
|
|
const { setSession } = useSession();
|
|
const t = useT();
|
|
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: t("auth.signIn.errEmail") }));
|
|
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: t("auth.signIn.errCode") }));
|
|
return;
|
|
}
|
|
|
|
if (reset.password.length < 8) {
|
|
setReset((prev) => ({
|
|
...prev,
|
|
error: t("auth.signIn.errPassword"),
|
|
}));
|
|
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
|
|
: t("auth.signIn.errReset"),
|
|
}));
|
|
}
|
|
};
|
|
|
|
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(
|
|
t("auth.signIn.alertMissingTitle"),
|
|
t("auth.signIn.alertMissingBody"),
|
|
);
|
|
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
|
|
: t("auth.signIn.alertErrorFallback");
|
|
|
|
Alert.alert(t("auth.signIn.alertErrorTitle"), 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, t]);
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
className="flex-1 bg-white dark:bg-neutral-950"
|
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
keyboardVerticalOffset={Platform.OS === "ios" ? 40 : 0}
|
|
>
|
|
<ScrollView
|
|
className="flex-1 bg-white dark:bg-neutral-950"
|
|
keyboardShouldPersistTaps="handled"
|
|
contentContainerStyle={{ flexGrow: 1 }}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View className="flex-1 bg-white dark:bg-neutral-950">
|
|
<View className="relative w-full h-[250px]">
|
|
<Image
|
|
source={images.signUpCar}
|
|
alt={t("auth.signIn.carAlt")}
|
|
className="z-0 w-full h-[250px]"
|
|
resizeMode="contain"
|
|
/>
|
|
|
|
<Text className="text-2xl text-black dark:text-white font-JakartaSemiBold absolute bottom-5 left-5">
|
|
{t("auth.signIn.welcome")}
|
|
</Text>
|
|
</View>
|
|
|
|
<View className="p-5">
|
|
<InputField
|
|
label={t("auth.signIn.email")}
|
|
placeholder={t("auth.signIn.emailPlaceholder")}
|
|
icon={icons.email}
|
|
value={form.email}
|
|
onChangeText={(value) =>
|
|
setForm((prevForm) => ({
|
|
...prevForm,
|
|
email: value,
|
|
}))
|
|
}
|
|
keyboardType="email-address"
|
|
autoComplete="email"
|
|
textContentType="username"
|
|
/>
|
|
|
|
<InputField
|
|
label={t("auth.signIn.password")}
|
|
placeholder={t("auth.signIn.passwordPlaceholder")}
|
|
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 dark:bg-neutral-900 border-neutral-300 dark:border-neutral-700"
|
|
}`}
|
|
>
|
|
{remember ? (
|
|
<Text className="text-white text-xs font-JakartaBold">✓</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<Text className="ml-3 font-JakartaMedium text-[15px] text-black dark:text-white">
|
|
{t("auth.signIn.keepSignedIn")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<CustomButton
|
|
title={busy ? t("auth.signIn.signingIn") : t("auth.signIn.signInBtn")}
|
|
onPress={onSignInPress}
|
|
disabled={busy}
|
|
className="mt-6"
|
|
/>
|
|
|
|
<TouchableOpacity onPress={openReset} className="mt-4">
|
|
<Text className="text-primary-500 text-center font-JakartaMedium">
|
|
{t("auth.signIn.forgotPassword")}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
|
|
<OAuth title={t("auth.signIn.signInGoogle")} />
|
|
|
|
<Link
|
|
href="/sign-up"
|
|
className="text-base text-center text-general-200 dark:text-neutral-400 mt-10"
|
|
>
|
|
<Text className="text-black dark:text-white">{t("auth.signIn.noAccount")}</Text>
|
|
<Text className="text-primary-500">{t("auth.signIn.signUpLink")}</Text>
|
|
</Link>
|
|
</View>
|
|
|
|
<ReactNativeModal
|
|
isVisible={reset.state === "request"}
|
|
onBackdropPress={closeReset}
|
|
>
|
|
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[280px]">
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
|
{t("auth.signIn.reset.title")}
|
|
</Text>
|
|
|
|
<Text className="font-Jakarta mb-5 text-black dark:text-white">
|
|
{t("auth.signIn.reset.requestBody")}
|
|
</Text>
|
|
|
|
<InputField
|
|
label={t("auth.signIn.email")}
|
|
placeholder={t("auth.signIn.reset.emailPlaceholder")}
|
|
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 ? t("auth.signIn.reset.sending") : t("auth.signIn.reset.sendCode")}
|
|
onPress={onRequestReset}
|
|
disabled={reset.busy}
|
|
className="mt-5"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
|
|
<ReactNativeModal
|
|
isVisible={reset.state === "reset"}
|
|
onBackdropPress={closeReset}
|
|
>
|
|
<View className="bg-white dark:bg-neutral-900 px-7 py-9 rounded-2xl min-h-[300px]">
|
|
<Text className="text-2xl font-JakartaExtraBold mb-2 text-black dark:text-white">
|
|
{t("auth.signIn.reset.newPassTitle")}
|
|
</Text>
|
|
|
|
<Text className="font-Jakarta mb-5 text-black dark:text-white">
|
|
{t("auth.signIn.reset.resetBody", { email: reset.email })}
|
|
</Text>
|
|
|
|
{reset.devCode ? (
|
|
<View className="bg-amber-50 dark:bg-amber-950/40 border border-amber-300 dark:border-amber-800 rounded-xl p-3 mb-5">
|
|
<Text className="text-sm text-amber-700 dark:text-amber-400 font-Jakarta">
|
|
{t("auth.signIn.reset.devBanner")}
|
|
<Text className="font-JakartaBold">{reset.devCode}</Text>
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<OtpField
|
|
value={reset.code}
|
|
onChange={(code) =>
|
|
setReset((prev) => ({ ...prev, code, error: "" }))
|
|
}
|
|
/>
|
|
|
|
<InputField
|
|
label={t("auth.signIn.reset.newPassLabel")}
|
|
icon={icons.lock}
|
|
placeholder={t("auth.signIn.reset.newPasswordPlaceholder")}
|
|
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 ? t("auth.signIn.reset.resetting") : t("auth.signIn.reset.resetBtn")}
|
|
onPress={onSubmitReset}
|
|
disabled={reset.busy}
|
|
className="mt-5 bg-emerald-500"
|
|
/>
|
|
</View>
|
|
</ReactNativeModal>
|
|
</View>
|
|
</ScrollView>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
};
|
|
|
|
export default SignIn; |