Files
guilan-ace-frontend/src/views/Auth.tsx

766 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
AlertTriangle,
ArrowRight,
KeyRound,
Loader2,
MessageSquareMore,
Smartphone,
} from "lucide-react";
import AsyncSearchableCombobox from "@/components/AsyncSearchableCombobox";
import OtpCodeField from "@/components/OtpCodeField";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import { Link, Navigate, useNavigate } from "@/lib/router";
import { resolveErrorMessage } from "@/lib/utils";
type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details";
const OTP_LENGTH = 5;
const USERNAME_REGEX = /^[A-Za-z0-9._-]{3,30}$/;
const sanitizeUsername = (value: string) => value.replace(/[^A-Za-z0-9._-]/g, "");
const sanitizeText = (value: string) => value.trim();
const normalizeDigits = (value: string) =>
value
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660));
const sanitizeMobile = (value: string) => normalizeDigits(value).replace(/[^\d]/g, "");
const createEmptyRegisterForm = () => ({
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
student_id: "",
year_of_study: "",
major: null as string | null,
university: null as string | null,
});
function GoogleIcon() {
return (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
<path
d="M21.805 10.023h-9.72v3.955h5.57c-.24 1.272-.96 2.35-2.042 3.07v2.548h3.3c1.933-1.78 3.042-4.4 3.042-7.506 0-.692-.062-1.357-.15-2.067Z"
fill="#4285F4"
/>
<path
d="M12.085 22c2.79 0 5.13-.925 6.84-2.504l-3.3-2.548c-.924.617-2.103.986-3.54.986-2.705 0-4.99-1.823-5.807-4.28H2.87v2.626A10.33 10.33 0 0 0 12.085 22Z"
fill="#34A853"
/>
<path
d="M6.278 13.654A6.214 6.214 0 0 1 5.95 11.7c0-.68.117-1.34.328-1.954V7.12H2.87A10.31 10.31 0 0 0 1.75 11.7c0 1.65.39 3.218 1.12 4.58l3.408-2.626Z"
fill="#FBBC05"
/>
<path
d="M12.085 5.466c1.52 0 2.882.522 3.955 1.55l2.966-2.966C17.21 2.387 14.874 1.4 12.085 1.4A10.33 10.33 0 0 0 2.87 7.12l3.408 2.626c.818-2.457 3.103-4.28 5.807-4.28Z"
fill="#EA4335"
/>
</svg>
);
}
export default function Auth() {
const navigate = useNavigate();
const { toast } = useToast();
const { login, loginWithOtp, isAuthenticated } = useAuth();
const [step, setStep] = useState<AuthStep>("mobile");
const [authLoading, setAuthLoading] = useState(false);
const [mobile, setMobile] = useState("");
const [password, setPassword] = useState("");
const [otpCode, setOtpCode] = useState("");
const [lookupState, setLookupState] = useState<{ exists: boolean; has_password: boolean } | null>(null);
const [otpCooldowns, setOtpCooldowns] = useState<Record<"login" | "register", number>>({
login: 0,
register: 0,
});
const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm);
useEffect(() => {
const timer = window.setInterval(() => {
setOtpCooldowns((current) => ({
login: Math.max(current.login - 1, 0),
register: Math.max(current.register - 1, 0),
}));
}, 1000);
return () => window.clearInterval(timer);
}, []);
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
const data = await api.getMajorsPaged(params);
return {
count: data.count,
results: data.results.map((major) => ({ value: String(major.code), label: major.label })),
};
}, []);
const loadUniversities = useCallback(async (params: { search: string; limit: number; offset: number }) => {
const data = await api.getUniversitiesPaged(params);
return {
count: data.count,
results: data.results.map((university) => ({ value: String(university.code), label: university.label })),
};
}, []);
const majorsLoading = false;
const universitiesLoading = false;
const stepMeta = useMemo(() => {
switch (step) {
case "password":
return {
title: "رمز عبور حساب",
description: "برای این شماره موبایل حساب فعال پیدا شد. رمز عبور را وارد کنید یا روش ورود را به کد پیامکی تغییر دهید.",
};
case "otp_login":
return {
title: "ورود با کد پیامکی",
description: "کد ۵ رقمی ارسال‌شده به موبایل را وارد کنید تا وارد حساب خود شوید.",
};
case "otp_register":
return {
title: "تایید موبایل",
description: "ابتدا موبایل شما با کد پیامکی تایید می‌شود، سپس فرم تکمیل اطلاعات نمایش داده خواهد شد.",
};
case "register_details":
return {
title: "تکمیل اطلاعات حساب",
description: "موبایل شما تایید شد. حالا اطلاعات تکمیلی و رمز عبور را وارد کنید تا ثبت‌نام نهایی شود.",
};
default:
return {
title: "ورود و ثبت‌نام",
description: "ابتدا شماره موبایل را وارد کنید تا مسیر مناسب برای ورود یا ثبت‌نام به شما نمایش داده شود.",
};
}
}, [step]);
if (isAuthenticated) {
return <Navigate to="/profile" replace />;
}
const updateRegisterForm = <K extends keyof ReturnType<typeof createEmptyRegisterForm>>(
key: K,
value: ReturnType<typeof createEmptyRegisterForm>[K],
) => {
setRegisterForm((current) => ({ ...current, [key]: value }));
};
const resetToMobileStep = () => {
setStep("mobile");
setPassword("");
setOtpCode("");
setLookupState(null);
setRegisterForm(createEmptyRegisterForm());
};
const ensureValidMobile = (value: string) => {
const normalizedMobile = sanitizeMobile(value);
if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) {
toast({
title: "شماره موبایل نامعتبر است",
description: "لطفاً شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.",
variant: "destructive",
});
return null;
}
return normalizedMobile;
};
const sendOtpAndAdvance = async (mode: "login" | "register", mobileValue = mobile) => {
const normalizedMobile = ensureValidMobile(mobileValue);
if (!normalizedMobile) {
return;
}
try {
setAuthLoading(true);
const payload = await api.sendOtp({ mobile: normalizedMobile, mode });
setMobile(normalizedMobile);
setOtpCode("");
setOtpCooldowns((current) => ({
...current,
[mode]: Math.min(payload.expires_in_seconds, 120),
}));
setStep(mode === "login" ? "otp_login" : "otp_register");
toast({
title: "کد تایید ارسال شد",
description: payload.message,
variant: "success",
});
} catch (error: unknown) {
toast({
title: "ارسال کد ناموفق بود",
description: resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const handleMobileStep = async (event: React.FormEvent) => {
event.preventDefault();
const normalizedMobile = ensureValidMobile(mobile);
if (!normalizedMobile) {
return;
}
try {
setAuthLoading(true);
const lookup = await api.checkMobile(normalizedMobile);
setMobile(normalizedMobile);
setLookupState(lookup);
setPassword("");
setOtpCode("");
if (lookup.exists && lookup.has_password) {
setStep("password");
return;
}
await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile);
} catch (error: unknown) {
toast({
title: "بررسی شماره موبایل ناموفق بود",
description: resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const handlePasswordLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (!password) {
toast({
title: "رمز عبور لازم است",
description: "برای ادامه، رمز عبور حساب را وارد کنید.",
variant: "destructive",
});
return;
}
try {
setAuthLoading(true);
await login(mobile, password);
toast({
title: "ورود انجام شد",
description: "خوش آمدید. حساب شما آماده استفاده است.",
variant: "success",
});
navigate("/profile");
} catch (error: unknown) {
toast({
title: "ورود ناموفق بود",
description: resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const handleOtpLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (otpCode.length !== OTP_LENGTH) {
toast({
title: "کد تایید ناقص است",
description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.",
variant: "destructive",
});
return;
}
try {
setAuthLoading(true);
await loginWithOtp(mobile, otpCode);
toast({
title: "ورود پیامکی انجام شد",
description: "با موفقیت وارد حساب کاربری خود شدید.",
variant: "success",
});
navigate("/profile");
} catch (error: unknown) {
toast({
title: "ورود با کد ناموفق بود",
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const handleRegisterOtpVerification = async (event: React.FormEvent) => {
event.preventDefault();
if (otpCode.length !== OTP_LENGTH) {
toast({
title: "کد تایید ناقص است",
description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.",
variant: "destructive",
});
return;
}
try {
setAuthLoading(true);
await api.verifyRegisterOtp({ mobile, code: otpCode });
setStep("register_details");
toast({
title: "موبایل تایید شد",
description: "حالا اطلاعات تکمیلی حساب را وارد کنید.",
variant: "success",
});
} catch (error: unknown) {
toast({
title: "تایید کد ناموفق بود",
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const handleRegister = async (event: React.FormEvent) => {
event.preventDefault();
if (!USERNAME_REGEX.test(registerForm.username)) {
toast({
title: "نام کاربری نامعتبر است",
description: "نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.",
variant: "destructive",
});
return;
}
if (registerForm.password.length < 8) {
toast({
title: "رمز عبور کوتاه است",
description: "رمز عبور باید حداقل ۸ کاراکتر داشته باشد.",
variant: "destructive",
});
return;
}
if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) {
toast({
title: "ایمیل نامعتبر است",
description: "اگر ایمیل وارد می‌کنید، فرمت آن باید صحیح باشد.",
variant: "destructive",
});
return;
}
try {
setAuthLoading(true);
await api.register({
username: registerForm.username,
mobile,
code: otpCode,
password: registerForm.password,
email: registerForm.email ? sanitizeText(registerForm.email) : null,
first_name: registerForm.first_name ? sanitizeText(registerForm.first_name) : null,
last_name: registerForm.last_name ? sanitizeText(registerForm.last_name) : null,
student_id: registerForm.student_id ? sanitizeMobile(registerForm.student_id) : null,
year_of_study: registerForm.year_of_study ? Number(registerForm.year_of_study) : null,
major: registerForm.major,
university: registerForm.university,
});
toast({
title: "ثبت‌نام کامل شد",
description: "اکنون می‌توانید با موبایل و رمز عبور یا کد پیامکی وارد شوید.",
variant: "success",
});
setLookupState({ exists: true, has_password: true });
setPassword("");
setRegisterForm(createEmptyRegisterForm());
setOtpCode("");
setStep("password");
} catch (error: unknown) {
toast({
title: "ثبت‌نام ناموفق بود",
description: resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."),
variant: "destructive",
});
} finally {
setAuthLoading(false);
}
};
const renderStepHeader = () =>
step !== "mobile" ? (
<div className="mb-6 flex items-center justify-between rounded-[1.5rem] border border-border/70 bg-muted/25 p-4">
<div className="text-right">
<p className="text-xs text-muted-foreground">شماره موبایل انتخابشده</p>
<p className="mt-1 font-semibold" dir="ltr">
{mobile}
</p>
</div>
<Button type="button" variant="ghost" className="rounded-xl text-sm" onClick={resetToMobileStep}>
<ArrowRight className="ml-2 h-4 w-4" />
تغییر شماره
</Button>
</div>
) : null;
const renderMobileStep = () => (
<form className="space-y-5" onSubmit={handleMobileStep}>
<div>
<Label htmlFor="mobile" className="mb-2 block text-right">
شماره موبایل
</Label>
<Input
id="mobile"
type="tel"
dir="ltr"
inputMode="numeric"
maxLength={11}
value={mobile}
onChange={(event) => setMobile(sanitizeMobile(event.target.value))}
placeholder="09xxxxxxxxx"
className="h-12 rounded-2xl"
/>
</div>
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
{authLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال بررسی...
</>
) : (
<>
<Smartphone className="ml-2 h-4 w-4" />
ادامه
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/70" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-3 text-muted-foreground">یا</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="h-12 w-full rounded-2xl border-border/70 bg-background/80"
onClick={() => void api.startGoogleLogin()}
>
<GoogleIcon />
<span>ادامه با حساب گوگل</span>
</Button>
</form>
);
const renderPasswordStep = () => (
<form className="space-y-4" onSubmit={handlePasswordLogin}>
{renderStepHeader()}
<div>
<Label htmlFor="password" className="mb-2 block text-right">
رمز عبور
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
{authLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ورود...
</>
) : (
<>
<KeyRound className="ml-2 h-4 w-4" />
ورود با رمز عبور
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="h-12 w-full rounded-2xl"
disabled={authLoading || otpCooldowns.login > 0}
onClick={() => void sendOtpAndAdvance("login")}
>
{otpCooldowns.login > 0 ? `ارسال مجدد تا ${otpCooldowns.login} ثانیه` : "ورود با کد پیامکی"}
</Button>
<Link
to="/reset-password"
className="block text-right text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
رمز عبور را فراموش کردهام
</Link>
</form>
);
const renderOtpStep = (mode: "login" | "register") => {
const isLogin = mode === "login";
return (
<form className="space-y-4" onSubmit={isLogin ? handleOtpLogin : handleRegisterOtpVerification}>
{renderStepHeader()}
<div>
<Label className="mb-3 block text-right">کد ۵ رقمی پیامکی</Label>
<OtpCodeField value={otpCode} onChange={(value) => setOtpCode(normalizeDigits(value))} disabled={authLoading} />
</div>
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button type="submit" className="h-12 flex-1 rounded-2xl" disabled={authLoading || otpCode.length !== OTP_LENGTH}>
{authLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال بررسی...
</>
) : (
<>
<Smartphone className="ml-2 h-4 w-4" />
{isLogin ? "ورود با کد" : "تایید کد و ادامه"}
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="h-12 rounded-2xl"
disabled={authLoading || otpCooldowns[mode] > 0}
onClick={() => void sendOtpAndAdvance(mode)}
>
{otpCooldowns[mode] > 0 ? `ارسال مجدد تا ${otpCooldowns[mode]} ثانیه` : "ارسال مجدد کد"}
</Button>
</div>
{isLogin && lookupState?.has_password ? (
<Button type="button" variant="ghost" className="h-11 w-full rounded-2xl" onClick={() => setStep("password")}>
بازگشت به ورود با رمز عبور
</Button>
) : null}
</form>
);
};
const renderRegisterDetailsStep = () => (
<form className="space-y-5" onSubmit={handleRegister}>
{renderStepHeader()}
<div className="rounded-[1.5rem] border border-emerald-500/20 bg-emerald-500/10 p-4 text-right text-sm text-emerald-900 dark:text-emerald-100">
این شماره موبایل با موفقیت تایید شده است. برای ثبتنام فقط کافی است اطلاعات باقیمانده را تکمیل کنید.
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="register-first-name" className="mb-2 block text-right">
نام
</Label>
<Input
id="register-first-name"
value={registerForm.first_name}
onChange={(event) => updateRegisterForm("first_name", event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label htmlFor="register-last-name" className="mb-2 block text-right">
نام خانوادگی
</Label>
<Input
id="register-last-name"
value={registerForm.last_name}
onChange={(event) => updateRegisterForm("last_name", event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="register-username" className="mb-2 block text-right">
نام کاربری
</Label>
<Input
id="register-username"
dir="ltr"
value={registerForm.username}
onChange={(event) => updateRegisterForm("username", sanitizeUsername(event.target.value))}
placeholder="latin.username"
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label htmlFor="register-password" className="mb-2 block text-right">
رمز عبور
</Label>
<Input
id="register-password"
type="password"
value={registerForm.password}
onChange={(event) => updateRegisterForm("password", event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="register-email" className="mb-2 block text-right">
ایمیل (اختیاری)
</Label>
<Input
id="register-email"
type="email"
value={registerForm.email}
onChange={(event) => updateRegisterForm("email", event.target.value)}
placeholder="user@example.com"
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label htmlFor="register-student-id" className="mb-2 block text-right">
شماره دانشجویی (اختیاری)
</Label>
<Input
id="register-student-id"
dir="ltr"
inputMode="numeric"
value={registerForm.student_id}
onChange={(event) => updateRegisterForm("student_id", sanitizeMobile(event.target.value))}
className="h-12 rounded-2xl"
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="register-university" className="mb-2 block text-right">
دانشگاه
</Label>
{universitiesLoading ? (
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
) : (
<AsyncSearchableCombobox
loadOptions={loadUniversities}
value={registerForm.university}
onChange={(value) => updateRegisterForm("university", value)}
placeholder="انتخاب دانشگاه"
searchPlaceholder="نام دانشگاه را بنویسید..."
emptyText="دانشگاهی پیدا نشد"
className="h-12 rounded-2xl"
/>
)}
</div>
<div>
<Label htmlFor="register-major" className="mb-2 block text-right">
رشته تحصیلی (اختیاری)
</Label>
{majorsLoading ? (
<div className="h-12 animate-pulse rounded-2xl bg-muted" />
) : (
<AsyncSearchableCombobox
loadOptions={loadMajors}
value={registerForm.major}
onChange={(value) => updateRegisterForm("major", value)}
placeholder="انتخاب رشته"
searchPlaceholder="نام رشته را بنویسید..."
emptyText="رشته‌ای پیدا نشد"
className="h-12 rounded-2xl"
/>
)}
</div>
</div>
<div>
<Label htmlFor="register-year" className="mb-2 block text-right">
سال ورودی (اختیاری)
</Label>
<Input
id="register-year"
dir="ltr"
inputMode="numeric"
value={registerForm.year_of_study}
onChange={(event) => updateRegisterForm("year_of_study", sanitizeMobile(event.target.value))}
className="h-12 rounded-2xl"
/>
</div>
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
{authLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ثبتنام...
</>
) : (
<>
<MessageSquareMore className="ml-2 h-4 w-4" />
تکمیل ثبتنام
</>
)}
</Button>
<p className="text-right text-xs leading-6 text-muted-foreground">
ایمیل در این مرحله اختیاری است و برای ارسال پیام استفاده نخواهد شد. اطلاعرسانیهای مهم از طریق پیامک یا اعلان داخل سایت انجام میشوند.
</p>
</form>
);
return (
<div
className="min-h-screen bg-[radial-gradient(circle_at_top_right,rgba(59,130,246,0.12),transparent_35%),radial-gradient(circle_at_bottom_left,rgba(15,23,42,0.08),transparent_30%)] px-4 py-10"
dir="rtl"
>
<div className="mx-auto w-full max-w-2xl">
<Card className="border-border/70 bg-background/90 shadow-xl backdrop-blur-xl">
<CardHeader className="text-right">
<CardTitle>{stepMeta.title}</CardTitle>
<CardDescription>{stepMeta.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-[1.5rem] border border-amber-400/30 bg-amber-500/10 p-4 text-sm leading-7 text-amber-900 dark:text-amber-100">
<div className="flex items-start justify-between gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="text-right">
<p className="font-semibold">نکته مهم برای کاربران قبلی</p>
<p className="mt-2">
اگر دسترسی به ایمیل ندارید و رمز عبور را فراموش کردهاید، در همین مرحله از دکمه گوگل استفاده کنید. اگر ایمیل حساب شما با گوگل یکسان باشد، میتوانید حساب را بازیابی کرده و بعد موبایل خود را تایید کنید.
</p>
</div>
</div>
</div>
{step === "mobile" ? renderMobileStep() : null}
{step === "password" ? renderPasswordStep() : null}
{step === "otp_login" ? renderOtpStep("login") : null}
{step === "otp_register" ? renderOtpStep("register") : null}
{step === "register_details" ? renderRegisterDetailsStep() : null}
</CardContent>
</Card>
</div>
</div>
);
}