feat: simplify phone-first authentication flow

This commit is contained in:
2026-09-28 22:06:42 +03:30
parent 4c969a7c9f
commit b7d5f7bc65
12 changed files with 150 additions and 401 deletions

View File

@@ -1,11 +1,5 @@
import type { Metadata } from "next"; import { redirect } from "next/navigation";
import ResetPasswordRequest from "@/views/ResetPasswordRequest";
export const metadata: Metadata = {
title: "بازیابی رمز عبور",
robots: { index: false, follow: false },
};
export default function ResetPasswordPage() { export default function ResetPasswordPage() {
return <ResetPasswordRequest />; redirect("/auth");
} }

View File

@@ -13,7 +13,6 @@ export default function robots(): MetadataRoute.Robots {
"/logout", "/logout",
"/profile", "/profile",
"/payments/", "/payments/",
"/reset-password",
], ],
}, },
], ],

View File

@@ -20,8 +20,7 @@ const isProfileOrAuthPath = (pathname: string) =>
pathname === "/profile" || pathname === "/profile" ||
pathname.startsWith("/profile/") || pathname.startsWith("/profile/") ||
pathname === "/auth" || pathname === "/auth" ||
pathname.startsWith("/auth/") || pathname.startsWith("/auth/");
pathname.startsWith("/reset-password");
export default function MobileBottomNav({ siteContent }: { siteContent: SiteContent }) { export default function MobileBottomNav({ siteContent }: { siteContent: SiteContent }) {
const pathname = usePathname() || "/"; const pathname = usePathname() || "/";

View File

@@ -3,7 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react"; import { LayoutDashboard, LogOut, UserRound } from "lucide-react";
import { Link, NavLink } from "@/lib/router"; import { Link, NavLink } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import ModeToggle from "@/components/ModeToggle"; import ModeToggle from "@/components/ModeToggle";
@@ -91,12 +91,6 @@ function ProfileAvatarMenu() {
<UserRound className="h-4 w-4" /> <UserRound className="h-4 w-4" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/reset-password">
تغییر یا بازیابی رمز
<RotateCcw className="h-4 w-4" />
</Link>
</DropdownMenuItem>
{isAdminUser ? ( {isAdminUser ? (
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl"> <DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/admin"> <Link to="/admin">

View File

@@ -9,6 +9,7 @@ type OtpCodeFieldProps = {
disabled?: boolean; disabled?: boolean;
length?: number; length?: number;
className?: string; className?: string;
ariaLabel?: string;
}; };
export default function OtpCodeField({ export default function OtpCodeField({
@@ -17,6 +18,7 @@ export default function OtpCodeField({
disabled = false, disabled = false,
length = 5, length = 5,
className, className,
ariaLabel = "کد تایید پیامکی",
}: OtpCodeFieldProps) { }: OtpCodeFieldProps) {
return ( return (
<InputOTP <InputOTP
@@ -26,6 +28,7 @@ export default function OtpCodeField({
onChange={(nextValue) => onChange(nextValue.replace(/[^\d]/g, ""))} onChange={(nextValue) => onChange(nextValue.replace(/[^\d]/g, ""))}
disabled={disabled} disabled={disabled}
inputMode="numeric" inputMode="numeric"
aria-label={ariaLabel}
containerClassName={cn("w-full justify-center", className)} containerClassName={cn("w-full justify-center", className)}
> >
<InputOTPGroup dir="ltr" className="justify-center"> <InputOTPGroup dir="ltr" className="justify-center">

View File

@@ -11,9 +11,9 @@ const buttonVariants = cva(
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90", default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", outline: "border border-input bg-background text-foreground hover:bg-secondary hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/75 hover:text-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-secondary hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {

View File

@@ -35,7 +35,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item; const NavigationMenuItem = NavigationMenuPrimitive.Item;
const navigationMenuTriggerStyle = cva( const navigationMenuTriggerStyle = cva(
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50", "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary hover:text-foreground focus:bg-secondary focus:text-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-secondary/70 data-[state=open]:bg-secondary/70",
); );
const NavigationMenuTrigger = React.forwardRef< const NavigationMenuTrigger = React.forwardRef<

View File

@@ -10,7 +10,7 @@ const toggleVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-transparent", default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", outline: "border border-input bg-transparent hover:bg-secondary hover:text-foreground",
}, },
size: { size: {
default: "h-10 px-3", default: "h-10 px-3",

View File

@@ -207,13 +207,6 @@ class ApiClient {
}); });
} }
async resetPassword(data: Types.PasswordResetSchema) {
return this.request<Types.MessageSchema>('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify(data),
});
}
async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) { async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) {
return this.request<Types.OtpSendResponseSchema>('/api/auth/mobile/send-otp', { return this.request<Types.OtpSendResponseSchema>('/api/auth/mobile/send-otp', {
method: 'POST', method: 'POST',

View File

@@ -172,7 +172,7 @@ export interface RegisterOtpVerifySchema {
export interface OtpSendSchema { export interface OtpSendSchema {
mobile: string; mobile: string;
mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim"; mode: "register" | "login" | "verify_mobile" | "google_claim";
} }
export interface OtpSendResponseSchema { export interface OtpSendResponseSchema {
@@ -194,12 +194,6 @@ export interface MobileLookupSchema {
has_password: boolean; has_password: boolean;
} }
export interface PasswordResetSchema {
mobile: string;
code: string;
new_password: string;
}
export interface MobileOtpSendSchema { export interface MobileOtpSendSchema {
mobile: string; mobile: string;
} }

View File

@@ -1,8 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
AlertTriangle,
ArrowRight, ArrowRight,
KeyRound, KeyRound,
Loader2, Loader2,
@@ -18,7 +17,7 @@ import { Label } from "@/components/ui/label";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Link, Navigate, useNavigate } from "@/lib/router"; import { Navigate, useNavigate } from "@/lib/router";
import { resolveErrorMessage } from "@/lib/utils"; import { resolveErrorMessage } from "@/lib/utils";
type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details"; type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details";
@@ -75,6 +74,8 @@ export default function Auth() {
const { login, loginWithOtp, isAuthenticated } = useAuth(); const { login, loginWithOtp, isAuthenticated } = useAuth();
const [step, setStep] = useState<AuthStep>("mobile"); const [step, setStep] = useState<AuthStep>("mobile");
const [authLoading, setAuthLoading] = useState(false); const [authLoading, setAuthLoading] = useState(false);
const [formError, setFormError] = useState("");
const authActionRef = useRef(false);
const [mobile, setMobile] = useState(""); const [mobile, setMobile] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [otpCode, setOtpCode] = useState(""); const [otpCode, setOtpCode] = useState("");
@@ -86,14 +87,17 @@ export default function Auth() {
const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm); const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { if (otpCooldowns.login <= 0 && otpCooldowns.register <= 0) {
return;
}
const timer = window.setTimeout(() => {
setOtpCooldowns((current) => ({ setOtpCooldowns((current) => ({
login: Math.max(current.login - 1, 0), login: Math.max(current.login - 1, 0),
register: Math.max(current.register - 1, 0), register: Math.max(current.register - 1, 0),
})); }));
}, 1000); }, 1000);
return () => window.clearInterval(timer); return () => window.clearTimeout(timer);
}, []); }, [otpCooldowns.login, otpCooldowns.register]);
const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => { const loadMajors = useCallback(async (params: { search: string; limit: number; offset: number }) => {
const data = await api.getMajorsPaged(params); const data = await api.getMajorsPaged(params);
@@ -117,28 +121,28 @@ export default function Auth() {
switch (step) { switch (step) {
case "password": case "password":
return { return {
title: "رمز عبور حساب", title: "ورود با رمز عبور",
description: "برای این شماره موبایل حساب فعال پیدا شد. رمز عبور را وارد کنید یا روش ورود را به کد پیامکی تغییر دهید.", description: "برای این شماره حساب پیدا شد. رمز عبور را وارد کنید یا از کد پیامکی استفاده کنید.",
}; };
case "otp_login": case "otp_login":
return { return {
title: "ورود با کد پیامکی", title: "تأیید شماره موبایل",
description: "کد ۵ رقمی ارسال‌شده به موبایل را وارد کنید تا وارد حساب خود شوید.", description: "کد پیامکی را وارد کنید تا وارد حساب خود شوید.",
}; };
case "otp_register": case "otp_register":
return { return {
title: "تایید موبایل", title: "تأیید شماره موبایل",
description: "ابتدا موبایل شما با کد پیامکی تایید می‌شود، سپس فرم تکمیل اطلاعات نمایش داده خواهد شد.", description: "کد پیامکی را وارد کنید؛ سپس اطلاعات لازم برای ساخت حساب را تکمیل می‌کنید.",
}; };
case "register_details": case "register_details":
return { return {
title: "تکمیل اطلاعات حساب", title: "تکمیل اطلاعات حساب",
description: "موبایل شما تایید شد. حالا اطلاعات تکمیلی و رمز عبور را وارد کنید تا ثبت‌نام نهایی شود.", description: "شماره موبایل تأیید شد. اطلاعات حساب خود را وارد کنید تا ثبت‌نام کامل شود.",
}; };
default: default:
return { return {
title: "ورود و ثبت‌نام", title: "ورود یا ثبت‌نام",
description: "ابتدا شماره موبایل را وارد کنید تا مسیر مناسب برای ورود یا ثبت‌نام به شما نمایش داده شود.", description: "شماره موبایل را وارد کنید. اگر حساب رمز داشته باشد، ابتدا رمز را وارد می‌کنید؛ در غیر این صورت کد پیامکی دریافت می‌کنید.",
}; };
} }
}, [step]); }, [step]);
@@ -151,6 +155,7 @@ export default function Auth() {
key: K, key: K,
value: ReturnType<typeof createEmptyRegisterForm>[K], value: ReturnType<typeof createEmptyRegisterForm>[K],
) => { ) => {
setFormError("");
setRegisterForm((current) => ({ ...current, [key]: value })); setRegisterForm((current) => ({ ...current, [key]: value }));
}; };
@@ -159,30 +164,47 @@ export default function Auth() {
setPassword(""); setPassword("");
setOtpCode(""); setOtpCode("");
setLookupState(null); setLookupState(null);
setRegisterForm(createEmptyRegisterForm()); setFormError("");
};
const beginAuthAction = () => {
if (authActionRef.current) {
return false;
}
authActionRef.current = true;
setAuthLoading(true);
setFormError("");
return true;
};
const finishAuthAction = () => {
authActionRef.current = false;
setAuthLoading(false);
}; };
const ensureValidMobile = (value: string) => { const ensureValidMobile = (value: string) => {
const normalizedMobile = sanitizeMobile(value); const normalizedMobile = sanitizeMobile(value);
if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) { if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) {
toast({ setFormError("شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.");
title: "شماره موبایل نامعتبر است",
description: "لطفاً شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.",
variant: "destructive",
});
return null; return null;
} }
return normalizedMobile; return normalizedMobile;
}; };
const sendOtpAndAdvance = async (mode: "login" | "register", mobileValue = mobile) => { const sendOtpAndAdvance = async (
mode: "login" | "register",
mobileValue = mobile,
actionAlreadyStarted = false,
) => {
const normalizedMobile = ensureValidMobile(mobileValue); const normalizedMobile = ensureValidMobile(mobileValue);
if (!normalizedMobile) { if (!normalizedMobile) {
return; return;
} }
if (!actionAlreadyStarted && !beginAuthAction()) {
return;
}
try { try {
setAuthLoading(true);
const payload = await api.sendOtp({ mobile: normalizedMobile, mode }); const payload = await api.sendOtp({ mobile: normalizedMobile, mode });
setMobile(normalizedMobile); setMobile(normalizedMobile);
setOtpCode(""); setOtpCode("");
@@ -197,61 +219,50 @@ export default function Auth() {
variant: "success", variant: "success",
}); });
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."));
title: "ارسال کد ناموفق بود",
description: resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); if (!actionAlreadyStarted) {
finishAuthAction();
}
} }
}; };
const handleMobileStep = async (event: React.FormEvent) => { const handleMobileStep = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const normalizedMobile = ensureValidMobile(mobile); const normalizedMobile = ensureValidMobile(mobile);
if (!normalizedMobile) { if (!normalizedMobile || !beginAuthAction()) {
return; return;
} }
try { try {
setAuthLoading(true);
const lookup = await api.checkMobile(normalizedMobile); const lookup = await api.checkMobile(normalizedMobile);
setMobile(normalizedMobile); setMobile(normalizedMobile);
setLookupState(lookup); setLookupState(lookup);
setPassword(""); setPassword("");
setOtpCode(""); setOtpCode("");
if (lookup.exists && lookup.has_password) { if (lookup.exists && lookup.has_password) {
setStep("password"); setStep("password");
return; return;
} }
await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile, true);
await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile);
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."));
title: "بررسی شماره موبایل ناموفق بود",
description: resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); finishAuthAction();
} }
}; };
const handlePasswordLogin = async (event: React.FormEvent) => { const handlePasswordLogin = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!password) { if (!password) {
toast({ setFormError("برای ادامه، رمز عبور حساب را وارد کنید.");
title: "رمز عبور لازم است", return;
description: "برای ادامه، رمز عبور حساب را وارد کنید.", }
variant: "destructive", if (!beginAuthAction()) {
});
return; return;
} }
try { try {
setAuthLoading(true);
await login(mobile, password); await login(mobile, password);
toast({ toast({
title: "ورود انجام شد", title: "ورود انجام شد",
@@ -260,29 +271,23 @@ export default function Auth() {
}); });
navigate("/profile"); navigate("/profile");
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."));
title: "ورود ناموفق بود",
description: resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); finishAuthAction();
} }
}; };
const handleOtpLogin = async (event: React.FormEvent) => { const handleOtpLogin = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (otpCode.length !== OTP_LENGTH) { if (otpCode.length !== OTP_LENGTH) {
toast({ setFormError("کد ۵ رقمی پیامک‌شده را کامل وارد کنید.");
title: "کد تایید ناقص است", return;
description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.", }
variant: "destructive", if (!beginAuthAction()) {
});
return; return;
} }
try { try {
setAuthLoading(true);
await loginWithOtp(mobile, otpCode); await loginWithOtp(mobile, otpCode);
toast({ toast({
title: "ورود پیامکی انجام شد", title: "ورود پیامکی انجام شد",
@@ -291,29 +296,23 @@ export default function Auth() {
}); });
navigate("/profile"); navigate("/profile");
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "کد واردشده معتبر نیست."));
title: "ورود با کد ناموفق بود",
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); finishAuthAction();
} }
}; };
const handleRegisterOtpVerification = async (event: React.FormEvent) => { const handleRegisterOtpVerification = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (otpCode.length !== OTP_LENGTH) { if (otpCode.length !== OTP_LENGTH) {
toast({ setFormError("کد ۵ رقمی پیامک‌شده را کامل وارد کنید.");
title: "کد تایید ناقص است", return;
description: "کد ۵ رقمی پیامک‌شده را کامل وارد کنید.", }
variant: "destructive", if (!beginAuthAction()) {
});
return; return;
} }
try { try {
setAuthLoading(true);
await api.verifyRegisterOtp({ mobile, code: otpCode }); await api.verifyRegisterOtp({ mobile, code: otpCode });
setStep("register_details"); setStep("register_details");
toast({ toast({
@@ -322,13 +321,9 @@ export default function Auth() {
variant: "success", variant: "success",
}); });
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "کد واردشده معتبر نیست."));
title: "تایید کد ناموفق بود",
description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); finishAuthAction();
} }
}; };
@@ -336,34 +331,24 @@ export default function Auth() {
event.preventDefault(); event.preventDefault();
if (!USERNAME_REGEX.test(registerForm.username)) { if (!USERNAME_REGEX.test(registerForm.username)) {
toast({ setFormError("نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.");
title: "نام کاربری نامعتبر است",
description: "نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.",
variant: "destructive",
});
return; return;
} }
if (registerForm.password.length < 8) { if (registerForm.password.length < 8) {
toast({ setFormError("رمز عبور باید حداقل ۸ کاراکتر داشته باشد.");
title: "رمز عبور کوتاه است",
description: "رمز عبور باید حداقل ۸ کاراکتر داشته باشد.",
variant: "destructive",
});
return; return;
} }
if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) { if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) {
toast({ setFormError("اگر ایمیل وارد می‌کنید، فرمت آن باید صحیح باشد.");
title: "ایمیل نامعتبر است", return;
description: "اگر ایمیل وارد می‌کنید، فرمت آن باید صحیح باشد.", }
variant: "destructive", if (!beginAuthAction()) {
});
return; return;
} }
try { try {
setAuthLoading(true);
await api.register({ await api.register({
username: registerForm.username, username: registerForm.username,
mobile, mobile,
@@ -378,24 +363,28 @@ export default function Auth() {
university: registerForm.university, university: registerForm.university,
}); });
toast({ const registrationPassword = registerForm.password;
title: "ثبت‌نام کامل شد",
description: "اکنون می‌توانید با موبایل و رمز عبور یا کد پیامکی وارد شوید.",
variant: "success",
});
setLookupState({ exists: true, has_password: true }); setLookupState({ exists: true, has_password: true });
setPassword(""); setPassword(registrationPassword);
setRegisterForm(createEmptyRegisterForm()); setRegisterForm(createEmptyRegisterForm());
setOtpCode(""); setOtpCode("");
setStep("password");
try {
await login(mobile, registrationPassword);
toast({
title: "ثبت‌نام کامل شد",
description: "حساب شما ساخته شد و وارد سایت شدید.",
variant: "success",
});
navigate("/profile");
} catch {
setStep("password");
setFormError("حساب ساخته شد، اما ورود خودکار کامل نشد. با همین رمز وارد شوید.");
}
} catch (error: unknown) { } catch (error: unknown) {
toast({ setFormError(resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."));
title: "ثبت‌نام ناموفق بود",
description: resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."),
variant: "destructive",
});
} finally { } finally {
setAuthLoading(false); finishAuthAction();
} }
}; };
@@ -408,9 +397,9 @@ export default function Auth() {
{mobile} {mobile}
</p> </p>
</div> </div>
<Button type="button" variant="ghost" className="rounded-xl text-sm" onClick={resetToMobileStep}> <Button type="button" variant="ghost" className="rounded-xl text-sm" onClick={resetToMobileStep} disabled={authLoading}>
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
تغییر شماره ویرایش شماره
</Button> </Button>
</div> </div>
) : null; ) : null;
@@ -426,12 +415,20 @@ export default function Auth() {
type="tel" type="tel"
dir="ltr" dir="ltr"
inputMode="numeric" inputMode="numeric"
autoComplete="tel"
required
pattern="09[0-9]{9}"
maxLength={11} maxLength={11}
disabled={authLoading}
value={mobile} value={mobile}
onChange={(event) => setMobile(sanitizeMobile(event.target.value))} onChange={(event) => {
setMobile(sanitizeMobile(event.target.value));
setFormError("");
}}
placeholder="09xxxxxxxxx" placeholder="09xxxxxxxxx"
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
/> />
<p className="mt-2 text-right text-xs text-muted-foreground">مثال: 09123456789</p>
</div> </div>
<Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}> <Button type="submit" className="h-12 w-full rounded-2xl" disabled={authLoading}>
@@ -462,6 +459,7 @@ export default function Auth() {
variant="outline" variant="outline"
className="h-12 w-full rounded-2xl border-border/70 bg-background/80" className="h-12 w-full rounded-2xl border-border/70 bg-background/80"
onClick={() => void api.startGoogleLogin()} onClick={() => void api.startGoogleLogin()}
disabled={authLoading}
> >
<GoogleIcon /> <GoogleIcon />
<span>ادامه با حساب گوگل</span> <span>ادامه با حساب گوگل</span>
@@ -480,8 +478,11 @@ export default function Auth() {
<Input <Input
id="password" id="password"
type="password" type="password"
autoComplete="current-password"
required
disabled={authLoading}
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => { setPassword(event.target.value); setFormError(""); }}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
/> />
</div> </div>
@@ -510,12 +511,6 @@ export default function Auth() {
{otpCooldowns.login > 0 ? `ارسال مجدد تا ${otpCooldowns.login} ثانیه` : "ورود با کد پیامکی"} {otpCooldowns.login > 0 ? `ارسال مجدد تا ${otpCooldowns.login} ثانیه` : "ورود با کد پیامکی"}
</Button> </Button>
<Link
to="/reset-password"
className="block text-right text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
رمز عبور را فراموش کرده‌ام
</Link>
</form> </form>
); );
@@ -528,7 +523,12 @@ export default function Auth() {
<div> <div>
<Label className="mb-3 block text-right">کد ۵ رقمی پیامکی</Label> <Label className="mb-3 block text-right">کد ۵ رقمی پیامکی</Label>
<OtpCodeField value={otpCode} onChange={(value) => setOtpCode(normalizeDigits(value))} disabled={authLoading} /> <OtpCodeField
value={otpCode}
onChange={(value) => { setOtpCode(normalizeDigits(value)); setFormError(""); }}
disabled={authLoading}
ariaLabel="کد ۵ رقمی پیامکی"
/>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row-reverse"> <div className="flex flex-col gap-3 sm:flex-row-reverse">
@@ -557,7 +557,7 @@ export default function Auth() {
</div> </div>
{isLogin && lookupState?.has_password ? ( {isLogin && lookupState?.has_password ? (
<Button type="button" variant="ghost" className="h-11 w-full rounded-2xl" onClick={() => setStep("password")}> <Button type="button" variant="ghost" className="h-11 w-full rounded-2xl" onClick={() => setStep("password")} disabled={authLoading}>
بازگشت به ورود با رمز عبور بازگشت به ورود با رمز عبور
</Button> </Button>
) : null} ) : null}
@@ -580,6 +580,7 @@ export default function Auth() {
</Label> </Label>
<Input <Input
id="register-first-name" id="register-first-name"
disabled={authLoading}
value={registerForm.first_name} value={registerForm.first_name}
onChange={(event) => updateRegisterForm("first_name", event.target.value)} onChange={(event) => updateRegisterForm("first_name", event.target.value)}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
@@ -591,6 +592,7 @@ export default function Auth() {
</Label> </Label>
<Input <Input
id="register-last-name" id="register-last-name"
disabled={authLoading}
value={registerForm.last_name} value={registerForm.last_name}
onChange={(event) => updateRegisterForm("last_name", event.target.value)} onChange={(event) => updateRegisterForm("last_name", event.target.value)}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
@@ -606,8 +608,11 @@ export default function Auth() {
<Input <Input
id="register-username" id="register-username"
dir="ltr" dir="ltr"
autoComplete="username"
required
disabled={authLoading}
value={registerForm.username} value={registerForm.username}
onChange={(event) => updateRegisterForm("username", sanitizeUsername(event.target.value))} onChange={(event) => { updateRegisterForm("username", sanitizeUsername(event.target.value)); setFormError(""); }}
placeholder="latin.username" placeholder="latin.username"
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
/> />
@@ -619,8 +624,12 @@ export default function Auth() {
<Input <Input
id="register-password" id="register-password"
type="password" type="password"
autoComplete="new-password"
minLength={8}
required
disabled={authLoading}
value={registerForm.password} value={registerForm.password}
onChange={(event) => updateRegisterForm("password", event.target.value)} onChange={(event) => { updateRegisterForm("password", event.target.value); setFormError(""); }}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
/> />
</div> </div>
@@ -634,6 +643,7 @@ export default function Auth() {
<Input <Input
id="register-email" id="register-email"
type="email" type="email"
disabled={authLoading}
value={registerForm.email} value={registerForm.email}
onChange={(event) => updateRegisterForm("email", event.target.value)} onChange={(event) => updateRegisterForm("email", event.target.value)}
placeholder="user@example.com" placeholder="user@example.com"
@@ -648,6 +658,7 @@ export default function Auth() {
id="register-student-id" id="register-student-id"
dir="ltr" dir="ltr"
inputMode="numeric" inputMode="numeric"
disabled={authLoading}
value={registerForm.student_id} value={registerForm.student_id}
onChange={(event) => updateRegisterForm("student_id", sanitizeMobile(event.target.value))} onChange={(event) => updateRegisterForm("student_id", sanitizeMobile(event.target.value))}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
@@ -667,6 +678,7 @@ export default function Auth() {
loadOptions={loadUniversities} loadOptions={loadUniversities}
value={registerForm.university} value={registerForm.university}
onChange={(value) => updateRegisterForm("university", value)} onChange={(value) => updateRegisterForm("university", value)}
disabled={authLoading}
placeholder="انتخاب دانشگاه" placeholder="انتخاب دانشگاه"
searchPlaceholder="نام دانشگاه را بنویسید..." searchPlaceholder="نام دانشگاه را بنویسید..."
emptyText="دانشگاهی پیدا نشد" emptyText="دانشگاهی پیدا نشد"
@@ -685,6 +697,7 @@ export default function Auth() {
loadOptions={loadMajors} loadOptions={loadMajors}
value={registerForm.major} value={registerForm.major}
onChange={(value) => updateRegisterForm("major", value)} onChange={(value) => updateRegisterForm("major", value)}
disabled={authLoading}
placeholder="انتخاب رشته" placeholder="انتخاب رشته"
searchPlaceholder="نام رشته را بنویسید..." searchPlaceholder="نام رشته را بنویسید..."
emptyText="رشته‌ای پیدا نشد" emptyText="رشته‌ای پیدا نشد"
@@ -702,6 +715,7 @@ export default function Auth() {
id="register-year" id="register-year"
dir="ltr" dir="ltr"
inputMode="numeric" inputMode="numeric"
disabled={authLoading}
value={registerForm.year_of_study} value={registerForm.year_of_study}
onChange={(event) => updateRegisterForm("year_of_study", sanitizeMobile(event.target.value))} onChange={(event) => updateRegisterForm("year_of_study", sanitizeMobile(event.target.value))}
className="h-12 rounded-2xl" className="h-12 rounded-2xl"
@@ -740,17 +754,11 @@ export default function Auth() {
<CardDescription>{stepMeta.description}</CardDescription> <CardDescription>{stepMeta.description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <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"> {formError ? (
<div className="flex items-start justify-between gap-3"> <div role="alert" aria-live="assertive" className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-right text-sm leading-6 text-destructive dark:border-red-400/40 dark:bg-red-950/60 dark:text-red-200">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" /> <p>{formError}</p>
<div className="text-right">
<p className="font-semibold">نکته مهم برای کاربران قبلی</p>
<p className="mt-2">
اگر دسترسی به ایمیل ندارید و رمز عبور را فراموش کرده‌اید، در همین مرحله از دکمه گوگل استفاده کنید. اگر ایمیل حساب شما با گوگل یکسان باشد، می‌توانید حساب را بازیابی کرده و بعد موبایل خود را تایید کنید.
</p>
</div>
</div> </div>
</div> ) : null}
{step === "mobile" ? renderMobileStep() : null} {step === "mobile" ? renderMobileStep() : null}
{step === "password" ? renderPasswordStep() : null} {step === "password" ? renderPasswordStep() : null}

View File

@@ -1,235 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle, Loader2, ShieldCheck } from "lucide-react";
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 { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import { Link } from "@/lib/router";
import { resolveErrorMessage } from "@/lib/utils";
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, "");
export default function ResetPasswordRequest() {
const { toast } = useToast();
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [cooldown, setCooldown] = useState(0);
useEffect(() => {
if (cooldown <= 0) {
return;
}
const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000);
return () => window.clearTimeout(timer);
}, [cooldown]);
const handleSendOtp = async () => {
try {
setLoading(true);
const response = await api.sendOtp({
mobile: sanitizeMobile(mobile),
mode: "reset_password",
});
setCooldown(Math.min(response.expires_in_seconds, 120));
toast({
title: "کد بازیابی ارسال شد",
description: response.message,
variant: "success",
});
} catch (error: unknown) {
toast({
title: "ارسال کد انجام نشد",
description: resolveErrorMessage(error, "امکان ارسال پیامک بازیابی وجود ندارد."),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleResetPassword = async (event: React.FormEvent) => {
event.preventDefault();
if (newPassword.length < 8) {
toast({
title: "رمز عبور کوتاه است",
description: "رمز جدید باید حداقل ۸ کاراکتر داشته باشد.",
variant: "destructive",
});
return;
}
if (newPassword !== confirmPassword) {
toast({
title: "عدم تطابق رمزها",
description: "تکرار رمز عبور با رمز جدید یکسان نیست.",
variant: "destructive",
});
return;
}
try {
setLoading(true);
await api.resetPassword({
mobile: sanitizeMobile(mobile),
code: normalizeDigits(code),
new_password: newPassword,
});
toast({
title: "رمز عبور تغییر کرد",
description: "اکنون می‌توانید با رمز جدید وارد شوید.",
variant: "success",
});
setCode("");
setNewPassword("");
setConfirmPassword("");
} catch (error: unknown) {
toast({
title: "بازیابی ناموفق بود",
description: resolveErrorMessage(error, "کد تأیید یا رمز جدید قابل پذیرش نیست."),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-background px-4 py-10" dir="rtl">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 lg:flex-row">
<Card className="border-border/70 bg-background/85 shadow-xl backdrop-blur-xl lg:w-[22rem]">
<CardHeader className="text-right">
<CardTitle>بازیابی حساب بدون ایمیل</CardTitle>
<CardDescription className="leading-7">
بازیابی رمز عبور اکنون با موبایل و کد پیامکی انجام می‌شود. اگر به موبایل ثبت‌شده هم دسترسی ندارید، از همان حساب گوگلی که قبلاً با ایمیل‌تان استفاده می‌کردید کمک بگیرید.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-right">
<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>
<p className="font-semibold">اگر رمز را فراموش کرده‌اید</p>
<p className="mt-2">
می‌توانید از مسیر ورود با گوگل ادامه دهید؛ به شرطی که حساب گوگل شما با ایمیل قدیمی‌تان یکسان باشد.
</p>
</div>
</div>
</div>
<Button
type="button"
variant="outline"
className="h-12 w-full rounded-2xl"
onClick={() => void api.startGoogleLogin()}
>
ادامه با حساب گوگل
</Button>
<div className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4 text-sm leading-7 text-muted-foreground">
<p className="font-medium text-foreground">گام‌های بازیابی</p>
<ol className="mt-2 space-y-2">
<li>۱. موبایل ثبت‌شده را وارد کنید.</li>
<li>۲. کد پیامکی را دریافت و ثبت کنید.</li>
<li>۳. رمز عبور جدید را تعیین کنید.</li>
</ol>
</div>
</CardContent>
</Card>
<Card className="flex-1 border-border/70 bg-background/90 shadow-xl backdrop-blur-xl">
<CardHeader className="text-right">
<CardTitle>تغییر رمز عبور با کد پیامکی</CardTitle>
<CardDescription>
این فرم جایگزین کامل بازیابی ایمیلی است.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleResetPassword} className="space-y-5">
<div>
<Label htmlFor="mobile" className="mb-2 block text-right">شماره موبایل</Label>
<Input
id="mobile"
type="tel"
dir="ltr"
inputMode="numeric"
value={mobile}
onChange={(event) => setMobile(sanitizeMobile(event.target.value))}
placeholder="09xxxxxxxxx"
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label className="mb-3 block text-right">کد بازیابی</Label>
<OtpCodeField value={code} onChange={(value) => setCode(normalizeDigits(value))} disabled={loading} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="new-password" className="mb-2 block text-right">رمز عبور جدید</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
<div>
<Label htmlFor="confirm-password" className="mb-2 block text-right">تکرار رمز عبور</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
className="h-12 rounded-2xl"
/>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button type="submit" className="h-12 flex-1 rounded-2xl" disabled={loading || code.length !== 5}>
{loading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ثبت...
</>
) : (
<>
<ShieldCheck className="ml-2 h-4 w-4" />
ثبت رمز جدید
</>
)}
</Button>
<Button
type="button"
variant="outline"
className="h-12 rounded-2xl"
onClick={() => void handleSendOtp()}
disabled={loading || cooldown > 0}
>
{cooldown > 0 ? `ارسال مجدد تا ${cooldown} ثانیه` : "ارسال کد بازیابی"}
</Button>
</div>
<div className="text-right text-sm text-muted-foreground">
<Link to="/auth" className="underline underline-offset-4">
بازگشت به صفحه ورود
</Link>
</div>
</form>
</CardContent>
</Card>
</div>
</div>
);
}