feat(auth): unify mobile-first authentication flow
This commit is contained in:
@@ -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: <AuthLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/auth/login" replace /> },
|
||||
{ path: "login", element: <LoginMobilePage /> },
|
||||
{ index: true, element: <LoginMobilePage /> },
|
||||
{ path: "login", element: <Navigate to="/auth" replace /> },
|
||||
{ path: "login/verify", element: <LoginOtpPage /> },
|
||||
{ path: "login/password", element: <LoginPasswordPage /> },
|
||||
{ path: "signup", element: <SignupMobilePage /> },
|
||||
{ path: "signup", element: <Navigate to="/auth" replace /> },
|
||||
{ path: "signup/verify", element: <SignupOtpPage /> },
|
||||
{ path: "signup/password", element: <SignupPasswordPage /> },
|
||||
{ path: "forgot-password", element: <ForgotPasswordMobilePage /> },
|
||||
|
||||
@@ -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/', {
|
||||
|
||||
@@ -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<AuthFlowContextValue | null>(null)
|
||||
|
||||
const parseStoredState = (): AuthFlowState => {
|
||||
@@ -121,6 +138,7 @@ const parseStoredState = (): AuthFlowState => {
|
||||
|
||||
export function AuthFlowProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthFlowState>(parseStoredState)
|
||||
const [signupDetails, setSignupDetailsState] = useState<SignupDetailsState>(defaultSignupDetails)
|
||||
|
||||
useEffect(() => {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
@@ -150,6 +168,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }) {
|
||||
const value = useMemo<AuthFlowContextValue>(
|
||||
() => ({
|
||||
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 <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider>
|
||||
|
||||
@@ -27,9 +27,10 @@ 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.",
|
||||
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?",
|
||||
@@ -40,15 +41,17 @@ export const en = {
|
||||
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.",
|
||||
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}`,
|
||||
stepLabel: (current: number, total: number) => `Step ${current} of ${total}`,
|
||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||
continueWithPassword: "Continue with Password",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
@@ -97,6 +103,7 @@ export const en = {
|
||||
invalidCreds: "Invalid credentials",
|
||||
enterOtp: "Please enter the OTP code",
|
||||
invalidOtp: "Invalid OTP code",
|
||||
resolveMobileFailed: "We could not check this mobile number. Please try again.",
|
||||
passwordResetSuccess: "Password reset successfully.",
|
||||
passwordResetFailed: "Failed to reset password.",
|
||||
},
|
||||
|
||||
@@ -26,9 +26,10 @@ export const fa = {
|
||||
},
|
||||
|
||||
login: {
|
||||
loginTitle: "ورود به حساب",
|
||||
loginDescription: "شماره موبایل خود را وارد کنید تا با رمز عبور وارد شوید.",
|
||||
loginTitle: "شروع کار با پنل کاربری",
|
||||
loginDescription: "جهت شروع، شماره موبایل خود را وارد کنید",
|
||||
loginCta: "ورود",
|
||||
continue: "ادامه",
|
||||
createAccount: "ایجاد حساب",
|
||||
haveNoAccount: "حساب ندارید؟",
|
||||
haveAccount: "قبلا حساب دارید؟",
|
||||
@@ -39,15 +40,17 @@ export const fa = {
|
||||
useOtpInstead: "استفاده از کد یکبار مصرف",
|
||||
backToMobile: "بازگشت به مرحله موبایل",
|
||||
backToPasswordLogin: "بازگشت به ورود با رمز عبور",
|
||||
backToSignupDetails: "بازگشت به اطلاعات حساب",
|
||||
forgotPassword: "رمز عبور را فراموش کردهاید؟",
|
||||
signupTitle: "ساخت حساب جدید",
|
||||
signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.",
|
||||
sendSignupCode: "ارسال کد تایید",
|
||||
signupVerifyTitle: "تایید شماره موبایل",
|
||||
continueToPassword: "ادامه به مرحله رمز عبور",
|
||||
signupPasswordTitle: "تعیین رمز عبور",
|
||||
signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.",
|
||||
signupPasswordTitle: "تکمیل اطلاعات حساب",
|
||||
signupPasswordDescription: "پیش از تایید شماره موبایل، برای حساب خود رمز عبور انتخاب کنید.",
|
||||
createAccountPasswordCta: "ایجاد حساب",
|
||||
continueToVerifyMobile: "ادامه برای تایید موبایل",
|
||||
forgotPasswordTitle: "بازیابی رمز عبور",
|
||||
forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.",
|
||||
sendResetCode: "ارسال کد بازیابی",
|
||||
@@ -62,12 +65,15 @@ export const fa = {
|
||||
passwordRequirements:
|
||||
"رمز عبور باید حداقل 8 کاراکتر باشد و حداقل یک حرف کوچک، یک حرف بزرگ، یک عدد و یک نماد داشته باشد.",
|
||||
passwordReuse: "رمز عبور جدید نباید با رمز عبور قبلی یکسان باشد.",
|
||||
firstNamePlaceholder: "نام",
|
||||
lastNamePlaceholder: "نام خانوادگی",
|
||||
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
||||
enterPassword: "رمز عبور خود را وارد کنید",
|
||||
verifyNumber: "تایید شماره موبایل",
|
||||
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
||||
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
|
||||
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
|
||||
stepLabel: (current: number, total: number) => `مرحله ${current} از ${total}`,
|
||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||
continueWithPassword: "ادامه با رمز عبور",
|
||||
continueWithGoogle: "ادامه با گوگل",
|
||||
@@ -97,6 +103,7 @@ export const fa = {
|
||||
invalidCreds: "اطلاعات ورود نامعتبر است.",
|
||||
enterOtp: "لطفا کد تایید را وارد کنید.",
|
||||
invalidOtp: "کد تایید نامعتبر است.",
|
||||
resolveMobileFailed: "بررسی شماره موبایل انجام نشد. لطفا دوباره تلاش کنید.",
|
||||
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
|
||||
passwordResetFailed: "تغییر رمز عبور انجام نشد."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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">
|
||||
<Command className="h-8 w-8" />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative w-full" dir="ltr">
|
||||
<div className="relative w-full" dir={isRtl ? "rtl" : "ltr"}>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
dir="ltr"
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-11 pe-10 text-start"
|
||||
className={'h-11'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
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} />}
|
||||
</button>
|
||||
|
||||
@@ -47,6 +47,8 @@ export function ForgotPasswordMobilePage() {
|
||||
<AuthPanel
|
||||
title={t.login.forgotPasswordTitle}
|
||||
description={t.login.forgotPasswordDescription}
|
||||
backTo="/auth/login/password"
|
||||
backLabel={t.login.backToPasswordLogin}
|
||||
alert={alert}
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -120,6 +120,8 @@ export function ForgotPasswordOtpPage() {
|
||||
<AuthPanel
|
||||
title={t.login.forgotPasswordVerifyTitle}
|
||||
description={t.login.sentCodeDesc(state.forgotPassword.mobile)}
|
||||
backTo="/auth/forgot-password"
|
||||
backLabel={t.login.back}
|
||||
alert={alert}
|
||||
>
|
||||
<form
|
||||
|
||||
@@ -64,6 +64,8 @@ export function ForgotPasswordPasswordPage() {
|
||||
<AuthPanel
|
||||
title={t.login.resetPasswordTitle}
|
||||
description={t.login.resetPasswordDescription}
|
||||
backTo="/auth/forgot-password/verify"
|
||||
backLabel={t.login.back}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||
<AuthPasswordField
|
||||
|
||||
@@ -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 { startGoogleLogin } from "../../api/users"
|
||||
import { resolveAuthMobile, startGoogleLogin } from "../../api/users"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { useAuthFlow } from "../../context/AuthFlowContext"
|
||||
import { useTranslation } from "../../hooks/useTranslation"
|
||||
import { AuthPanel } from "./AuthPanel"
|
||||
import { getApiErrorMessage } from "./utils"
|
||||
|
||||
const GoogleIcon = () => (
|
||||
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
|
||||
@@ -31,27 +34,51 @@ const GoogleIcon = () => (
|
||||
|
||||
export function LoginMobilePage() {
|
||||
const navigate = useNavigate()
|
||||
const { t, lang } = useTranslation()
|
||||
const { state, setMobile, clearOtpDelivery, resetFlow } = useAuthFlow()
|
||||
const isRtl = lang === "fa"
|
||||
const { t } = useTranslation()
|
||||
const { state, setMobile, clearOtpDelivery, clearSignupDetails, resetFlow } = useAuthFlow()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleContinue = async () => {
|
||||
if (!state.login.mobile) {
|
||||
toast.error(t.login.toasts.enterMobile)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
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 (
|
||||
<AuthPanel
|
||||
title={t.login.loginTitle}
|
||||
description={t.login.loginDescription}
|
||||
step={{ current: 1, total: 3, label: t.login.stepLabel(1, 3) }}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleContinue()
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="grid gap-4">
|
||||
<Input
|
||||
id="login-mobile"
|
||||
placeholder={t.login.mobilePlaceholder}
|
||||
@@ -60,14 +87,16 @@ export function LoginMobilePage() {
|
||||
maxLength={11}
|
||||
value={state.login.mobile}
|
||||
onChange={(event) => setMobile("login", event.target.value)}
|
||||
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
|
||||
className="h-11 text-start"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="h-11 w-full"
|
||||
>
|
||||
{t.login.continueWithPassword}
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t.login.continue}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
@@ -84,6 +113,7 @@ export function LoginMobilePage() {
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={startGoogleLogin}
|
||||
className="h-11 w-full"
|
||||
>
|
||||
@@ -91,16 +121,7 @@ export function LoginMobilePage() {
|
||||
<span className="ms-3">{t.login.continueWithGoogle}</span>
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
{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>
|
||||
</form>
|
||||
</AuthPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export function LoginOtpPage() {
|
||||
const [now, setNow] = useState(Date.now())
|
||||
|
||||
if (!state.login.mobile) {
|
||||
return <Navigate to="/auth/login" replace />
|
||||
return <Navigate to="/auth" replace />
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,6 +181,9 @@ export function LoginOtpPage() {
|
||||
<AuthPanel
|
||||
title={t.login.loginOtpTitle}
|
||||
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}
|
||||
>
|
||||
<form
|
||||
|
||||
@@ -29,7 +29,7 @@ export function LoginPasswordPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
if (!state.login.mobile) {
|
||||
return <Navigate to="/auth/login" replace />
|
||||
return <Navigate to="/auth" replace />
|
||||
}
|
||||
|
||||
const alert = useMemo(() => {
|
||||
@@ -88,6 +88,9 @@ export function LoginPasswordPage() {
|
||||
<AuthPanel
|
||||
title={t.login.passwordLoginTitle}
|
||||
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}
|
||||
>
|
||||
<form onSubmit={handleSubmit} autoComplete="off" className="grid gap-4">
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -3,24 +3,33 @@ import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Navigate, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { sendOtp } from "../../api/users"
|
||||
import { registerWithOtp, sendOtp } from "../../api/users"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { useAuthFlow } from "../../context/AuthFlowContext"
|
||||
import { useTranslation } from "../../hooks/useTranslation"
|
||||
import { AuthOtpInput } from "./AuthOtpInput"
|
||||
import { AuthPanel } from "./AuthPanel"
|
||||
import { formatCooldown, getApiErrorMessage, getOtpRemainingSeconds, handleThrottleError } from "./utils"
|
||||
import {
|
||||
completeAuthentication,
|
||||
formatCooldown,
|
||||
getApiErrorMessage,
|
||||
getOtpRemainingSeconds,
|
||||
handleThrottleError,
|
||||
} from "./utils"
|
||||
|
||||
export function SignupOtpPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t, lang } = useTranslation()
|
||||
const {
|
||||
state,
|
||||
signupDetails,
|
||||
setCode,
|
||||
setCooldown,
|
||||
clearCooldown,
|
||||
setOtpDelivery,
|
||||
clearOtpDelivery,
|
||||
clearSignupDetails,
|
||||
resetFlow,
|
||||
} = useAuthFlow()
|
||||
const isRtl = lang === "fa"
|
||||
const autoSendStartedRef = useRef(false)
|
||||
@@ -29,7 +38,11 @@ export function SignupOtpPage() {
|
||||
const [now, setNow] = useState(Date.now())
|
||||
|
||||
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(() => {
|
||||
@@ -103,7 +116,7 @@ export function SignupOtpPage() {
|
||||
? t.login.otpExpired
|
||||
: null
|
||||
|
||||
const continueToPassword = async (code: string) => {
|
||||
const submitRegistration = async (code: string) => {
|
||||
if (code.length !== 5) {
|
||||
toast.error(t.login.toasts.enterOtp)
|
||||
return
|
||||
@@ -111,7 +124,30 @@ export function SignupOtpPage() {
|
||||
|
||||
setIsContinuing(true)
|
||||
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
|
||||
@@ -120,12 +156,15 @@ export function SignupOtpPage() {
|
||||
<AuthPanel
|
||||
title={t.login.signupVerifyTitle}
|
||||
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}
|
||||
>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void continueToPassword(state.signup.code)
|
||||
void submitRegistration(state.signup.code)
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
@@ -134,7 +173,7 @@ export function SignupOtpPage() {
|
||||
value={state.signup.code}
|
||||
disabled={isBusy}
|
||||
onChange={(value) => setCode("signup", value)}
|
||||
onComplete={(value) => void continueToPassword(value)}
|
||||
onComplete={(value) => void submitRegistration(value)}
|
||||
/>
|
||||
|
||||
{expiryMessage ? (
|
||||
@@ -143,7 +182,7 @@ export function SignupOtpPage() {
|
||||
|
||||
<Button type="submit" className="h-11 w-full" disabled={isBusy}>
|
||||
{(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
|
||||
|
||||
@@ -1,36 +1,60 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Navigate, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { registerWithOtp } from "../../api/users"
|
||||
import { Button } from "../../components/ui/button"
|
||||
import { Input } from "../../components/ui/input"
|
||||
import { useAuthFlow } from "../../context/AuthFlowContext"
|
||||
import { useTranslation } from "../../hooks/useTranslation"
|
||||
import { AuthPanel } from "./AuthPanel"
|
||||
import { AuthPasswordField } from "./AuthPasswordField"
|
||||
import { completeAuthentication, getApiErrorMessage, getPasswordValidationMessage } from "./utils"
|
||||
import { formatCooldown, getPasswordValidationMessage, getTextInputDirection } from "./utils"
|
||||
|
||||
export function SignupPasswordPage() {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { state, resetFlow } = useAuthFlow()
|
||||
const [password, setPassword] = useState("")
|
||||
const [confirmation, setConfirmation] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { t, lang } = useTranslation()
|
||||
const {
|
||||
state,
|
||||
signupDetails,
|
||||
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) {
|
||||
return <Navigate to="/auth/signup" replace />
|
||||
return <Navigate to="/auth" replace />
|
||||
}
|
||||
|
||||
if (!state.signup.code) {
|
||||
return <Navigate to="/auth/signup/verify" replace />
|
||||
const alert = useMemo(() => {
|
||||
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()
|
||||
|
||||
if (!password || !confirmation) {
|
||||
if (!firstName.trim() || !lastName.trim() || !password || !confirmation) {
|
||||
toast.error(t.login.toasts.fillAll)
|
||||
return
|
||||
}
|
||||
@@ -46,52 +70,63 @@ export function SignupPasswordPage() {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation)
|
||||
resetFlow("signup")
|
||||
completeAuthentication({
|
||||
access: data.access,
|
||||
refresh: data.refresh,
|
||||
successMessage: t.login.toasts.accountCreated,
|
||||
redirectTo: "/timesheet",
|
||||
navigate,
|
||||
setSignupDetails({
|
||||
password,
|
||||
confirmation,
|
||||
firstName: firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
setCode("signup", "")
|
||||
clearOtpDelivery("signup")
|
||||
markOtpSendPending("signup")
|
||||
navigate("/auth/signup/verify")
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthPanel
|
||||
title={t.login.signupPasswordTitle}
|
||||
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">
|
||||
<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
|
||||
id="signup-password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
placeholder={t.login.newPasswordPlaceholder}
|
||||
disabled={loading}
|
||||
/>
|
||||
<AuthPasswordField
|
||||
id="signup-password-confirmation"
|
||||
value={confirmation}
|
||||
onChange={setConfirmation}
|
||||
placeholder={t.login.confirmPasswordPlaceholder}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="h-11 w-full" disabled={loading}>
|
||||
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||
{t.login.createAccountPasswordCta}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/signup/verify")}>
|
||||
{t.login.back}
|
||||
<Button type="submit" className="h-11 w-full" disabled={state.cooldowns.signupOtpSend > 0}>
|
||||
{cooldownLabel || t.login.continueToVerifyMobile}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthPanel>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user