feat(auth): add stepped auth and password recovery flows

This commit is contained in:
2026-05-03 17:10:02 +03:30
parent 9b1cd772fb
commit 380b794ab1
19 changed files with 1857 additions and 687 deletions

View File

@@ -1,250 +1,24 @@
import React, { useEffect, useMemo, useState } from "react"
import { useNavigate, Link } from "react-router-dom"
import { Button } from "../components/ui/button"
import { Input } from "../components/ui/input"
import { Outlet } from "react-router-dom"
import { Command } from "lucide-react"
import { SettingsMenu } from "../components/SettingsMenu"
import { AlertTriangle, ArrowLeft, ArrowRight, Command, Eye, EyeOff, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { useTranslation } from "../hooks/useTranslation"
import { loginWithOtp, loginWithPassword, sendOtp, startGoogleLogin } from "../api/users"
import { ApiError } from "../api/client"
import { setSessionTokens } from "../lib/session"
type AuthStep = "mobile" | "password" | "otp"
type AuthMode = "login" | "register"
type CooldownKey = "otpSend" | "passwordLogin" | "otpLogin"
type Cooldowns = Record<CooldownKey, number>
const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]
const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit)
const GoogleIcon = () => (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
<path
d="M21.805 10.023h-9.72v3.955h5.57c-.24 1.272-.96 2.35-2.042 3.07v2.548h3.3c1.933-1.78 3.042-4.4 3.042-7.506 0-.692-.062-1.357-.15-2.067Z"
fill="#4285F4"
/>
<path
d="M12.085 22c2.79 0 5.13-.925 6.84-2.504l-3.3-2.548c-.924.617-2.103.986-3.54.986-2.705 0-4.99-1.823-5.807-4.28H2.87v2.626A10.33 10.33 0 0 0 12.085 22Z"
fill="#34A853"
/>
<path
d="M6.278 13.654A6.214 6.214 0 0 1 5.95 11.7c0-.68.117-1.34.328-1.954V7.12H2.87A10.31 10.31 0 0 0 1.75 11.7c0 1.65.39 3.218 1.12 4.58l3.408-2.626Z"
fill="#FBBC05"
/>
<path
d="M12.085 5.466c1.52 0 2.882.522 3.955 1.55l2.966-2.966C17.21 2.387 14.874 1.4 12.085 1.4A10.33 10.33 0 0 0 2.87 7.12l3.408 2.626c.818-2.457 3.103-4.28 5.807-4.28Z"
fill="#EA4335"
/>
</svg>
)
export default function Auth() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const isRtl = lang === "fa"
const [step, setStep] = useState<AuthStep>("mobile")
const [mode, setMode] = useState<AuthMode>("login")
const [mobile, setMobile] = useState("")
const [password, setPassword] = useState("")
const [otpCode, setOtpCode] = useState("")
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [cooldowns, setCooldowns] = useState<Cooldowns>({
otpSend: 0,
passwordLogin: 0,
otpLogin: 0,
})
useEffect(() => {
if (!Object.values(cooldowns).some((value) => value > 0)) {
return
}
const timer = window.setInterval(() => {
setCooldowns((current) => ({
otpSend: Math.max(0, current.otpSend - 1),
passwordLogin: Math.max(0, current.passwordLogin - 1),
otpLogin: Math.max(0, current.otpLogin - 1),
}))
}, 1000)
return () => window.clearInterval(timer)
}, [cooldowns])
const localizeDigits = (value: string) => (isRtl ? toPersianDigits(value) : value)
const formatCooldown = (seconds: number) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
const base = minutes > 0
? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`
: `${remainingSeconds}s`
return localizeDigits(base)
}
const setCooldown = (key: CooldownKey, seconds: number) => {
setCooldowns((current) => ({
...current,
[key]: Math.max(current[key], seconds),
}))
}
const handleTokenResponse = (access: string, refresh: string) => {
setSessionTokens(access, refresh)
toast.success(t.login.toasts.successLogin)
navigate("/profile")
}
const handleThrottleError = (error: unknown, key: CooldownKey) => {
if (!(error instanceof ApiError) || error.code !== "throttled") {
return false
}
const seconds = Math.max(1, error.retryAfterSeconds ?? 0)
const formattedTime = formatCooldown(seconds)
setCooldown(key, seconds)
const throttleCopy = t.login.throttle
const message =
key === "otpSend"
? throttleCopy.otpSendMessage(formattedTime)
: key === "passwordLogin"
? throttleCopy.passwordLoginMessage(formattedTime)
: throttleCopy.otpLoginMessage(formattedTime)
toast.error(message, {
description: throttleCopy.countdownLabel(formattedTime),
})
return true
}
const handleSendOtp = async (selectedMode: AuthMode) => {
if (!mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
setLoading(true)
try {
await sendOtp(mobile, selectedMode)
setCooldowns((current) => ({ ...current, otpSend: 0 }))
setMode(selectedMode)
setStep("otp")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (!handleThrottleError(error, "otpSend")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.failedOtp)
}
} finally {
setLoading(false)
}
}
const handlePasswordLogin = async (e: React.FormEvent) => {
e.preventDefault()
if (!mobile || !password) {
toast.error(t.login.toasts.fillAll)
return
}
setLoading(true)
try {
const data = await loginWithPassword(mobile, password)
setCooldowns((current) => ({ ...current, passwordLogin: 0 }))
handleTokenResponse(data.access, data.refresh)
} catch (error) {
if (!handleThrottleError(error, "passwordLogin")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.invalidCreds)
}
} finally {
setLoading(false)
}
}
const handleOtpVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (!mobile || !otpCode) {
toast.error(t.login.toasts.enterOtp)
return
}
setLoading(true)
try {
const data = await loginWithOtp(mobile, otpCode)
setCooldowns((current) => ({ ...current, otpLogin: 0 }))
handleTokenResponse(data.access, data.refresh)
} catch (error) {
if (!handleThrottleError(error, "otpLogin")) {
toast.error(error instanceof Error ? error.message : t.login.toasts.invalidOtp)
}
} finally {
setLoading(false)
}
}
const activeCooldownMessage = useMemo(() => {
const throttleCopy = t.login.throttle
if (step === "mobile" && cooldowns.otpSend > 0) {
const formatted = formatCooldown(cooldowns.otpSend)
return {
title: throttleCopy.title,
description: throttleCopy.otpSendMessage(formatted),
}
}
if (step === "password" && cooldowns.passwordLogin > 0) {
const formatted = formatCooldown(cooldowns.passwordLogin)
return {
title: throttleCopy.title,
description: throttleCopy.passwordLoginMessage(formatted),
}
}
if (step === "otp" && cooldowns.otpLogin > 0) {
const formatted = formatCooldown(cooldowns.otpLogin)
return {
title: throttleCopy.title,
description: throttleCopy.otpLoginMessage(formatted),
}
}
return null
}, [cooldowns, formatCooldown, step, t.login.throttle])
const otpCooldownLabel =
cooldowns.otpSend > 0 ? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpSend)) : null
const passwordCooldownLabel =
cooldowns.passwordLogin > 0
? t.login.throttle.countdownLabel(formatCooldown(cooldowns.passwordLogin))
: null
const otpLoginCooldownLabel =
cooldowns.otpLogin > 0 ? t.login.throttle.countdownLabel(formatCooldown(cooldowns.otpLogin)) : null
const BackIcon = isRtl ? ArrowRight : ArrowLeft
const { t } = useTranslation()
return (
<div className="container relative min-h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0 bg-white dark:bg-slate-950 transition-colors">
<div className="container relative min-h-screen grid flex-col items-center justify-center bg-white transition-colors lg:max-w-none lg:grid-cols-2 lg:px-0 dark:bg-slate-950">
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
<SettingsMenu />
</div>
<div className="relative hidden h-full flex-col bg-slate-900 dark:bg-slate-900/50 p-10 text-white lg:flex border-e border-slate-200 dark:border-slate-800">
<div className="relative z-20 flex items-center text-lg font-medium gap-2">
<div className="relative hidden h-full flex-col border-e border-slate-200 bg-slate-900 p-10 text-white dark:border-slate-800 dark:bg-slate-900/50 lg:flex">
<div className="relative z-20 flex items-center gap-2 text-lg font-medium">
<Command className="h-6 w-6" />
{t.title || "Qlockify"}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">"{t.login.brandingQuote}"</p>
@@ -252,176 +26,9 @@ export default function Auth() {
</div>
</div>
<div className="p-8 lg:p-8 flex h-screen items-center justify-center">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-87.5">
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
<div className="flex justify-center lg:hidden mb-4">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{step === "mobile" && t.login.welcome(t.title)}
{step === "password" && t.login.enterPassword}
{step === "otp" && t.login.verifyNumber}
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
{step === "mobile" && t.login.enterMobileDesc}
{step === "password" && t.login.signInDesc}
{step === "otp" && t.login.sentCodeDesc(mobile)}
</p>
</div>
{activeCooldownMessage && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{activeCooldownMessage.title}</p>
<p className="text-sm">{activeCooldownMessage.description}</p>
</div>
</div>
</div>
)}
<div className="grid gap-6">
{step === "mobile" && (
<div className="grid gap-4">
<Input
id="mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
value={mobile}
onChange={(e) => setMobile(e.target.value)}
maxLength={11}
disabled={loading}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={() => {
if (!mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
setStep("password")
}}
className="w-full h-11"
>
{t.login.continueWithPassword}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white dark:bg-slate-950 px-2 text-slate-500 dark:text-slate-400 transition-colors">
{t.login.orContinueWith}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={startGoogleLogin}
disabled={loading}
className="h-11 w-full"
>
<GoogleIcon />
<span className="ms-3">{t.login.continueWithGoogle}</span>
</Button>
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
onClick={() => handleSendOtp("login")}
disabled={loading || cooldowns.otpSend > 0}
className="h-11"
>
{loading && mode === "login" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldowns.otpSend > 0 ? otpCooldownLabel : t.login.otpLogin}
</Button>
<Button
variant="outline"
onClick={() => handleSendOtp("register")}
disabled={loading || cooldowns.otpSend > 0}
className="h-11"
>
{loading && mode === "register" && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldowns.otpSend > 0 ? otpCooldownLabel : t.login.register}
</Button>
</div>
</div>
)}
{step === "password" && (
<form onSubmit={handlePasswordLogin} autoComplete="off" className="grid gap-4">
<div className="relative w-full" dir="ltr">
<Input
id="password"
placeholder={t.login.passwordPlaceholder}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
dir="ltr"
name="some-random-name-to-disable-auto-complete-on-browser"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`}
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
<Button type="submit" className="w-full h-11" disabled={loading || cooldowns.passwordLogin > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{passwordCooldownLabel || t.login.signIn}
</Button>
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
</Button>
</form>
)}
{step === "otp" && (
<form onSubmit={handleOtpVerify} className="grid gap-4">
<Input
id="otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
maxLength={6}
disabled={loading}
className="h-11 text-center tracking-widest text-lg"
/>
<Button type="submit" className="w-full h-11" disabled={loading || cooldowns.otpLogin > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{otpLoginCooldownLabel || t.login.verifyAndContinue}
</Button>
<Button type="button" variant="ghost" onClick={() => setStep("mobile")} className="text-sm text-slate-500 dark:text-slate-400">
<BackIcon className="me-2 h-4 w-4" /> {t.login.back}
</Button>
</form>
)}
</div>
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
{t.loginTerms?.prefix}
<Link
to="/terms"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 transition-colors"
>
{t.loginTerms?.link}
</Link>
{t.loginTerms?.suffix}
</div>
<div className="flex h-screen items-center justify-center p-8 lg:p-8">
<div className="mx-auto flex w-full max-w-[26rem] flex-col justify-center">
<Outlet />
</div>
</div>
</div>

View File

@@ -5,7 +5,8 @@ import {
getUserProfile,
updateUserProfile,
updateProfilePicture,
removeProfilePicture
removeProfilePicture,
changePassword,
} from "../api/users"
import { Button } from "../components/ui/button"
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react"
@@ -14,6 +15,7 @@ import { toast } from "sonner"
import { Modal } from "../components/Modal"
import { Input } from "../components/ui/input"
import { TextAreaInput } from "../components/ui/TextAreaInput"
import { AuthPasswordField } from "./auth/AuthPasswordField"
export interface UserProfile {
id?: string;
@@ -36,6 +38,7 @@ export default function Profile() {
const { t, lang } = useTranslation()
const isFa = lang === 'fa'
const passwordCopy = t.profile.password
const toPersianNum = (num: string | number | undefined | null) => {
if (num === null || num === undefined) return num
@@ -59,6 +62,7 @@ export default function Profile() {
// Modals & Editing state
const [isEditing, setIsEditing] = useState(false)
const [isPicModalOpen, setIsPicModalOpen] = useState(false)
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form states
@@ -66,6 +70,11 @@ export default function Profile() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [passwordForm, setPasswordForm] = useState({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
const fetchProfile = async () => {
try {
@@ -162,6 +171,44 @@ export default function Profile() {
}
}
const resetPasswordForm = () => {
setPasswordForm({
currentPassword: "",
newPassword: "",
confirmPassword: "",
})
}
const handleChangePassword = async (event: React.FormEvent) => {
event.preventDefault()
if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) {
toast.error(t.login.toasts.fillAll)
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error(t.login.passwordMismatch)
return
}
setIsSaving(true)
try {
await changePassword(
passwordForm.currentPassword,
passwordForm.newPassword,
passwordForm.confirmPassword,
)
resetPasswordForm()
setIsPasswordModalOpen(false)
toast.success(passwordCopy.toasts.success)
} catch (error) {
toast.error(error instanceof Error ? error.message : passwordCopy.toasts.error)
} finally {
setIsSaving(false)
}
}
// Drag & Drop Handlers
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
@@ -233,10 +280,14 @@ export default function Profile() {
</h2>
{!isEditing && (
<Button onClick={handleEditClick} className="flex items-center gap-2">
<Edit2 className="h-4 w-4" />
{t.profile?.editInfo || 'Edit Profile'}
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsPasswordModalOpen(true)}>
{passwordCopy.trigger}
</Button>
<Button onClick={handleEditClick}>
<Edit2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
@@ -446,6 +497,62 @@ export default function Profile() {
</Modal>
)}
{isPasswordModalOpen && (
<Modal
isOpen={isPasswordModalOpen}
isFa={isFa}
onClose={() => {
if (isSaving) return
setIsPasswordModalOpen(false)
resetPasswordForm()
}}
title={passwordCopy.title}
description={passwordCopy.description}
maxWidth="max-w-md"
>
<form onSubmit={handleChangePassword} className="grid gap-4">
<AuthPasswordField
id="current-password"
value={passwordForm.currentPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, currentPassword: value }))}
placeholder={passwordCopy.currentPassword}
disabled={isSaving}
/>
<AuthPasswordField
id="new-password"
value={passwordForm.newPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, newPassword: value }))}
placeholder={passwordCopy.newPassword}
disabled={isSaving}
/>
<AuthPasswordField
id="confirm-password"
value={passwordForm.confirmPassword}
onChange={(value) => setPasswordForm((current) => ({ ...current, confirmPassword: value }))}
placeholder={passwordCopy.confirmPassword}
disabled={isSaving}
/>
<div className="flex flex-col-reverse gap-3 mt-3 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => {
setIsPasswordModalOpen(false)
resetPasswordForm()
}}
disabled={isSaving}
>
{t.actions?.cancel || "Cancel"}
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? passwordCopy.saving : passwordCopy.submit}
</Button>
</div>
</form>
</Modal>
)}
</div>
</>
)

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from "react"
import { Link } from "react-router-dom"
import { Command, AlertTriangle } from "lucide-react"
import { useTranslation } from "../../hooks/useTranslation"
interface AuthPanelProps {
title: string
description: string
children: ReactNode
alert?: {
title: string
description: string
} | null
}
export function AuthPanel({ title, description, children, alert = null }: AuthPanelProps) {
const { t } = useTranslation()
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="mb-4 flex justify-center lg:hidden">
<Command className="h-8 w-8" />
</div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
</div>
{alert && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-start text-amber-900 shadow-sm dark:border-amber-900/50 dark:bg-amber-950/40 dark:text-amber-100">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium">{alert.title}</p>
<p className="text-sm">{alert.description}</p>
</div>
</div>
</div>
)}
<div className="grid gap-6">
{children}
</div>
<div className="mt-6 text-center text-sm text-slate-500 dark:text-slate-400">
{t.loginTerms?.prefix}
<Link
to="/terms"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.loginTerms?.link}
</Link>
{t.loginTerms?.suffix}
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import { Input } from "../../components/ui/input"
interface AuthPasswordFieldProps {
id: string
value: string
onChange: (value: string) => void
placeholder: string
disabled?: boolean
}
export function AuthPasswordField({
id,
value,
onChange,
placeholder,
disabled = false,
}: AuthPasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<div className="relative w-full" dir="ltr">
<Input
id={id}
value={value}
type={showPassword ? "text" : "password"}
placeholder={placeholder}
autoComplete="new-password"
dir="ltr"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-11 pe-10 text-start"
/>
<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"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { sendOtp } 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 { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
export function ForgotPasswordMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow()
const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => {
if (state.cooldowns.forgotPasswordOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.forgotPasswordOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl))
: null
const handleContinue = async () => {
if (!state.forgotPassword.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
setLoading(true)
try {
await sendOtp(state.forgotPassword.mobile, "forget_password")
clearCooldown("forgotPasswordOtpSend")
setCode("forgotPassword", "")
navigate("/auth/forgot-password/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "forgotPasswordOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.forgotPasswordTitle}
description={t.login.forgotPasswordDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="forgot-password-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
disabled={loading}
value={state.forgotPassword.mobile}
onChange={(event) => setMobile("forgotPassword", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleContinue}
disabled={loading || state.cooldowns.forgotPasswordOtpSend > 0}
className="h-11 w-full"
>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.sendResetCode}
</Button>
<div className="text-center underline text-sm text-slate-500 dark:text-slate-400">
<Link
to="/auth/login/password"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.backToPasswordLogin}
</Link>
</div>
</div>
</AuthPanel>
)
}

View File

@@ -0,0 +1,57 @@
import { useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
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"
export function ForgotPasswordOtpPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { state, setCode } = useAuthFlow()
const [loading, setLoading] = useState(false)
if (!state.forgotPassword.mobile) {
return <Navigate to="/auth/forgot-password" replace />
}
const handleContinue = async (event: React.FormEvent) => {
event.preventDefault()
if (!state.forgotPassword.code) {
toast.error(t.login.toasts.enterOtp)
return
}
setLoading(true)
navigate("/auth/forgot-password/password")
}
return (
<AuthPanel
title={t.login.forgotPasswordVerifyTitle}
description={t.login.sentCodeDesc(state.forgotPassword.mobile)}
>
<form onSubmit={handleContinue} className="grid gap-4">
<Input
id="forgot-password-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={6}
disabled={loading}
value={state.forgotPassword.code}
onChange={(event) => setCode("forgotPassword", event.target.value)}
className="h-11 text-center text-lg tracking-widest"
/>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{t.login.continueToResetPassword}
</Button>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,89 @@
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { resetPasswordWithOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { getApiErrorMessage } from "./utils"
export function ForgotPasswordPasswordPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { state, resetFlow, setMobile } = useAuthFlow()
const [password, setPassword] = useState("")
const [confirmation, setConfirmation] = useState("")
const [loading, setLoading] = useState(false)
if (!state.forgotPassword.mobile) {
return <Navigate to="/auth/forgot-password" replace />
}
if (!state.forgotPassword.code) {
return <Navigate to="/auth/forgot-password/verify" replace />
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password || !confirmation) {
toast.error(t.login.toasts.fillAll)
return
}
if (password !== confirmation) {
toast.error(t.login.passwordMismatch)
return
}
setLoading(true)
try {
await resetPasswordWithOtp(state.forgotPassword.mobile, state.forgotPassword.code, password, confirmation)
setMobile("login", state.forgotPassword.mobile)
resetFlow("forgotPassword")
toast.success(t.login.toasts.passwordResetSuccess)
navigate("/auth/login/password", { replace: true })
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.passwordResetFailed))
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.resetPasswordTitle}
description={t.login.resetPasswordDescription}
>
<form onSubmit={handleSubmit} className="grid gap-4">
<AuthPasswordField
id="reset-password"
value={password}
onChange={setPassword}
placeholder={t.login.newPasswordPlaceholder}
disabled={loading}
/>
<AuthPasswordField
id="reset-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.resetPasswordCta}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/forgot-password/verify")}>
{t.login.back}
</Button>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,151 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { sendOtp, 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 { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
const GoogleIcon = () => (
<svg aria-hidden="true" className="h-5 w-5" viewBox="0 0 24 24">
<path
d="M21.805 10.023h-9.72v3.955h5.57c-.24 1.272-.96 2.35-2.042 3.07v2.548h3.3c1.933-1.78 3.042-4.4 3.042-7.506 0-.692-.062-1.357-.15-2.067Z"
fill="#4285F4"
/>
<path
d="M12.085 22c2.79 0 5.13-.925 6.84-2.504l-3.3-2.548c-.924.617-2.103.986-3.54.986-2.705 0-4.99-1.823-5.807-4.28H2.87v2.626A10.33 10.33 0 0 0 12.085 22Z"
fill="#34A853"
/>
<path
d="M6.278 13.654A6.214 6.214 0 0 1 5.95 11.7c0-.68.117-1.34.328-1.954V7.12H2.87A10.31 10.31 0 0 0 1.75 11.7c0 1.65.39 3.218 1.12 4.58l3.408-2.626Z"
fill="#FBBC05"
/>
<path
d="M12.085 5.466c1.52 0 2.882.522 3.955 1.55l2.966-2.966C17.21 2.387 14.874 1.4 12.085 1.4A10.33 10.33 0 0 0 2.87 7.12l3.408 2.626c.818-2.457 3.103-4.28 5.807-4.28Z"
fill="#EA4335"
/>
</svg>
)
export function LoginMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, setCooldown, clearCooldown, resetFlow } = useAuthFlow()
const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => {
if (state.cooldowns.loginOtpSend <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpSendMessage(formatted),
}
}, [isRtl, state.cooldowns.loginOtpSend, t.login.throttle])
const cooldownLabel =
state.cooldowns.loginOtpSend > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl))
: null
const handleLogin = async () => {
if (!state.login.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
setLoading(true)
try {
await sendOtp(state.login.mobile, "login")
clearCooldown("loginOtpSend")
resetFlow("forgotPassword")
navigate("/auth/login/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.loginTitle}
description={t.login.loginDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="login-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
disabled={loading}
value={state.login.mobile}
onChange={(event) => setMobile("login", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleLogin}
disabled={loading || state.cooldowns.loginOtpSend > 0}
className="h-11 w-full"
>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.loginCta}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200 dark:border-slate-800" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500 transition-colors dark:bg-slate-950 dark:text-slate-400">
{t.login.orContinueWith}
</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={startGoogleLogin}
disabled={loading}
className="h-11 w-full"
>
<GoogleIcon />
<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>
</AuthPanel>
)
}

View File

@@ -0,0 +1,113 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { loginWithOtp } 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 { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
export function LoginOtpPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setCode, setCooldown, clearCooldown } = useAuthFlow()
const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
if (!state.login.mobile) {
return <Navigate to="/auth/login" replace />
}
const alert = useMemo(() => {
if (state.cooldowns.loginOtpVerify <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.otpLoginMessage(formatted),
}
}, [isRtl, state.cooldowns.loginOtpVerify, t.login.throttle])
const cooldownLabel =
state.cooldowns.loginOtpVerify > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpVerify, isRtl))
: null
const handleVerify = async (event: React.FormEvent) => {
event.preventDefault()
if (!state.login.code) {
toast.error(t.login.toasts.enterOtp)
return
}
setLoading(true)
try {
const data = await loginWithOtp(state.login.mobile, state.login.code)
clearCooldown("loginOtpVerify")
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.successLogin,
redirectTo: "/profile",
navigate,
})
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginOtpVerify",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.loginOtpTitle}
description={t.login.sentCodeDesc(state.login.mobile)}
alert={alert}
>
<form onSubmit={handleVerify} className="grid gap-4">
<Input
id="login-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={5}
disabled={loading}
value={state.login.code}
onChange={(event) => setCode("login", event.target.value)}
className="h-11 text-center text-lg tracking-widest"
/>
<Button type="submit" className="h-11 w-full" disabled={loading || state.cooldowns.loginOtpVerify > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.verifyAndContinue}
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400 underline">
<Link
to="/auth/login/password"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.usePasswordInstead}
</Link>
</div>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,119 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { loginWithPassword } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
export function LoginPasswordPage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, clearCooldown, setCooldown, setCode, resetFlow } = useAuthFlow()
const isRtl = lang === "fa"
const [password, setPassword] = useState("")
const [loading, setLoading] = useState(false)
if (!state.login.mobile) {
return <Navigate to="/auth/login" replace />
}
const alert = useMemo(() => {
if (state.cooldowns.loginPassword <= 0) {
return null
}
const formatted = formatCooldown(state.cooldowns.loginPassword, isRtl)
return {
title: t.login.throttle.title,
description: t.login.throttle.passwordLoginMessage(formatted),
}
}, [isRtl, state.cooldowns.loginPassword, t.login.throttle])
const cooldownLabel =
state.cooldowns.loginPassword > 0
? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginPassword, isRtl))
: null
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password) {
toast.error(t.login.toasts.fillAll)
return
}
setLoading(true)
try {
const data = await loginWithPassword(state.login.mobile, password)
clearCooldown("loginPassword")
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.successLogin,
redirectTo: "/profile",
navigate,
})
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "loginPassword",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.invalidCreds))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.passwordLoginTitle}
description={t.login.passwordLoginDescription(state.login.mobile)}
alert={alert}
>
<form onSubmit={handleSubmit} autoComplete="off" className="grid gap-4">
<AuthPasswordField
id="login-password"
placeholder={t.login.passwordPlaceholder}
value={password}
onChange={setPassword}
disabled={loading}
/>
<Button type="submit" className="h-11 w-full" disabled={loading || state.cooldowns.loginPassword > 0}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.signIn}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/login/verify")}>
{t.login.useOtpInstead}
</Button>
<div className="space-y-2 text-center text-sm text-slate-500 dark:text-slate-400">
<button
type="button"
className="font-medium underline text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
onClick={() => {
resetFlow("forgotPassword")
setMobile("forgotPassword", state.login.mobile)
setCode("forgotPassword", "")
navigate("/auth/forgot-password")
}}
>
{t.login.forgotPassword}
</button>
</div>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,108 @@
import { Loader2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { sendOtp } 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 { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils"
export function SignupMobilePage() {
const navigate = useNavigate()
const { t, lang } = useTranslation()
const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow()
const isRtl = lang === "fa"
const [loading, setLoading] = useState(false)
const alert = useMemo(() => {
if (state.cooldowns.signupOtpSend <= 0) {
return null
}
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 handleContinue = async () => {
if (!state.signup.mobile) {
toast.error(t.login.toasts.enterMobile)
return
}
setLoading(true)
try {
await sendOtp(state.signup.mobile, "register")
clearCooldown("signupOtpSend")
setCode("signup", "")
navigate("/auth/signup/verify")
toast.success(t.login.toasts.verifySent)
} catch (error) {
if (
!handleThrottleError({
error,
cooldownKey: "signupOtpSend",
setCooldown,
formatTime: (seconds) => formatCooldown(seconds, isRtl),
throttleCopy: t.login.throttle,
})
) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp))
}
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.signupTitle}
description={t.login.signupDescription}
alert={alert}
>
<div className="grid gap-4">
<Input
id="signup-mobile"
placeholder={t.login.mobilePlaceholder}
type="tel"
dir="ltr"
maxLength={11}
disabled={loading}
value={state.signup.mobile}
onChange={(event) => setMobile("signup", event.target.value)}
className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
/>
<Button
onClick={handleContinue}
disabled={loading || state.cooldowns.signupOtpSend > 0}
className="h-11 w-full"
>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{cooldownLabel || t.login.sendSignupCode}
</Button>
<div className="text-center text-sm text-slate-500 dark:text-slate-400">
{t.login.haveAccount}{" "}
<Link
to="/auth/login"
className="font-medium text-blue-600 transition-colors hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300"
>
{t.login.signIn}
</Link>
</div>
</div>
</AuthPanel>
)
}

View File

@@ -0,0 +1,57 @@
import { useState } from "react"
import { Link, Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
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"
export function SignupOtpPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { state, setCode } = useAuthFlow()
const [loading, setLoading] = useState(false)
if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace />
}
const handleContinue = async (event: React.FormEvent) => {
event.preventDefault()
if (!state.signup.code) {
toast.error(t.login.toasts.enterOtp)
return
}
setLoading(true)
navigate("/auth/signup/password")
}
return (
<AuthPanel
title={t.login.signupVerifyTitle}
description={t.login.sentCodeDesc(state.signup.mobile)}
>
<form onSubmit={handleContinue} className="grid gap-4">
<Input
id="signup-otp"
placeholder={t.login.otpPlaceholder}
type="text"
dir="ltr"
maxLength={6}
disabled={loading}
value={state.signup.code}
onChange={(event) => setCode("signup", event.target.value)}
className="h-11 text-center text-lg tracking-widest"
/>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{t.login.continueToPassword}
</Button>
</form>
</AuthPanel>
)
}

