Files
waseel/app/(auth)/sign-in.tsx
T

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;