feat(auth): unify mobile-first authentication flow
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-23 02:07:14 +03:30
parent 1aa45beba4
commit 22b500dba5
18 changed files with 334 additions and 123 deletions

View File

@@ -34,7 +34,6 @@ import { AuthFlowProvider } from "./context/AuthFlowContext"
import { LoginMobilePage } from "./pages/auth/LoginMobilePage" import { LoginMobilePage } from "./pages/auth/LoginMobilePage"
import { LoginOtpPage } from "./pages/auth/LoginOtpPage" import { LoginOtpPage } from "./pages/auth/LoginOtpPage"
import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage" import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage"
import { SignupMobilePage } from "./pages/auth/SignupMobilePage"
import { SignupOtpPage } from "./pages/auth/SignupOtpPage" import { SignupOtpPage } from "./pages/auth/SignupOtpPage"
import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage" import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage"
import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage" import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage"
@@ -156,11 +155,11 @@ const router = createBrowserRouter([
{ {
element: <AuthLayout />, element: <AuthLayout />,
children: [ children: [
{ index: true, element: <Navigate to="/auth/login" replace /> }, { index: true, element: <LoginMobilePage /> },
{ path: "login", element: <LoginMobilePage /> }, { path: "login", element: <Navigate to="/auth" replace /> },
{ path: "login/verify", element: <LoginOtpPage /> }, { path: "login/verify", element: <LoginOtpPage /> },
{ path: "login/password", element: <LoginPasswordPage /> }, { path: "login/password", element: <LoginPasswordPage /> },
{ path: "signup", element: <SignupMobilePage /> }, { path: "signup", element: <Navigate to="/auth" replace /> },
{ path: "signup/verify", element: <SignupOtpPage /> }, { path: "signup/verify", element: <SignupOtpPage /> },
{ path: "signup/password", element: <SignupPasswordPage /> }, { path: "signup/password", element: <SignupPasswordPage /> },
{ path: "forgot-password", element: <ForgotPasswordMobilePage /> }, { path: "forgot-password", element: <ForgotPasswordMobilePage /> },

View File

@@ -7,6 +7,18 @@ const normalizeDigits = (value: string) =>
// --- Auth Endpoints --- // --- 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) => { export const loginWithPassword = async (mobile: string, password: string) => {
const normalizedMobile = normalizeDigits(mobile) const normalizedMobile = normalizeDigits(mobile)
const response = await authFetch('/api/users/login/', { const response = await authFetch('/api/users/login/', {

View File

@@ -30,10 +30,20 @@ interface AuthFlowState {
cooldowns: CooldownState cooldowns: CooldownState
} }
interface SignupDetailsState {
password: string
confirmation: string
firstName: string
lastName: string
}
interface AuthFlowContextValue { interface AuthFlowContextValue {
state: AuthFlowState state: AuthFlowState
signupDetails: SignupDetailsState
setMobile: (flow: FlowName, mobile: string) => void setMobile: (flow: FlowName, mobile: string) => void
setCode: (flow: FlowName, code: string) => void setCode: (flow: FlowName, code: string) => void
setSignupDetails: (details: SignupDetailsState) => void
clearSignupDetails: () => void
markOtpSendPending: (flow: FlowName) => void markOtpSendPending: (flow: FlowName) => void
setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void setOtpDelivery: (flow: FlowName, expiresInSeconds: number) => void
clearOtpDelivery: (flow: FlowName) => void clearOtpDelivery: (flow: FlowName) => void
@@ -72,6 +82,13 @@ const defaultState: AuthFlowState = {
}, },
} }
const defaultSignupDetails: SignupDetailsState = {
password: "",
confirmation: "",
firstName: "",
lastName: "",
}
const AuthFlowContext = createContext<AuthFlowContextValue | null>(null) const AuthFlowContext = createContext<AuthFlowContextValue | null>(null)
const parseStoredState = (): AuthFlowState => { const parseStoredState = (): AuthFlowState => {
@@ -121,6 +138,7 @@ const parseStoredState = (): AuthFlowState => {
export function AuthFlowProvider({ children }: { children: ReactNode }) { export function AuthFlowProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthFlowState>(parseStoredState) const [state, setState] = useState<AuthFlowState>(parseStoredState)
const [signupDetails, setSignupDetailsState] = useState<SignupDetailsState>(defaultSignupDetails)
useEffect(() => { useEffect(() => {
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state)) window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
@@ -150,6 +168,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
const value = useMemo<AuthFlowContextValue>( const value = useMemo<AuthFlowContextValue>(
() => ({ () => ({
state, state,
signupDetails,
setMobile: (flow, mobile) => { setMobile: (flow, mobile) => {
setState((current) => ({ setState((current) => ({
...current, ...current,
@@ -168,6 +187,12 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
}, },
})) }))
}, },
setSignupDetails: (details) => {
setSignupDetailsState(details)
},
clearSignupDetails: () => {
setSignupDetailsState(defaultSignupDetails)
},
markOtpSendPending: (flow) => { markOtpSendPending: (flow) => {
setState((current) => ({ setState((current) => ({
...current, ...current,
@@ -226,9 +251,12 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
pendingOtpSend: false, pendingOtpSend: false,
}, },
})) }))
if (flow === "signup") {
setSignupDetailsState(defaultSignupDetails)
}
}, },
}), }),
[state], [signupDetails, state],
) )
return <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider> return <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider>

View File

@@ -27,9 +27,10 @@ export const en = {
login: { login: {
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`, welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
loginTitle: "Sign in to your account", loginTitle: "Start using your dashboard",
loginDescription: "Enter your mobile number to continue with password login.", loginDescription: "To begin, enter your mobile number.",
loginCta: "Login", loginCta: "Login",
continue: "Continue",
createAccount: "Create account", createAccount: "Create account",
haveNoAccount: "Need an account?", haveNoAccount: "Need an account?",
haveAccount: "Already have an account?", haveAccount: "Already have an account?",
@@ -40,15 +41,17 @@ export const en = {
useOtpInstead: "Use OTP instead", useOtpInstead: "Use OTP instead",
backToMobile: "Back to mobile step", backToMobile: "Back to mobile step",
backToPasswordLogin: "Back to password login", backToPasswordLogin: "Back to password login",
backToSignupDetails: "Back to account details",
forgotPassword: "Forgot password?", forgotPassword: "Forgot password?",
signupTitle: "Create your account", signupTitle: "Create your account",
signupDescription: "Start with your mobile number to receive a verification code.", signupDescription: "Start with your mobile number to receive a verification code.",
sendSignupCode: "Send verification code", sendSignupCode: "Send verification code",
signupVerifyTitle: "Verify your mobile number", signupVerifyTitle: "Verify your mobile number",
continueToPassword: "Continue to password", continueToPassword: "Continue to password",
signupPasswordTitle: "Set your password", signupPasswordTitle: "Set up your account",
signupPasswordDescription: "Choose a password for your new account.", signupPasswordDescription: "Choose a password before verifying your mobile number.",
createAccountPasswordCta: "Create account", createAccountPasswordCta: "Create account",
continueToVerifyMobile: "Continue to mobile verification",
forgotPasswordTitle: "Recover your password", forgotPasswordTitle: "Recover your password",
forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.", forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.",
sendResetCode: "Send reset code", sendResetCode: "Send reset code",
@@ -63,11 +66,14 @@ export const en = {
passwordRequirements: passwordRequirements:
"Password must be at least 8 characters and include at least one lowercase letter, one uppercase letter, one digit, and one symbol.", "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.", passwordReuse: "New password must be different from your previous password.",
firstNamePlaceholder: "First name",
lastNamePlaceholder: "Last name",
enterPassword: "Enter your password", enterPassword: "Enter your password",
verifyNumber: "Verify your number", verifyNumber: "Verify your number",
enterMobileDesc: "Enter your mobile number to continue", enterMobileDesc: "Enter your mobile number to continue",
signInDesc: "Sign in using your account password", 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)", mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
continueWithPassword: "Continue with Password", continueWithPassword: "Continue with Password",
continueWithGoogle: "Continue with Google", continueWithGoogle: "Continue with Google",
@@ -97,6 +103,7 @@ export const en = {
invalidCreds: "Invalid credentials", invalidCreds: "Invalid credentials",
enterOtp: "Please enter the OTP code", enterOtp: "Please enter the OTP code",
invalidOtp: "Invalid OTP code", invalidOtp: "Invalid OTP code",
resolveMobileFailed: "We could not check this mobile number. Please try again.",
passwordResetSuccess: "Password reset successfully.", passwordResetSuccess: "Password reset successfully.",
passwordResetFailed: "Failed to reset password.", passwordResetFailed: "Failed to reset password.",
}, },

View File

@@ -26,9 +26,10 @@ export const fa = {
}, },
login: { login: {
loginTitle: "ورود به حساب", loginTitle: "شروع کار با پنل کاربری",
loginDescription: "شماره موبایل خود را وارد کنید تا با رمز عبور وارد شوید.", loginDescription: "جهت شروع، شماره موبایل خود را وارد کنید",
loginCta: "ورود", loginCta: "ورود",
continue: "ادامه",
createAccount: "ایجاد حساب", createAccount: "ایجاد حساب",
haveNoAccount: "حساب ندارید؟", haveNoAccount: "حساب ندارید؟",
haveAccount: "قبلا حساب دارید؟", haveAccount: "قبلا حساب دارید؟",
@@ -39,15 +40,17 @@ export const fa = {
useOtpInstead: "استفاده از کد یکبار مصرف", useOtpInstead: "استفاده از کد یکبار مصرف",
backToMobile: "بازگشت به مرحله موبایل", backToMobile: "بازگشت به مرحله موبایل",
backToPasswordLogin: "بازگشت به ورود با رمز عبور", backToPasswordLogin: "بازگشت به ورود با رمز عبور",
backToSignupDetails: "بازگشت به اطلاعات حساب",
forgotPassword: "رمز عبور را فراموش کرده‌اید؟", forgotPassword: "رمز عبور را فراموش کرده‌اید؟",
signupTitle: "ساخت حساب جدید", signupTitle: "ساخت حساب جدید",
signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.", signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.",
sendSignupCode: "ارسال کد تایید", sendSignupCode: "ارسال کد تایید",
signupVerifyTitle: "تایید شماره موبایل", signupVerifyTitle: "تایید شماره موبایل",
continueToPassword: "ادامه به مرحله رمز عبور", continueToPassword: "ادامه به مرحله رمز عبور",
signupPasswordTitle: عیین رمز عبور", signupPasswordTitle: کمیل اطلاعات حساب",
signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.", signupPasswordDescription: "پیش از تایید شماره موبایل، برای حساب خود رمز عبور انتخاب کنید.",
createAccountPasswordCta: "ایجاد حساب", createAccountPasswordCta: "ایجاد حساب",
continueToVerifyMobile: "ادامه برای تایید موبایل",
forgotPasswordTitle: "بازیابی رمز عبور", forgotPasswordTitle: "بازیابی رمز عبور",
forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.", forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.",
sendResetCode: "ارسال کد بازیابی", sendResetCode: "ارسال کد بازیابی",
@@ -62,12 +65,15 @@ export const fa = {
passwordRequirements: passwordRequirements:
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.", "رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.", passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
firstNamePlaceholder: "نام",
lastNamePlaceholder: "نام خانوادگی",
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`, welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
enterPassword: "رمز عبور خود را وارد کنید", enterPassword: "رمز عبور خود را وارد کنید",
verifyNumber: "تایید شماره موبایل", verifyNumber: "تایید شماره موبایل",
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید", enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
signInDesc: "با استفاده از رمز عبور خود وارد شوید", signInDesc: "با استفاده از رمز عبور خود وارد شوید",
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`, sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
stepLabel: (current: number, total: number) => `مرحله ${current} از ${total}`,
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)", mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
continueWithPassword: "ادامه با رمز عبور", continueWithPassword: "ادامه با رمز عبور",
continueWithGoogle: "ادامه با گوگل", continueWithGoogle: "ادامه با گوگل",
@@ -97,6 +103,7 @@ export const fa = {
invalidCreds: "اطلاعات ورود نامعتبر است.", invalidCreds: "اطلاعات ورود نامعتبر است.",
enterOtp: "لطفا کد تایید را وارد کنید.", enterOtp: "لطفا کد تایید را وارد کنید.",
invalidOtp: "کد تایید نامعتبر است.", invalidOtp: "کد تایید نامعتبر است.",
resolveMobileFailed: "بررسی شماره موبایل انجام نشد. لطفا دوباره تلاش کنید.",
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.", passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
passwordResetFailed: "تغییر رمز عبور انجام نشد." passwordResetFailed: "تغییر رمز عبور انجام نشد."
}, },

View File

@@ -381,7 +381,7 @@ export default function GoogleAuthCallback() {
}} }}
maxLength={11} maxLength={11}
disabled={loading} disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className="h-11 text-start"
/> />
</div> </div>

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { Link } from "react-router-dom" 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" import { useTranslation } from "../../hooks/useTranslation"
@@ -8,18 +8,55 @@ interface AuthPanelProps {
title: string title: string
description: string description: string
children: ReactNode children: ReactNode
backTo?: string
backLabel?: string
step?: {
current: number
total: number
label?: string
}
alert?: { alert?: {
title: string title: string
description: string description: string
} | null } | null
} }
export function AuthPanel({ title, description, children, alert = null }: AuthPanelProps) { export function AuthPanel({ title, description, children, backTo, backLabel, step, alert = null }: AuthPanelProps) {
const { t } = useTranslation() const { t, lang } = useTranslation()
const BackIcon = lang === "fa" ? ArrowRight : ArrowLeft
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50"> <div className="flex flex-col space-y-3 text-center text-slate-900 dark:text-slate-50">
<div className="flex min-h-6 items-center justify-between">
{backTo ? (
<Link
to={backTo}
aria-label={backLabel || t.login.back}
className="inline-flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-900 dark:hover:text-slate-200"
>
<BackIcon className="h-4 w-4" />
</Link>
) : (
<span className="h-8 w-8" />
)}
{step ? (
<div className="flex items-center gap-1.5" aria-label={step.label}>
{Array.from({ length: step.total }, (_, index) => (
<span
key={index}
className={`h-1.5 rounded-full transition-all ${
index + 1 <= step.current
? "w-5 bg-blue-600 dark:bg-blue-400"
: "w-1.5 bg-slate-200 dark:bg-slate-800"
}`}
/>
))}
</div>
) : null}
</div>
<div className="mb-4 flex justify-center lg:hidden"> <div className="mb-4 flex justify-center lg:hidden">
<Command className="h-8 w-8" /> <Command className="h-8 w-8" />
</div> </div>

View File

@@ -2,6 +2,7 @@ import { useState } from "react"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { useTranslation } from "../../hooks/useTranslation"
interface AuthPasswordFieldProps { interface AuthPasswordFieldProps {
id: string id: string
@@ -19,25 +20,29 @@ export function AuthPasswordField({
disabled = false, disabled = false,
}: AuthPasswordFieldProps) { }: AuthPasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const { lang } = useTranslation()
const isRtl = lang === "fa"
return ( return (
<div className="relative w-full" dir="ltr"> <div className="relative w-full" dir={isRtl ? "rtl" : "ltr"}>
<Input <Input
id={id} id={id}
value={value} value={value}
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder={placeholder} placeholder={placeholder}
autoComplete="new-password" autoComplete="new-password"
dir="ltr" dir={isRtl ? "rtl" : "ltr"}
disabled={disabled} disabled={disabled}
onChange={(event) => onChange(event.target.value)} onChange={(event) => onChange(event.target.value)}
className="h-11 pe-10 text-start" className={'h-11'}
/> />
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
onClick={() => setShowPassword((current) => !current)} onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 end-0 flex items-center pe-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300" className={`absolute inset-y-0 flex items-center text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300 ${
isRtl ? "left-0 pl-3" : "right-0 pr-3"
}`}
> >
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />} {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button> </button>

View File

@@ -47,6 +47,8 @@ export function ForgotPasswordMobilePage() {
<AuthPanel <AuthPanel
title={t.login.forgotPasswordTitle} title={t.login.forgotPasswordTitle}
description={t.login.forgotPasswordDescription} description={t.login.forgotPasswordDescription}
backTo="/auth/login/password"
backLabel={t.login.backToPasswordLogin}
alert={alert} alert={alert}
> >
<div className="grid gap-4"> <div className="grid gap-4">
@@ -58,7 +60,7 @@ export function ForgotPasswordMobilePage() {
maxLength={11} maxLength={11}
value={state.forgotPassword.mobile} value={state.forgotPassword.mobile}
onChange={(event) => setMobile("forgotPassword", event.target.value)} onChange={(event) => setMobile("forgotPassword", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className="h-11 text-start"
/> />
<Button <Button

View File

@@ -120,6 +120,8 @@ export function ForgotPasswordOtpPage() {
<AuthPanel <AuthPanel
title={t.login.forgotPasswordVerifyTitle} title={t.login.forgotPasswordVerifyTitle}
description={t.login.sentCodeDesc(state.forgotPassword.mobile)} description={t.login.sentCodeDesc(state.forgotPassword.mobile)}
backTo="/auth/forgot-password"
backLabel={t.login.back}
alert={alert} alert={alert}
> >
<form <form

View File

@@ -64,6 +64,8 @@ export function ForgotPasswordPasswordPage() {
<AuthPanel <AuthPanel
title={t.login.resetPasswordTitle} title={t.login.resetPasswordTitle}
description={t.login.resetPasswordDescription} description={t.login.resetPasswordDescription}
backTo="/auth/forgot-password/verify"
backLabel={t.login.back}
> >
<form onSubmit={handleSubmit} className="grid gap-4"> <form onSubmit={handleSubmit} className="grid gap-4">
<AuthPasswordField <AuthPasswordField

View File

@@ -1,12 +1,15 @@
import { Link, useNavigate } from "react-router-dom" import { Loader2 } from "lucide-react"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { startGoogleLogin } from "../../api/users" import { resolveAuthMobile, startGoogleLogin } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input" import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { getApiErrorMessage } from "./utils"
const GoogleIcon = () => ( const GoogleIcon = () => (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24"> <svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
@@ -31,27 +34,51 @@ const GoogleIcon = () => (
export function LoginMobilePage() { export function LoginMobilePage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t } = useTranslation()
const { state, setMobile, clearOtpDelivery, resetFlow } = useAuthFlow() const { state, setMobile, clearOtpDelivery, clearSignupDetails, resetFlow } = useAuthFlow()
const isRtl = lang === "fa" const [loading, setLoading] = useState(false)
const handleLogin = async () => { const handleContinue = async () => {
if (!state.login.mobile) { if (!state.login.mobile) {
toast.error(t.login.toasts.enterMobile) toast.error(t.login.toasts.enterMobile)
return return
} }
resetFlow("forgotPassword") setLoading(true)
clearOtpDelivery("login") try {
navigate("/auth/login/password") const result = await resolveAuthMobile(state.login.mobile)
resetFlow("forgotPassword")
if (result.status === "existing_user") {
clearOtpDelivery("login")
navigate("/auth/login/password")
return
}
resetFlow("signup")
clearSignupDetails()
setMobile("signup", state.login.mobile)
navigate("/auth/signup/password")
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.resolveMobileFailed))
} finally {
setLoading(false)
}
} }
return ( return (
<AuthPanel <AuthPanel
title={t.login.loginTitle} title={t.login.loginTitle}
description={t.login.loginDescription} description={t.login.loginDescription}
step={{ current: 1, total: 3, label: t.login.stepLabel(1, 3) }}
> >
<div className="grid gap-4"> <form
onSubmit={(event) => {
event.preventDefault()
void handleContinue()
}}
className="grid gap-4"
>
<Input <Input
id="login-mobile" id="login-mobile"
placeholder={t.login.mobilePlaceholder} placeholder={t.login.mobilePlaceholder}
@@ -60,14 +87,16 @@ export function LoginMobilePage() {
maxLength={11} maxLength={11}
value={state.login.mobile} value={state.login.mobile}
onChange={(event) => setMobile("login", event.target.value)} onChange={(event) => setMobile("login", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className="h-11 text-start"
/> />
<Button <Button
onClick={handleLogin} type="submit"
disabled={loading}
className="h-11 w-full" className="h-11 w-full"
> >
{t.login.continueWithPassword} {loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.continue}
</Button> </Button>
<div className="relative"> <div className="relative">
@@ -84,6 +113,7 @@ export function LoginMobilePage() {
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={loading}
onClick={startGoogleLogin} onClick={startGoogleLogin}
className="h-11 w-full" className="h-11 w-full"
> >
@@ -91,16 +121,7 @@ export function LoginMobilePage() {
<span className="ms-3">{t.login.continueWithGoogle}</span> <span className="ms-3">{t.login.continueWithGoogle}</span>
</Button> </Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400"> </form>
{t.login.haveNoAccount}{" "}
<Link
to="/auth/signup"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.register}
</Link>
</div>
</div>
</AuthPanel> </AuthPanel>
) )
} }

View File

@@ -37,7 +37,7 @@ export function LoginOtpPage() {
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
if (!state.login.mobile) { if (!state.login.mobile) {
return <Navigate to="/auth/login" replace /> return <Navigate to="/auth" replace />
} }
useEffect(() => { useEffect(() => {
@@ -181,6 +181,9 @@ export function LoginOtpPage() {
<AuthPanel <AuthPanel
title={t.login.loginOtpTitle} title={t.login.loginOtpTitle}
description={t.login.sentCodeDesc(state.login.mobile)} description={t.login.sentCodeDesc(state.login.mobile)}
backTo="/auth/login/password"
backLabel={t.login.backToPasswordLogin}
step={{ current: 2, total: 2, label: t.login.stepLabel(2, 2) }}
alert={throttleAlert} alert={throttleAlert}
> >
<form <form

View File

@@ -29,7 +29,7 @@ export function LoginPasswordPage() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
if (!state.login.mobile) { if (!state.login.mobile) {
return <Navigate to="/auth/login" replace /> return <Navigate to="/auth" replace />
} }
const alert = useMemo(() => { const alert = useMemo(() => {
@@ -88,6 +88,9 @@ export function LoginPasswordPage() {
<AuthPanel <AuthPanel
title={t.login.passwordLoginTitle} title={t.login.passwordLoginTitle}
description={t.login.passwordLoginDescription(state.login.mobile)} description={t.login.passwordLoginDescription(state.login.mobile)}
backTo="/auth"
backLabel={t.login.backToMobile}
step={{ current: 2, total: 2, label: t.login.stepLabel(2, 2) }}
alert={alert} alert={alert}
> >
<form onSubmit={handleSubmit} autoComplete="off" className="grid gap-4"> <form onSubmit={handleSubmit} autoComplete="off" className="grid gap-4">

View File

@@ -58,7 +58,7 @@ export function SignupMobilePage() {
maxLength={11} maxLength={11}
value={state.signup.mobile} value={state.signup.mobile}
onChange={(event) => setMobile("signup", event.target.value)} onChange={(event) => setMobile("signup", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`} className="h-11 text-start"
/> />
<Button <Button

View File

@@ -3,24 +3,33 @@ import { useEffect, useMemo, useRef, useState } from "react"
import { Navigate, useNavigate } from "react-router-dom" import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { sendOtp } from "../../api/users" import { registerWithOtp, sendOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthOtpInput } from "./AuthOtpInput" import { AuthOtpInput } from "./AuthOtpInput"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils" import {
completeAuthentication,
formatCooldown,
getApiErrorMessage,
getOtpRemainingSeconds,
handleThrottleError,
} from "./utils"
export function SignupOtpPage() { export function SignupOtpPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t, lang } = useTranslation() const { t, lang } = useTranslation()
const { const {
state, state,
signupDetails,
setCode, setCode,
setCooldown, setCooldown,
clearCooldown, clearCooldown,
setOtpDelivery, setOtpDelivery,
clearOtpDelivery, clearOtpDelivery,
clearSignupDetails,
resetFlow,
} = useAuthFlow() } = useAuthFlow()
const isRtl = lang === "fa" const isRtl = lang === "fa"
const autoSendStartedRef = useRef(false) const autoSendStartedRef = useRef(false)
@@ -29,7 +38,11 @@ export function SignupOtpPage() {
const [now, setNow] = useState(Date.now()) const [now, setNow] = useState(Date.now())
if (!state.signup.mobile) { if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace /> return <Navigate to="/auth" replace />
}
if (!signupDetails.password || !signupDetails.confirmation) {
return <Navigate to="/auth/signup/password" replace />
} }
useEffect(() => { useEffect(() => {
@@ -103,7 +116,7 @@ export function SignupOtpPage() {
? t.login.otpExpired ? t.login.otpExpired
: null : null
const continueToPassword = async (code: string) => { const submitRegistration = async (code: string) => {
if (code.length !== 5) { if (code.length !== 5) {
toast.error(t.login.toasts.enterOtp) toast.error(t.login.toasts.enterOtp)
return return
@@ -111,7 +124,30 @@ export function SignupOtpPage() {
setIsContinuing(true) setIsContinuing(true)
setCode("signup", code) setCode("signup", code)
navigate("/auth/signup/password") try {
const data = await registerWithOtp(
state.signup.mobile,
code,
signupDetails.password,
signupDetails.confirmation,
signupDetails.firstName,
signupDetails.lastName,
)
clearCooldown("signupOtpSend")
clearSignupDetails()
resetFlow("signup")
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.accountCreated,
redirectTo: "/timesheet",
navigate,
})
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup))
} finally {
setIsContinuing(false)
}
} }
const isBusy = isSendingOtp || isContinuing const isBusy = isSendingOtp || isContinuing
@@ -120,12 +156,15 @@ export function SignupOtpPage() {
<AuthPanel <AuthPanel
title={t.login.signupVerifyTitle} title={t.login.signupVerifyTitle}
description={t.login.sentCodeDesc(state.signup.mobile)} description={t.login.sentCodeDesc(state.signup.mobile)}
backTo="/auth/signup/password"
backLabel={t.login.backToSignupDetails}
step={{ current: 3, total: 3, label: t.login.stepLabel(3, 3) }}
alert={alert} alert={alert}
> >
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault() event.preventDefault()
void continueToPassword(state.signup.code) void submitRegistration(state.signup.code)
}} }}
className="grid gap-4" className="grid gap-4"
> >
@@ -134,7 +173,7 @@ export function SignupOtpPage() {
value={state.signup.code} value={state.signup.code}
disabled={isBusy} disabled={isBusy}
onChange={(value) => setCode("signup", value)} onChange={(value) => setCode("signup", value)}
onComplete={(value) => void continueToPassword(value)} onComplete={(value) => void submitRegistration(value)}
/> />
{expiryMessage ? ( {expiryMessage ? (
@@ -143,7 +182,7 @@ export function SignupOtpPage() {
<Button type="submit" className="h-11 w-full" disabled={isBusy}> <Button type="submit" className="h-11 w-full" disabled={isBusy}>
{(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {(isSendingOtp || isContinuing) && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{isSendingOtp ? t.login.sendingOtp : t.login.continueToPassword} {isSendingOtp ? t.login.sendingOtp : isContinuing ? t.login.verifyingOtp : t.login.createAccountPasswordCta}
</Button> </Button>
<Button <Button

View File

@@ -1,36 +1,60 @@
import { Loader2 } from "lucide-react" import { useMemo, useState } from "react"
import { useState } from "react"
import { Navigate, useNavigate } from "react-router-dom" import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { registerWithOtp } from "../../api/users"
import { Button } from "../../components/ui/button" import { Button } from "../../components/ui/button"
import { Input } from "../../components/ui/input"
import { useAuthFlow } from "../../context/AuthFlowContext" import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation" import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel" import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField" import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, getApiErrorMessage, getPasswordValidationMessage } from "./utils" import { formatCooldown, getPasswordValidationMessage, getTextInputDirection } from "./utils"
export function SignupPasswordPage() { export function SignupPasswordPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t, lang } = useTranslation()
const { state, resetFlow } = useAuthFlow() const {
const [password, setPassword] = useState("") state,
const [confirmation, setConfirmation] = useState("") signupDetails,
const [loading, setLoading] = useState(false) setSignupDetails,
setCode,
clearOtpDelivery,
markOtpSendPending,
} = useAuthFlow()
const isRtl = lang === "fa"
const [firstName, setFirstName] = useState(signupDetails.firstName)
const [lastName, setLastName] = useState(signupDetails.lastName)
const [password, setPassword] = useState(signupDetails.password)
const [confirmation, setConfirmation] = useState(signupDetails.confirmation)
const textFallbackDirection = isRtl ? "rtl" : "ltr"
const firstNameDirection = getTextInputDirection(firstName, textFallbackDirection)
const lastNameDirection = getTextInputDirection(lastName, textFallbackDirection)
if (!state.signup.mobile) { if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace /> return <Navigate to="/auth" replace />
} }
if (!state.signup.code) { const alert = useMemo(() => {
return <Navigate to="/auth/signup/verify" replace /> if (state.cooldowns.signupOtpSend <= 0) {
} return null
}
const handleSubmit = async (event: React.FormEvent) => { const formatted = formatCooldown(state.cooldowns.signupOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.signupOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.signupOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.signupOtpSend, isRtl))
: null
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (!password || !confirmation) { if (!firstName.trim() || !lastName.trim() || !password || !confirmation) {
toast.error(t.login.toasts.fillAll) toast.error(t.login.toasts.fillAll)
return return
} }
@@ -46,52 +70,63 @@ export function SignupPasswordPage() {
return return
} }
setLoading(true) setSignupDetails({
try { password,
const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation) confirmation,
resetFlow("signup") firstName: firstName.trim(),
completeAuthentication({ lastName: lastName.trim(),
access: data.access, })
refresh: data.refresh, setCode("signup", "")
successMessage: t.login.toasts.accountCreated, clearOtpDelivery("signup")
redirectTo: "/timesheet", markOtpSendPending("signup")
navigate, navigate("/auth/signup/verify")
})
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup))
} finally {
setLoading(false)
}
} }
return ( return (
<AuthPanel <AuthPanel
title={t.login.signupPasswordTitle} title={t.login.signupPasswordTitle}
description={t.login.signupPasswordDescription} description={t.login.signupPasswordDescription}
backTo="/auth"
backLabel={t.login.backToMobile}
step={{ current: 2, total: 3, label: t.login.stepLabel(2, 3) }}
alert={alert}
> >
<form onSubmit={handleSubmit} className="grid gap-4"> <form onSubmit={handleSubmit} className="grid gap-4">
<div className="grid gap-3 sm:grid-cols-2">
<Input
id="signup-first-name"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
placeholder={t.login.firstNamePlaceholder}
dir={firstNameDirection}
required
className={'h-11'}
/>
<Input
id="signup-last-name"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
placeholder={t.login.lastNamePlaceholder}
dir={lastNameDirection}
required
className={'h-11'}
/>
</div>
<AuthPasswordField <AuthPasswordField
id="signup-password" id="signup-password"
value={password} value={password}
onChange={setPassword} onChange={setPassword}
placeholder={t.login.newPasswordPlaceholder} placeholder={t.login.newPasswordPlaceholder}
disabled={loading}
/> />
<AuthPasswordField <AuthPasswordField
id="signup-password-confirmation" id="signup-password-confirmation"
value={confirmation} value={confirmation}
onChange={setConfirmation} onChange={setConfirmation}
placeholder={t.login.confirmPasswordPlaceholder} placeholder={t.login.confirmPasswordPlaceholder}
disabled={loading}
/> />
<Button type="submit" className="h-11 w-full" disabled={loading}> <Button type="submit" className="h-11 w-full" disabled={state.cooldowns.signupOtpSend > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />} {cooldownLabel || t.login.continueToVerifyMobile}
{t.login.createAccountPasswordCta}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/signup/verify")}>
{t.login.back}
</Button> </Button>
</form> </form>
</AuthPanel> </AuthPanel>

View File

@@ -112,3 +112,12 @@ export const completeAuthentication = ({
toast.success(successMessage) toast.success(successMessage)
navigate(redirectTo, { replace: true }) 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"
}