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 { 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 /> },
|
||||||
|
|||||||
@@ -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/', {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: "تغییر رمز عبور انجام نشد."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user