feat(auth): add stepped auth and password recovery flows
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
58
src/pages/auth/AuthPanel.tsx
Normal file
58
src/pages/auth/AuthPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
src/pages/auth/AuthPasswordField.tsx
Normal file
46
src/pages/auth/AuthPasswordField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
src/pages/auth/ForgotPasswordMobilePage.tsx
Normal file
107
src/pages/auth/ForgotPasswordMobilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/pages/auth/ForgotPasswordOtpPage.tsx
Normal file
57
src/pages/auth/ForgotPasswordOtpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
src/pages/auth/ForgotPasswordPasswordPage.tsx
Normal file
89
src/pages/auth/ForgotPasswordPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
src/pages/auth/LoginMobilePage.tsx
Normal file
151
src/pages/auth/LoginMobilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
src/pages/auth/LoginOtpPage.tsx
Normal file
113
src/pages/auth/LoginOtpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
src/pages/auth/LoginPasswordPage.tsx
Normal file
119
src/pages/auth/LoginPasswordPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
src/pages/auth/SignupMobilePage.tsx
Normal file
108
src/pages/auth/SignupMobilePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/pages/auth/SignupOtpPage.tsx
Normal file
57
src/pages/auth/SignupOtpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/pages/auth/SignupPasswordPage.tsx
Normal file
93
src/pages/auth/SignupPasswordPage.tsx
Normal 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
88
src/pages/auth/utils.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user