View File

@@ -0,0 +1,93 @@
import { Loader2 } from "lucide-react"
import { 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 { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, getApiErrorMessage } 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)
if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace />
}
if (!state.signup.code) {
return <Navigate to="/auth/signup/verify" replace />
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password || !confirmation) {
toast.error(t.login.toasts.fillAll)
return
}
if (password !== confirmation) {
toast.error(t.login.passwordMismatch)
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,
})
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup))
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.signupPasswordTitle}
description={t.login.signupPasswordDescription}
>
<form onSubmit={handleSubmit} className="grid gap-4">
<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>
</form>
</AuthPanel>
)
}

88
src/pages/auth/utils.ts Normal file
View File

@@ -0,0 +1,88 @@
import { toast } from "sonner"
import { ApiError } from "../../api/client"
import { setSessionTokens } from "../../lib/session"
const PERSIAN_DIGITS = ["\u06f0", "\u06f1", "\u06f2", "\u06f3", "\u06f4", "\u06f5", "\u06f6", "\u06f7", "\u06f8", "\u06f9"]
export const localizeDigits = (value: string, isRtl: boolean) =>
isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value
export const formatCooldown = (seconds: number, isRtl: boolean) => {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
const base =
minutes > 0 ? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}` : `${remainingSeconds}s`
return localizeDigits(base, isRtl)
}
export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => {
if (error instanceof ApiError) {
return error.message
}
if (error instanceof Error) {
return error.message
}
return fallbackMessage
}
export const handleThrottleError = ({
error,
cooldownKey,
setCooldown,
formatTime,
throttleCopy,
}: {
error: unknown
cooldownKey: "loginOtpSend" | "signupOtpSend" | "forgotPasswordOtpSend" | "loginPassword" | "loginOtpVerify"
setCooldown: (key: "loginOtpSend" | "signupOtpSend" | "forgotPasswordOtpSend" | "loginPassword" | "loginOtpVerify", seconds: number) => void
formatTime: (seconds: number) => string
throttleCopy: {
otpSendMessage: (time: string) => string
passwordLoginMessage: (time: string) => string
otpLoginMessage: (time: string) => string
countdownLabel: (time: string) => string
}
}) => {
if (!(error instanceof ApiError) || error.code !== "throttled") {
return false
}
const seconds = Math.max(1, error.retryAfterSeconds ?? 0)
const formatted = formatTime(seconds)
setCooldown(cooldownKey, seconds)
const message =
cooldownKey === "loginPassword"
? throttleCopy.passwordLoginMessage(formatted)
: cooldownKey === "loginOtpVerify"
? throttleCopy.otpLoginMessage(formatted)
: throttleCopy.otpSendMessage(formatted)
toast.error(message, {
description: throttleCopy.countdownLabel(formatted),
})
return true
}
export const completeAuthentication = ({
access,
refresh,
successMessage,
redirectTo,
navigate,
}: {
access: string
refresh: string
successMessage: string
redirectTo: string
navigate: (path: string, options?: { replace?: boolean }) => void
}) => {
setSessionTokens(access, refresh)
toast.success(successMessage)
navigate(redirectTo, { replace: true })
}