- sessions: require REMIX_SECRET in prod (no "secret" fallback); base Secure cookie on NODE_ENV; add maxAge - login: safeRedirect() blocks open-redirect via redirectTo (loader + action) - SSR loaders: encodeURIComponent on user path params before internal API fetch (product, store product, seller) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
import { authenticator } from "~/auth.server";
|
||
import {
|
||
ActionFunctionArgs,
|
||
json,
|
||
redirect,
|
||
LoaderFunctionArgs,
|
||
MetaFunction,
|
||
} from "@remix-run/node";
|
||
import { sessionStorage } from "~/sessions.server";
|
||
import { useServiceSendOtp, useServiceVerifyOtp } from "~/utils/RequestHandler";
|
||
import { useEffect, useState } from "react";
|
||
import verifyBanner from "app/assets/icons/login/verify-banner.svg";
|
||
import { Button } from "~/components/ui/button";
|
||
import { ArrowLeft, Loader2, Phone, RotateCcw } from "lucide-react";
|
||
import {
|
||
InputOTP,
|
||
InputOTPGroup,
|
||
InputOTPSlot,
|
||
} from "~/components/ui/input-otp";
|
||
import AppInput from "~/components/AppInput";
|
||
import { useToast } from "~/hooks/use-toast";
|
||
import { useSubmit, useLoaderData } from "@remix-run/react";
|
||
import { User } from "~/utils/token-manager.server";
|
||
|
||
// Only allow same-site, path-relative redirects. Rejects absolute URLs
|
||
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
|
||
// open-redirect / phishing after login.
|
||
function safeRedirect(
|
||
to: FormDataEntryValue | string | null,
|
||
defaultRedirect = "/"
|
||
): string {
|
||
if (!to || typeof to !== "string") return defaultRedirect;
|
||
if (!to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) {
|
||
return defaultRedirect;
|
||
}
|
||
return to;
|
||
}
|
||
|
||
export async function loader({ request }: LoaderFunctionArgs) {
|
||
const url = new URL(request.url);
|
||
const redirectTo = safeRedirect(url.searchParams.get("redirectTo"));
|
||
return json({ redirectTo });
|
||
}
|
||
|
||
// Helper function to convert Persian numbers to English
|
||
const convertPersianToEnglish = (str: string): string => {
|
||
const persianNumbers = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
|
||
const englishNumbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||
|
||
let result = str;
|
||
for (let i = 0; i < 10; i++) {
|
||
result = result.replace(
|
||
new RegExp(persianNumbers[i], "g"),
|
||
englishNumbers[i]
|
||
);
|
||
}
|
||
return result;
|
||
};
|
||
|
||
export default function LoginPage() {
|
||
const { redirectTo } = useLoaderData<typeof loader>();
|
||
const { toast } = useToast();
|
||
const [phoneNumber, setPhoneNumber] = useState("");
|
||
const [otpValue, setOtpValue] = useState("");
|
||
const [enableVerifySection, setEnableVerifySection] = useState(false);
|
||
|
||
const sendOtpMutation = useServiceSendOtp(
|
||
phoneNumber,
|
||
setEnableVerifySection
|
||
);
|
||
|
||
const verifyOtpMutation = useServiceVerifyOtp(phoneNumber, otpValue, toast);
|
||
|
||
const submit = useSubmit();
|
||
useEffect(() => {
|
||
if (
|
||
verifyOtpMutation.data &&
|
||
verifyOtpMutation.data.accessToken &&
|
||
verifyOtpMutation.data.refreshToken
|
||
) {
|
||
console.log("redirectTo", redirectTo);
|
||
submit(
|
||
{
|
||
phoneNumber: phoneNumber,
|
||
access_token: JSON.stringify(verifyOtpMutation.data.accessToken),
|
||
refresh_token: JSON.stringify(verifyOtpMutation.data.refreshToken),
|
||
redirectTo: redirectTo,
|
||
},
|
||
{
|
||
action: "/login",
|
||
method: "post",
|
||
encType: "application/x-www-form-urlencoded",
|
||
}
|
||
);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [verifyOtpMutation.data]);
|
||
|
||
const [canResend, setCanResend] = useState(false);
|
||
useEffect(() => {
|
||
if (enableVerifySection) setCanResend(false);
|
||
}, [enableVerifySection]);
|
||
|
||
const handleTimerEnd = () => {
|
||
setCanResend(true);
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full">
|
||
{enableVerifySection ? (
|
||
<>
|
||
<div className="flex flex-col gap-4 w-full items-center">
|
||
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
|
||
<p className={"text-R18"}>کد تایید شماره موبایل</p>
|
||
<p className="text-BLACK2 text-R12">
|
||
کد ارسال شده به {phoneNumber} را وارد کنید:
|
||
</p>
|
||
<Button
|
||
variant="ghost"
|
||
className="text-BLUE"
|
||
size={"lg"}
|
||
onClick={() => setEnableVerifySection(false)}
|
||
>
|
||
ویرایش شماره موبایل
|
||
<ArrowLeft />
|
||
</Button>
|
||
</div>
|
||
<div className="w-full mt-2 flex justify-center">
|
||
<InputOTP
|
||
maxLength={4}
|
||
onComplete={(finalValue) => {
|
||
const convertedValue = convertPersianToEnglish(finalValue);
|
||
setOtpValue(convertedValue);
|
||
}}
|
||
value={otpValue}
|
||
onChange={(value) => {
|
||
const convertedValue = convertPersianToEnglish(value);
|
||
setOtpValue(convertedValue);
|
||
}}
|
||
>
|
||
<InputOTPGroup dir="ltr" className="gap-4">
|
||
<InputOTPSlot index={0} />
|
||
<InputOTPSlot index={1} />
|
||
<InputOTPSlot index={2} />
|
||
<InputOTPSlot index={3} />
|
||
</InputOTPGroup>
|
||
</InputOTP>
|
||
</div>
|
||
<div className="gap-4 w-full flex flex-col items-center">
|
||
<Button
|
||
disabled={verifyOtpMutation.isPending || !(otpValue.length === 4)}
|
||
className="w-[90%]"
|
||
size={"xl"}
|
||
variant="dark"
|
||
onClick={() => verifyOtpMutation.mutate()}
|
||
>
|
||
{verifyOtpMutation.isPending ? (
|
||
<>
|
||
<Loader2 className="animate-spin" />
|
||
درحال تایید...
|
||
</>
|
||
) : (
|
||
"تایید شماره موبایل"
|
||
)}
|
||
</Button>
|
||
<div>
|
||
{!canResend ? (
|
||
<ResendTimer initialTime={120} onTimerEnd={handleTimerEnd} />
|
||
) : (
|
||
<Button
|
||
variant="link"
|
||
size="lg"
|
||
className="text-BLUE font-bold p-0 h-auto gap-1"
|
||
onClick={() => {
|
||
setCanResend(false);
|
||
setOtpValue("");
|
||
sendOtpMutation.mutate();
|
||
toast({
|
||
description: "کد مجددا ارسال گردید",
|
||
});
|
||
}}
|
||
>
|
||
<span className="text-B12">ارسال مجدد کد</span>
|
||
<RotateCcw className="size-5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex flex-col gap-4 w-full items-center">
|
||
<p className="text-R18">به ویترون خوش آمدید!</p>
|
||
<p className="text-GRAY text-R14">
|
||
برای ادامه اطلاعات زیر را وارد کنید
|
||
</p>
|
||
</div>
|
||
<div className="w-full mt-8">
|
||
<AppInput
|
||
inputTitle={"شماره موبایل خود را وارد کنید:"}
|
||
inputIcon={Phone}
|
||
inputValue={phoneNumber}
|
||
inputPlaceholder={"مثل ۰۹۳۹۱۲۳۴۵۶۷"}
|
||
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value;
|
||
// Convert Persian numbers to English
|
||
const convertedValue = convertPersianToEnglish(value);
|
||
// Only allow English digits
|
||
if (/^\d*$/.test(convertedValue)) {
|
||
setPhoneNumber(convertedValue);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
<Button
|
||
disabled={
|
||
sendOtpMutation.isPending ||
|
||
!(phoneNumber.length === 11 && phoneNumber.startsWith("0"))
|
||
}
|
||
size={"xl"}
|
||
variant={"dark"}
|
||
className="w-full"
|
||
onClick={() => {
|
||
sendOtpMutation.mutate();
|
||
}}
|
||
>
|
||
{sendOtpMutation.isPending ? (
|
||
<>
|
||
<Loader2 className="animate-spin" />
|
||
درحال ارسال کد
|
||
</>
|
||
) : (
|
||
"تایید و ادامه"
|
||
)}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const ResendTimer = ({
|
||
initialTime = 120,
|
||
onTimerEnd,
|
||
}: {
|
||
initialTime?: number;
|
||
onTimerEnd?: () => void;
|
||
}) => {
|
||
const [timeLeft, setTimeLeft] = useState(initialTime);
|
||
|
||
useEffect(() => {
|
||
if (timeLeft <= 0) {
|
||
if (onTimerEnd) onTimerEnd();
|
||
return;
|
||
}
|
||
|
||
const timer = setInterval(() => {
|
||
setTimeLeft((prev) => prev - 1);
|
||
}, 1000);
|
||
|
||
return () => clearInterval(timer); // پاک کردن تایمر هنگام خروج از کامپوننت
|
||
}, [timeLeft, onTimerEnd]);
|
||
|
||
const formatTime = (seconds: number) => {
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainingSeconds = seconds % 60;
|
||
return `${minutes}:${remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds}`;
|
||
};
|
||
|
||
return (
|
||
<p className="text-GRAY text-R12">
|
||
{formatTime(timeLeft)} تا ارسال مجدد کد
|
||
</p>
|
||
);
|
||
};
|
||
export const meta: MetaFunction = () => {
|
||
const title = "ورود و ثبت نام | ویترون";
|
||
const description =
|
||
"ورود یا ثبت نام در ویترون - پلتفرم خرید آنلاین. دسترسی آسان و سریع به حساب کاربری خود برای تجربه خرید بهتر.";
|
||
const imageUrl =
|
||
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
|
||
|
||
return [
|
||
{ title },
|
||
{ name: "description", content: description },
|
||
{ name: "keywords", content: "ورود ویترون، ثبت نام، حساب کاربری، لاگین" },
|
||
{ name: "robots", content: "noindex, follow" },
|
||
|
||
// Open Graph
|
||
{ property: "og:type", content: "website" },
|
||
{ property: "og:title", content: title },
|
||
{ property: "og:description", content: description },
|
||
{ property: "og:image", content: imageUrl },
|
||
{ property: "og:site_name", content: "ویترون" },
|
||
{ property: "og:locale", content: "fa_IR" },
|
||
|
||
// Twitter Card
|
||
{ name: "twitter:card", content: "summary" },
|
||
{ name: "twitter:title", content: title },
|
||
{ name: "twitter:description", content: description },
|
||
];
|
||
};
|
||
|
||
export async function action({ request }: ActionFunctionArgs) {
|
||
const clonedRequest = request.clone();
|
||
const formData = await clonedRequest.formData();
|
||
const redirectTo = safeRedirect(formData.get("redirectTo"));
|
||
|
||
try {
|
||
const user = (await authenticator.authenticate(
|
||
"vitrown-login",
|
||
request
|
||
)) as User;
|
||
if (!user.access_token) {
|
||
return redirect("/login");
|
||
}
|
||
|
||
const session = await sessionStorage.getSession(
|
||
request.headers.get("cookie")
|
||
);
|
||
|
||
const currentTime = Date.now();
|
||
|
||
// Store both tokens with their timestamps
|
||
session.set("user", {
|
||
access_token: user.access_token,
|
||
refresh_token: user.refresh_token,
|
||
access_token_timestamp: currentTime,
|
||
refresh_token_timestamp: currentTime,
|
||
});
|
||
|
||
return redirect(redirectTo, {
|
||
headers: {
|
||
"Set-Cookie": await sessionStorage.commitSession(session, {
|
||
expires: new Date(new Date().setMonth(new Date().getMonth() + 1)),
|
||
}),
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.log("error", error);
|
||
return json({ error: "Authentication failed" }, { status: 401 });
|
||
}
|
||
}
|