From 22b500dba5d520ccb76e41266e2fb1537b9051b7 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 23 Jun 2026 02:07:14 +0330 Subject: [PATCH] feat(auth): unify mobile-first authentication flow --- src/App.tsx | 7 +- src/api/users.ts | 12 ++ src/context/AuthFlowContext.tsx | 30 ++++- src/locales/en.ts | 35 +++--- src/locales/fa.ts | 47 ++++--- src/pages/GoogleAuthCallback.tsx | 2 +- src/pages/auth/AuthPanel.tsx | 45 ++++++- src/pages/auth/AuthPasswordField.tsx | 13 +- src/pages/auth/ForgotPasswordMobilePage.tsx | 4 +- src/pages/auth/ForgotPasswordOtpPage.tsx | 2 + src/pages/auth/ForgotPasswordPasswordPage.tsx | 2 + src/pages/auth/LoginMobilePage.tsx | 67 ++++++---- src/pages/auth/LoginOtpPage.tsx | 5 +- src/pages/auth/LoginPasswordPage.tsx | 5 +- src/pages/auth/SignupMobilePage.tsx | 2 +- src/pages/auth/SignupOtpPage.tsx | 55 +++++++-- src/pages/auth/SignupPasswordPage.tsx | 115 ++++++++++++------ src/pages/auth/utils.ts | 9 ++ 18 files changed, 334 insertions(+), 123 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 78d0d9e..0cec36c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,6 @@ import { AuthFlowProvider } from "./context/AuthFlowContext" import { LoginMobilePage } from "./pages/auth/LoginMobilePage" import { LoginOtpPage } from "./pages/auth/LoginOtpPage" import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage" -import { SignupMobilePage } from "./pages/auth/SignupMobilePage" import { SignupOtpPage } from "./pages/auth/SignupOtpPage" import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage" import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage" @@ -156,11 +155,11 @@ const router = createBrowserRouter([ { element: , children: [ - { index: true, element: }, - { path: "login", element: }, + { index: true, element: }, + { path: "login", element: }, { path: "login/verify", element: }, { path: "login/password", element: }, - { path: "signup", element: }, + { path: "signup", element: }, { path: "signup/verify", element: }, { path: "signup/password", element: }, { path: "forgot-password", element: }, diff --git a/src/api/users.ts b/src/api/users.ts index 647e724..cfc063a 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -7,6 +7,18 @@ const normalizeDigits = (value: string) => // --- Auth Endpoints --- +export type ResolveAuthMobileStatus = "existing_user" | "new_user" + +export const resolveAuthMobile = async (mobile: string): Promise<{ status: ResolveAuthMobileStatus }> => { + const normalizedMobile = normalizeDigits(mobile) + const response = await authFetch("/api/users/auth/resolve-mobile/", { + method: "POST", + body: JSON.stringify({ mobile: normalizedMobile }), + }) + if (!response.ok) throw await buildApiError(response) + return response.json() +} + export const loginWithPassword = async (mobile: string, password: string) => { const normalizedMobile = normalizeDigits(mobile) const response = await authFetch('/api/users/login/', { diff --git a/src/context/AuthFlowContext.tsx b/src/context/AuthFlowContext.tsx index 6aba529..59ae6d6 100644 --- a/src/context/AuthFlowContext.tsx +++ b/src/context/AuthFlowContext.tsx @@ -30,10 +30,20 @@ interface AuthFlowState { cooldowns: CooldownState } +interface SignupDetailsState { + password: string + confirmation: string + firstName: string + lastName: string +} + interface AuthFlowContextValue { state: AuthFlowState + signupDetails: SignupDetailsState setMobile: (flow: FlowName, mobile: string) => void setCode: (flow: FlowName, code: string) => void + setSignupDetails: (details: SignupDetailsState) => void + clearSignupDetails: () => void markOtpSendPending: (flow: FlowName) => void setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void clearOtpDelivery: (flow: FlowName) => void @@ -72,6 +82,13 @@ const defaultState: AuthFlowState = { }, } +const defaultSignupDetails: SignupDetailsState = { + password: "", + confirmation: "", + firstName: "", + lastName: "", +} + const AuthFlowContext = createContext(null) const parseStoredState = (): AuthFlowState => { @@ -121,6 +138,7 @@ const parseStoredState = (): AuthFlowState => { export function AuthFlowProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(parseStoredState) + const [signupDetails, setSignupDetailsState] = useState(defaultSignupDetails) useEffect(() => { window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)) @@ -150,6 +168,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) { const value = useMemo( () => ({ state, + signupDetails, setMobile: (flow, mobile) => { setState((current) => ({ ...current, @@ -168,6 +187,12 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) { }, })) }, + setSignupDetails: (details) => { + setSignupDetailsState(details) + }, + clearSignupDetails: () => { + setSignupDetailsState(defaultSignupDetails) + }, markOtpSendPending: (flow) => { setState((current) => ({ ...current, @@ -226,9 +251,12 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) { pendingOtpSend: false, }, })) + if (flow === "signup") { + setSignupDetailsState(defaultSignupDetails) + } }, }), - [state], + [signupDetails, state], ) return {children} diff --git a/src/locales/en.ts b/src/locales/en.ts index d151b41..7230b81 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -27,28 +27,31 @@ export const en = { login: { welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`, - loginTitle: "Sign in to your account", - loginDescription: "Enter your mobile number to continue with password login.", - loginCta: "Login", - createAccount: "Create account", + loginTitle: "Start using your dashboard", + loginDescription: "To begin, enter your mobile number.", + loginCta: "Login", + continue: "Continue", + createAccount: "Create account", haveNoAccount: "Need an account?", haveAccount: "Already have an account?", loginOtpTitle: "Verify your login code", passwordLoginTitle: "Login with password", passwordLoginDescription: (mobile: string) => `Enter the password for ${mobile}`, usePasswordInstead: "Use password instead", - useOtpInstead: "Use OTP instead", - backToMobile: "Back to mobile step", - backToPasswordLogin: "Back to password login", - forgotPassword: "Forgot password?", + useOtpInstead: "Use OTP instead", + backToMobile: "Back to mobile step", + backToPasswordLogin: "Back to password login", + backToSignupDetails: "Back to account details", + forgotPassword: "Forgot password?", signupTitle: "Create your account", signupDescription: "Start with your mobile number to receive a verification code.", sendSignupCode: "Send verification code", signupVerifyTitle: "Verify your mobile number", continueToPassword: "Continue to password", - signupPasswordTitle: "Set your password", - signupPasswordDescription: "Choose a password for your new account.", - createAccountPasswordCta: "Create account", + signupPasswordTitle: "Set up your account", + signupPasswordDescription: "Choose a password before verifying your mobile number.", + createAccountPasswordCta: "Create account", + continueToVerifyMobile: "Continue to mobile verification", forgotPasswordTitle: "Recover your password", forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.", sendResetCode: "Send reset code", @@ -63,11 +66,14 @@ export const en = { passwordRequirements: "Password must be at least 8 characters and include at least one lowercase letter, one uppercase letter, one digit, and one symbol.", passwordReuse: "New password must be different from your previous password.", + firstNamePlaceholder: "First name", + lastNamePlaceholder: "Last name", enterPassword: "Enter your password", verifyNumber: "Verify your number", enterMobileDesc: "Enter your mobile number to continue", signInDesc: "Sign in using your account password", - sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`, + sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`, + stepLabel: (current: number, total: number) => `Step ${current} of ${total}`, mobilePlaceholder: "Mobile Number (e.g. 09123456789)", continueWithPassword: "Continue with Password", continueWithGoogle: "Continue with Google", @@ -96,8 +102,9 @@ export const en = { failedSignup: "Failed to complete sign up", invalidCreds: "Invalid credentials", enterOtp: "Please enter the OTP code", - invalidOtp: "Invalid OTP code", - passwordResetSuccess: "Password reset successfully.", + invalidOtp: "Invalid OTP code", + resolveMobileFailed: "We could not check this mobile number. Please try again.", + passwordResetSuccess: "Password reset successfully.", passwordResetFailed: "Failed to reset password.", }, throttle: { diff --git a/src/locales/fa.ts b/src/locales/fa.ts index c701f3a..54c7120 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -26,28 +26,31 @@ export const fa = { }, login: { - loginTitle: "ورود به حساب", - loginDescription: "شماره موبایل خود را وارد کنید تا با رمز عبور وارد شوید.", - loginCta: "ورود", - createAccount: "ایجاد حساب", + loginTitle: "شروع کار با پنل کاربری", + loginDescription: "جهت شروع، شماره موبایل خود را وارد کنید", + loginCta: "ورود", + continue: "ادامه", + createAccount: "ایجاد حساب", haveNoAccount: "حساب ندارید؟", haveAccount: "قبلا حساب دارید؟", loginOtpTitle: "کد ورود را تایید کنید", passwordLoginTitle: "ورود با رمز عبور", passwordLoginDescription: (mobile: string) => `رمز عبور مربوط به ${mobile} را وارد کنید`, usePasswordInstead: "استفاده از رمز عبور", - useOtpInstead: "استفاده از کد یکبار مصرف", - backToMobile: "بازگشت به مرحله موبایل", - backToPasswordLogin: "بازگشت به ورود با رمز عبور", - forgotPassword: "رمز عبور را فراموش کرده‌اید؟", + useOtpInstead: "استفاده از کد یکبار مصرف", + backToMobile: "بازگشت به مرحله موبایل", + backToPasswordLogin: "بازگشت به ورود با رمز عبور", + backToSignupDetails: "بازگشت به اطلاعات حساب", + forgotPassword: "رمز عبور را فراموش کرده‌اید؟", signupTitle: "ساخت حساب جدید", signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.", sendSignupCode: "ارسال کد تایید", signupVerifyTitle: "تایید شماره موبایل", continueToPassword: "ادامه به مرحله رمز عبور", - signupPasswordTitle: "تعیین رمز عبور", - signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.", - createAccountPasswordCta: "ایجاد حساب", + signupPasswordTitle: "تکمیل اطلاعات حساب", + signupPasswordDescription: "پیش از تایید شماره موبایل، برای حساب خود رمز عبور انتخاب کنید.", + createAccountPasswordCta: "ایجاد حساب", + continueToVerifyMobile: "ادامه برای تایید موبایل", forgotPasswordTitle: "بازیابی رمز عبور", forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.", sendResetCode: "ارسال کد بازیابی", @@ -59,15 +62,18 @@ export const fa = { newPasswordPlaceholder: "رمز عبور جدید", confirmPasswordPlaceholder: "تکرار رمز عبور", passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.", - passwordRequirements: - "رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.", - passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.", + passwordRequirements: + "رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.", + passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.", + firstNamePlaceholder: "نام", + lastNamePlaceholder: "نام خانوادگی", welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`, enterPassword: "رمز عبور خود را وارد کنید", verifyNumber: "تایید شماره موبایل", enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید", - signInDesc: "با استفاده از رمز عبور خود وارد شوید", - sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`, + signInDesc: "با استفاده از رمز عبور خود وارد شوید", + sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`, + stepLabel: (current: number, total: number) => `مرحله ${current} از ${total}`, mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)", continueWithPassword: "ادامه با رمز عبور", continueWithGoogle: "ادامه با گوگل", @@ -94,10 +100,11 @@ export const fa = { successLogin: "با موفقیت وارد شدید.", accountCreated: "حساب با موفقیت ایجاد شد.", failedSignup: "تکمیل ثبت نام انجام نشد.", - invalidCreds: "اطلاعات ورود نامعتبر است.", - enterOtp: "لطفا کد تایید را وارد کنید.", - invalidOtp: "کد تایید نامعتبر است.", - passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", + invalidCreds: "اطلاعات ورود نامعتبر است.", + enterOtp: "لطفا کد تایید را وارد کنید.", + invalidOtp: "کد تایید نامعتبر است.", + resolveMobileFailed: "بررسی شماره موبایل انجام نشد. لطفا دوباره تلاش کنید.", + passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", passwordResetFailed: "تغییر رمز عبور انجام نشد." }, throttle: { diff --git a/src/pages/GoogleAuthCallback.tsx b/src/pages/GoogleAuthCallback.tsx index 1f5db70..90f0a64 100644 --- a/src/pages/GoogleAuthCallback.tsx +++ b/src/pages/GoogleAuthCallback.tsx @@ -381,7 +381,7 @@ export default function GoogleAuthCallback() { }} maxLength={11} disabled={loading} - className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + className="h-11 text-start" /> diff --git a/src/pages/auth/AuthPanel.tsx b/src/pages/auth/AuthPanel.tsx index e7c8a91..4bb2e26 100644 --- a/src/pages/auth/AuthPanel.tsx +++ b/src/pages/auth/AuthPanel.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react" import { Link } from "react-router-dom" -import { Command, AlertTriangle } from "lucide-react" +import { AlertTriangle, ArrowLeft, ArrowRight, Command } from "lucide-react" import { useTranslation } from "../../hooks/useTranslation" @@ -8,18 +8,55 @@ interface AuthPanelProps { title: string description: string children: ReactNode + backTo?: string + backLabel?: string + step?: { + current: number + total: number + label?: string + } alert?: { title: string description: string } | null } -export function AuthPanel({ title, description, children, alert = null }: AuthPanelProps) { - const { t } = useTranslation() +export function AuthPanel({ title, description, children, backTo, backLabel, step, alert = null }: AuthPanelProps) { + const { t, lang } = useTranslation() + const BackIcon = lang === "fa" ? ArrowRight : ArrowLeft return (
-
+
+
+ {backTo ? ( + + + + ) : ( + + )} + + {step ? ( +
+ {Array.from({ length: step.total }, (_, index) => ( + + ))} +
+ ) : null} +
+
diff --git a/src/pages/auth/AuthPasswordField.tsx b/src/pages/auth/AuthPasswordField.tsx index 742b1b2..63148c9 100644 --- a/src/pages/auth/AuthPasswordField.tsx +++ b/src/pages/auth/AuthPasswordField.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { Eye, EyeOff } from "lucide-react" import { Input } from "../../components/ui/input" +import { useTranslation } from "../../hooks/useTranslation" interface AuthPasswordFieldProps { id: string @@ -19,25 +20,29 @@ export function AuthPasswordField({ disabled = false, }: AuthPasswordFieldProps) { const [showPassword, setShowPassword] = useState(false) + const { lang } = useTranslation() + const isRtl = lang === "fa" return ( -
+
onChange(event.target.value)} - className="h-11 pe-10 text-start" + className={'h-11'} /> diff --git a/src/pages/auth/ForgotPasswordMobilePage.tsx b/src/pages/auth/ForgotPasswordMobilePage.tsx index 88a665c..5ba0447 100644 --- a/src/pages/auth/ForgotPasswordMobilePage.tsx +++ b/src/pages/auth/ForgotPasswordMobilePage.tsx @@ -47,6 +47,8 @@ export function ForgotPasswordMobilePage() {
@@ -58,7 +60,7 @@ export function ForgotPasswordMobilePage() { maxLength={11} value={state.forgotPassword.mobile} onChange={(event) => setMobile("forgotPassword", event.target.value)} - className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + className="h-11 text-start" />
@@ -84,6 +113,7 @@ export function LoginMobilePage() { -
- {t.login.haveNoAccount}{" "} - - {t.login.register} - -
-
+ ) } diff --git a/src/pages/auth/LoginOtpPage.tsx b/src/pages/auth/LoginOtpPage.tsx index e65bac4..fc248ea 100644 --- a/src/pages/auth/LoginOtpPage.tsx +++ b/src/pages/auth/LoginOtpPage.tsx @@ -37,7 +37,7 @@ export function LoginOtpPage() { const [now, setNow] = useState(Date.now()) if (!state.login.mobile) { - return + return } useEffect(() => { @@ -181,6 +181,9 @@ export function LoginOtpPage() {
+ return } const alert = useMemo(() => { @@ -88,6 +88,9 @@ export function LoginPasswordPage() { diff --git a/src/pages/auth/SignupMobilePage.tsx b/src/pages/auth/SignupMobilePage.tsx index f52bf6c..f9804fa 100644 --- a/src/pages/auth/SignupMobilePage.tsx +++ b/src/pages/auth/SignupMobilePage.tsx @@ -58,7 +58,7 @@ export function SignupMobilePage() { maxLength={11} value={state.signup.mobile} onChange={(event) => setMobile("signup", event.target.value)} - className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + className="h-11 text-start" /> - - diff --git a/src/pages/auth/utils.ts b/src/pages/auth/utils.ts index fae9597..4727258 100644 --- a/src/pages/auth/utils.ts +++ b/src/pages/auth/utils.ts @@ -112,3 +112,12 @@ export const completeAuthentication = ({ toast.success(successMessage) navigate(redirectTo, { replace: true }) } + +export const getTextInputDirection = (value: string, fallback: "rtl" | "ltr" = "ltr") => { + const firstStrongCharacter = value.match(/[A-Za-z\u0600-\u06FF]/)?.[0] + if (!firstStrongCharacter) { + return fallback + } + + return /[\u0600-\u06FF]/.test(firstStrongCharacter) ? "rtl" : "ltr" +}