From b7d5f7bc652ae77a17082e429ade05cd900c4e94 Mon Sep 17 00:00:00 2001
From: Amirhossein Khalili
Date: Mon, 28 Sep 2026 22:06:42 +0330
Subject: [PATCH] feat: simplify phone-first authentication flow
---
src/app/reset-password/page.tsx | 10 +-
src/app/robots.ts | 1 -
src/components/MobileBottomNav.tsx | 3 +-
src/components/Navbar.tsx | 8 +-
src/components/OtpCodeField.tsx | 3 +
src/components/ui/button.tsx | 6 +-
src/components/ui/navigation-menu.tsx | 2 +-
src/components/ui/toggle.tsx | 2 +-
src/lib/api.ts | 7 -
src/lib/types.ts | 8 +-
src/views/Auth.tsx | 266 +++++++++++++-------------
src/views/ResetPasswordRequest.tsx | 235 -----------------------
12 files changed, 150 insertions(+), 401 deletions(-)
delete mode 100644 src/views/ResetPasswordRequest.tsx
diff --git a/src/app/reset-password/page.tsx b/src/app/reset-password/page.tsx
index 3d7579e..3e57ff9 100644
--- a/src/app/reset-password/page.tsx
+++ b/src/app/reset-password/page.tsx
@@ -1,11 +1,5 @@
-import type { Metadata } from "next";
-import ResetPasswordRequest from "@/views/ResetPasswordRequest";
-
-export const metadata: Metadata = {
- title: "بازیابی رمز عبور",
- robots: { index: false, follow: false },
-};
+import { redirect } from "next/navigation";
export default function ResetPasswordPage() {
- return ;
+ redirect("/auth");
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
index 4ed1483..b7fc6b3 100644
--- a/src/app/robots.ts
+++ b/src/app/robots.ts
@@ -13,7 +13,6 @@ export default function robots(): MetadataRoute.Robots {
"/logout",
"/profile",
"/payments/",
- "/reset-password",
],
},
],
diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx
index 06609f0..24ec1a8 100644
--- a/src/components/MobileBottomNav.tsx
+++ b/src/components/MobileBottomNav.tsx
@@ -20,8 +20,7 @@ const isProfileOrAuthPath = (pathname: string) =>
pathname === "/profile" ||
pathname.startsWith("/profile/") ||
pathname === "/auth" ||
- pathname.startsWith("/auth/") ||
- pathname.startsWith("/reset-password");
+ pathname.startsWith("/auth/");
export default function MobileBottomNav({ siteContent }: { siteContent: SiteContent }) {
const pathname = usePathname() || "/";
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index b68a3a0..1f2ba63 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -3,7 +3,7 @@
import type { ReactNode } from "react";
import { useMemo } from "react";
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 { useAuth } from "@/contexts/AuthContext";
import ModeToggle from "@/components/ModeToggle";
@@ -91,12 +91,6 @@ function ProfileAvatarMenu() {
-
-
- تغییر یا بازیابی رمز
-
-
-
{isAdminUser ? (
diff --git a/src/components/OtpCodeField.tsx b/src/components/OtpCodeField.tsx
index e3a66b9..141255b 100644
--- a/src/components/OtpCodeField.tsx
+++ b/src/components/OtpCodeField.tsx
@@ -9,6 +9,7 @@ type OtpCodeFieldProps = {
disabled?: boolean;
length?: number;
className?: string;
+ ariaLabel?: string;
};
export default function OtpCodeField({
@@ -17,6 +18,7 @@ export default function OtpCodeField({
disabled = false,
length = 5,
className,
+ ariaLabel = "کد تایید پیامکی",
}: OtpCodeFieldProps) {
return (
onChange(nextValue.replace(/[^\d]/g, ""))}
disabled={disabled}
inputMode="numeric"
+ aria-label={ariaLabel}
containerClassName={cn("w-full justify-center", className)}
>
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index cdedd4f..22418d2 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -11,9 +11,9 @@ const buttonVariants = cva(
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
- outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost: "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/75 hover:text-foreground",
+ ghost: "hover:bg-secondary hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
diff --git a/src/components/ui/navigation-menu.tsx b/src/components/ui/navigation-menu.tsx
index c9fcd7a..66db5f9 100644
--- a/src/components/ui/navigation-menu.tsx
+++ b/src/components/ui/navigation-menu.tsx
@@ -35,7 +35,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
const NavigationMenuItem = NavigationMenuPrimitive.Item;
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<
diff --git a/src/components/ui/toggle.tsx b/src/components/ui/toggle.tsx
index de5dfc5..45a04eb 100644
--- a/src/components/ui/toggle.tsx
+++ b/src/components/ui/toggle.tsx
@@ -10,7 +10,7 @@ const toggleVariants = cva(
variants: {
variant: {
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: {
default: "h-10 px-3",
diff --git a/src/lib/api.ts b/src/lib/api.ts
index fdc4cf0..8351bd9 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -207,13 +207,6 @@ class ApiClient {
});
}
- async resetPassword(data: Types.PasswordResetSchema) {
- return this.request('/api/auth/reset-password', {
- method: 'POST',
- body: JSON.stringify(data),
- });
- }
-
async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) {
return this.request('/api/auth/mobile/send-otp', {
method: 'POST',
diff --git a/src/lib/types.ts b/src/lib/types.ts
index e26468e..5a8c110 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -172,7 +172,7 @@ export interface RegisterOtpVerifySchema {
export interface OtpSendSchema {
mobile: string;
- mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim";
+ mode: "register" | "login" | "verify_mobile" | "google_claim";
}
export interface OtpSendResponseSchema {
@@ -194,12 +194,6 @@ export interface MobileLookupSchema {
has_password: boolean;
}
-export interface PasswordResetSchema {
- mobile: string;
- code: string;
- new_password: string;
-}
-
export interface MobileOtpSendSchema {
mobile: string;
}
diff --git a/src/views/Auth.tsx b/src/views/Auth.tsx
index f76a25e..572a1af 100644
--- a/src/views/Auth.tsx
+++ b/src/views/Auth.tsx
@@ -1,8 +1,7 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- AlertTriangle,
ArrowRight,
KeyRound,
Loader2,
@@ -18,7 +17,7 @@ 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 { Navigate, useNavigate } from "@/lib/router";
import { resolveErrorMessage } from "@/lib/utils";
type AuthStep = "mobile" | "password" | "otp_login" | "otp_register" | "register_details";
@@ -75,6 +74,8 @@ export default function Auth() {
const { login, loginWithOtp, isAuthenticated } = useAuth();
const [step, setStep] = useState("mobile");
const [authLoading, setAuthLoading] = useState(false);
+ const [formError, setFormError] = useState("");
+ const authActionRef = useRef(false);
const [mobile, setMobile] = useState("");
const [password, setPassword] = useState("");
const [otpCode, setOtpCode] = useState("");
@@ -86,14 +87,17 @@ export default function Auth() {
const [registerForm, setRegisterForm] = useState(createEmptyRegisterForm);
useEffect(() => {
- const timer = window.setInterval(() => {
+ if (otpCooldowns.login <= 0 && otpCooldowns.register <= 0) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
setOtpCooldowns((current) => ({
login: Math.max(current.login - 1, 0),
register: Math.max(current.register - 1, 0),
}));
}, 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 data = await api.getMajorsPaged(params);
@@ -117,28 +121,28 @@ export default function Auth() {
switch (step) {
case "password":
return {
- title: "رمز عبور حساب",
- description: "برای این شماره موبایل حساب فعال پیدا شد. رمز عبور را وارد کنید یا روش ورود را به کد پیامکی تغییر دهید.",
+ title: "ورود با رمز عبور",
+ description: "برای این شماره حساب پیدا شد. رمز عبور را وارد کنید یا از کد پیامکی استفاده کنید.",
};
case "otp_login":
return {
- title: "ورود با کد پیامکی",
- description: "کد ۵ رقمی ارسالشده به موبایل را وارد کنید تا وارد حساب خود شوید.",
+ title: "تأیید شماره موبایل",
+ description: "کد پیامکی را وارد کنید تا وارد حساب خود شوید.",
};
case "otp_register":
return {
- title: "تایید موبایل",
- description: "ابتدا موبایل شما با کد پیامکی تایید میشود، سپس فرم تکمیل اطلاعات نمایش داده خواهد شد.",
+ title: "تأیید شماره موبایل",
+ description: "کد پیامکی را وارد کنید؛ سپس اطلاعات لازم برای ساخت حساب را تکمیل میکنید.",
};
case "register_details":
return {
title: "تکمیل اطلاعات حساب",
- description: "موبایل شما تایید شد. حالا اطلاعات تکمیلی و رمز عبور را وارد کنید تا ثبتنام نهایی شود.",
+ description: "شماره موبایل تأیید شد. اطلاعات حساب خود را وارد کنید تا ثبتنام کامل شود.",
};
default:
return {
- title: "ورود و ثبتنام",
- description: "ابتدا شماره موبایل را وارد کنید تا مسیر مناسب برای ورود یا ثبتنام به شما نمایش داده شود.",
+ title: "ورود یا ثبتنام",
+ description: "شماره موبایل را وارد کنید. اگر حساب رمز داشته باشد، ابتدا رمز را وارد میکنید؛ در غیر این صورت کد پیامکی دریافت میکنید.",
};
}
}, [step]);
@@ -151,6 +155,7 @@ export default function Auth() {
key: K,
value: ReturnType[K],
) => {
+ setFormError("");
setRegisterForm((current) => ({ ...current, [key]: value }));
};
@@ -159,30 +164,47 @@ export default function Auth() {
setPassword("");
setOtpCode("");
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 normalizedMobile = sanitizeMobile(value);
if (normalizedMobile.length !== 11 || !normalizedMobile.startsWith("09")) {
- toast({
- title: "شماره موبایل نامعتبر است",
- description: "لطفاً شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.",
- variant: "destructive",
- });
+ setFormError("شماره موبایل را به شکل 09xxxxxxxxx وارد کنید.");
return null;
}
return normalizedMobile;
};
- const sendOtpAndAdvance = async (mode: "login" | "register", mobileValue = mobile) => {
+ const sendOtpAndAdvance = async (
+ mode: "login" | "register",
+ mobileValue = mobile,
+ actionAlreadyStarted = false,
+ ) => {
const normalizedMobile = ensureValidMobile(mobileValue);
if (!normalizedMobile) {
return;
}
+ if (!actionAlreadyStarted && !beginAuthAction()) {
+ return;
+ }
try {
- setAuthLoading(true);
const payload = await api.sendOtp({ mobile: normalizedMobile, mode });
setMobile(normalizedMobile);
setOtpCode("");
@@ -197,61 +219,50 @@ export default function Auth() {
variant: "success",
});
} catch (error: unknown) {
- toast({
- title: "ارسال کد ناموفق بود",
- description: resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "ارسال کد پیامکی انجام نشد."));
} finally {
- setAuthLoading(false);
+ if (!actionAlreadyStarted) {
+ finishAuthAction();
+ }
}
};
const handleMobileStep = async (event: React.FormEvent) => {
event.preventDefault();
const normalizedMobile = ensureValidMobile(mobile);
- if (!normalizedMobile) {
+ if (!normalizedMobile || !beginAuthAction()) {
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);
+ await sendOtpAndAdvance(lookup.exists ? "login" : "register", normalizedMobile, true);
} catch (error: unknown) {
- toast({
- title: "بررسی شماره موبایل ناموفق بود",
- description: resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "امکان ادامه با این شماره موبایل وجود ندارد."));
} finally {
- setAuthLoading(false);
+ finishAuthAction();
}
};
const handlePasswordLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (!password) {
- toast({
- title: "رمز عبور لازم است",
- description: "برای ادامه، رمز عبور حساب را وارد کنید.",
- variant: "destructive",
- });
+ setFormError("برای ادامه، رمز عبور حساب را وارد کنید.");
+ return;
+ }
+ if (!beginAuthAction()) {
return;
}
try {
- setAuthLoading(true);
await login(mobile, password);
toast({
title: "ورود انجام شد",
@@ -260,29 +271,23 @@ export default function Auth() {
});
navigate("/profile");
} catch (error: unknown) {
- toast({
- title: "ورود ناموفق بود",
- description: resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "اطلاعات ورود صحیح نیست."));
} finally {
- setAuthLoading(false);
+ finishAuthAction();
}
};
const handleOtpLogin = async (event: React.FormEvent) => {
event.preventDefault();
if (otpCode.length !== OTP_LENGTH) {
- toast({
- title: "کد تایید ناقص است",
- description: "کد ۵ رقمی پیامکشده را کامل وارد کنید.",
- variant: "destructive",
- });
+ setFormError("کد ۵ رقمی پیامکشده را کامل وارد کنید.");
+ return;
+ }
+ if (!beginAuthAction()) {
return;
}
try {
- setAuthLoading(true);
await loginWithOtp(mobile, otpCode);
toast({
title: "ورود پیامکی انجام شد",
@@ -291,29 +296,23 @@ export default function Auth() {
});
navigate("/profile");
} catch (error: unknown) {
- toast({
- title: "ورود با کد ناموفق بود",
- description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "کد واردشده معتبر نیست."));
} finally {
- setAuthLoading(false);
+ finishAuthAction();
}
};
const handleRegisterOtpVerification = async (event: React.FormEvent) => {
event.preventDefault();
if (otpCode.length !== OTP_LENGTH) {
- toast({
- title: "کد تایید ناقص است",
- description: "کد ۵ رقمی پیامکشده را کامل وارد کنید.",
- variant: "destructive",
- });
+ setFormError("کد ۵ رقمی پیامکشده را کامل وارد کنید.");
+ return;
+ }
+ if (!beginAuthAction()) {
return;
}
try {
- setAuthLoading(true);
await api.verifyRegisterOtp({ mobile, code: otpCode });
setStep("register_details");
toast({
@@ -322,13 +321,9 @@ export default function Auth() {
variant: "success",
});
} catch (error: unknown) {
- toast({
- title: "تایید کد ناموفق بود",
- description: resolveErrorMessage(error, "کد واردشده معتبر نیست."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "کد واردشده معتبر نیست."));
} finally {
- setAuthLoading(false);
+ finishAuthAction();
}
};
@@ -336,34 +331,24 @@ export default function Auth() {
event.preventDefault();
if (!USERNAME_REGEX.test(registerForm.username)) {
- toast({
- title: "نام کاربری نامعتبر است",
- description: "نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.",
- variant: "destructive",
- });
+ setFormError("نام کاربری باید ۳ تا ۳۰ کاراکتر و فقط شامل حروف لاتین، عدد، نقطه، آندرلاین یا خط تیره باشد.");
return;
}
if (registerForm.password.length < 8) {
- toast({
- title: "رمز عبور کوتاه است",
- description: "رمز عبور باید حداقل ۸ کاراکتر داشته باشد.",
- variant: "destructive",
- });
+ setFormError("رمز عبور باید حداقل ۸ کاراکتر داشته باشد.");
return;
}
if (registerForm.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(registerForm.email.trim())) {
- toast({
- title: "ایمیل نامعتبر است",
- description: "اگر ایمیل وارد میکنید، فرمت آن باید صحیح باشد.",
- variant: "destructive",
- });
+ setFormError("اگر ایمیل وارد میکنید، فرمت آن باید صحیح باشد.");
+ return;
+ }
+ if (!beginAuthAction()) {
return;
}
try {
- setAuthLoading(true);
await api.register({
username: registerForm.username,
mobile,
@@ -378,24 +363,28 @@ export default function Auth() {
university: registerForm.university,
});
- toast({
- title: "ثبتنام کامل شد",
- description: "اکنون میتوانید با موبایل و رمز عبور یا کد پیامکی وارد شوید.",
- variant: "success",
- });
+ const registrationPassword = registerForm.password;
setLookupState({ exists: true, has_password: true });
- setPassword("");
+ setPassword(registrationPassword);
setRegisterForm(createEmptyRegisterForm());
setOtpCode("");
- setStep("password");
+
+ try {
+ await login(mobile, registrationPassword);
+ toast({
+ title: "ثبتنام کامل شد",
+ description: "حساب شما ساخته شد و وارد سایت شدید.",
+ variant: "success",
+ });
+ navigate("/profile");
+ } catch {
+ setStep("password");
+ setFormError("حساب ساخته شد، اما ورود خودکار کامل نشد. با همین رمز وارد شوید.");
+ }
} catch (error: unknown) {
- toast({
- title: "ثبتنام ناموفق بود",
- description: resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."),
- variant: "destructive",
- });
+ setFormError(resolveErrorMessage(error, "اطلاعات ارسالی قابل پذیرش نیست."));
} finally {
- setAuthLoading(false);
+ finishAuthAction();
}
};
@@ -408,9 +397,9 @@ export default function Auth() {
{mobile}
-