-
+
+
{t.title || "Qlockify"}
+
"{t.login.brandingQuote}"
@@ -252,176 +26,9 @@ export default function Auth() {
-
-
-
-
-
-
-
- {step === "mobile" && t.login.welcome(t.title)}
- {step === "password" && t.login.enterPassword}
- {step === "otp" && t.login.verifyNumber}
-
-
- {step === "mobile" && t.login.enterMobileDesc}
- {step === "password" && t.login.signInDesc}
- {step === "otp" && t.login.sentCodeDesc(mobile)}
-
-
-
- {activeCooldownMessage && (
-
-
-
-
-
{activeCooldownMessage.title}
-
{activeCooldownMessage.description}
-
-
-
- )}
-
-
- {step === "mobile" && (
-
-
setMobile(e.target.value)}
- maxLength={11}
- disabled={loading}
- className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
- />
-
-
-
-
-
-
-
-
- {t.login.orContinueWith}
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- {step === "password" && (
-
- )}
-
- {step === "otp" && (
-
- )}
-
-
-
- {t.loginTerms?.prefix}
-
- {t.loginTerms?.link}
-
- {t.loginTerms?.suffix}
-
+
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
index c3799f1..9d8341d 100644
--- a/src/pages/Profile.tsx
+++ b/src/pages/Profile.tsx
@@ -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
(null)
const [dragActive, setDragActive] = useState(false)
const fileInputRef = useRef(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() {
{!isEditing && (
-
+
+
+
+
)}
@@ -446,6 +497,62 @@ export default function Profile() {
)}
+ {isPasswordModalOpen && (
+
{
+ if (isSaving) return
+ setIsPasswordModalOpen(false)
+ resetPasswordForm()
+ }}
+ title={passwordCopy.title}
+ description={passwordCopy.description}
+ maxWidth="max-w-md"
+ >
+
+
+ )}
+
>
)
diff --git a/src/pages/auth/AuthPanel.tsx b/src/pages/auth/AuthPanel.tsx
new file mode 100644
index 0000000..e7c8a91
--- /dev/null
+++ b/src/pages/auth/AuthPanel.tsx
@@ -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 (
+
+
+
+
+
+
{title}
+
{description}
+
+
+ {alert && (
+
+
+
+
+
{alert.title}
+
{alert.description}
+
+
+
+ )}
+
+
+ {children}
+
+
+
+ {t.loginTerms?.prefix}
+
+ {t.loginTerms?.link}
+
+ {t.loginTerms?.suffix}
+
+
+ )
+}
diff --git a/src/pages/auth/AuthPasswordField.tsx b/src/pages/auth/AuthPasswordField.tsx
new file mode 100644
index 0000000..742b1b2
--- /dev/null
+++ b/src/pages/auth/AuthPasswordField.tsx
@@ -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 (
+
+ onChange(event.target.value)}
+ className="h-11 pe-10 text-start"
+ />
+
+
+ )
+}
diff --git a/src/pages/auth/ForgotPasswordMobilePage.tsx b/src/pages/auth/ForgotPasswordMobilePage.tsx
new file mode 100644
index 0000000..01f9bb5
--- /dev/null
+++ b/src/pages/auth/ForgotPasswordMobilePage.tsx
@@ -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 (
+
+
+
setMobile("forgotPassword", event.target.value)}
+ className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
+ />
+
+
+
+
+
+ {t.login.backToPasswordLogin}
+
+
+
+
+ )
+}
diff --git a/src/pages/auth/ForgotPasswordOtpPage.tsx b/src/pages/auth/ForgotPasswordOtpPage.tsx
new file mode 100644
index 0000000..8005357
--- /dev/null
+++ b/src/pages/auth/ForgotPasswordOtpPage.tsx
@@ -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
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/ForgotPasswordPasswordPage.tsx b/src/pages/auth/ForgotPasswordPasswordPage.tsx
new file mode 100644
index 0000000..4b0e1e0
--- /dev/null
+++ b/src/pages/auth/ForgotPasswordPasswordPage.tsx
@@ -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
+ }
+
+ if (!state.forgotPassword.code) {
+ return
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/LoginMobilePage.tsx b/src/pages/auth/LoginMobilePage.tsx
new file mode 100644
index 0000000..0608208
--- /dev/null
+++ b/src/pages/auth/LoginMobilePage.tsx
@@ -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 = () => (
+
+)
+
+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 (
+
+
+
setMobile("login", event.target.value)}
+ className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
+ />
+
+
+
+
+
+
+
+
+
+ {t.login.orContinueWith}
+
+
+
+
+
+
+
+ {t.login.haveNoAccount}{" "}
+
+ {t.login.register}
+
+
+
+
+ )
+}
diff --git a/src/pages/auth/LoginOtpPage.tsx b/src/pages/auth/LoginOtpPage.tsx
new file mode 100644
index 0000000..7736958
--- /dev/null
+++ b/src/pages/auth/LoginOtpPage.tsx
@@ -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
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/LoginPasswordPage.tsx b/src/pages/auth/LoginPasswordPage.tsx
new file mode 100644
index 0000000..11362ef
--- /dev/null
+++ b/src/pages/auth/LoginPasswordPage.tsx
@@ -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
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/SignupMobilePage.tsx b/src/pages/auth/SignupMobilePage.tsx
new file mode 100644
index 0000000..2381c90
--- /dev/null
+++ b/src/pages/auth/SignupMobilePage.tsx
@@ -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 (
+
+
+
setMobile("signup", event.target.value)}
+ className={`h-11 ${isRtl ? "text-end" : "text-start"}`}
+ />
+
+
+
+
+ {t.login.haveAccount}{" "}
+
+ {t.login.signIn}
+
+
+
+
+ )
+}
diff --git a/src/pages/auth/SignupOtpPage.tsx b/src/pages/auth/SignupOtpPage.tsx
new file mode 100644
index 0000000..f5aee39
--- /dev/null
+++ b/src/pages/auth/SignupOtpPage.tsx
@@ -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
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/SignupPasswordPage.tsx b/src/pages/auth/SignupPasswordPage.tsx
new file mode 100644
index 0000000..4517d21
--- /dev/null
+++ b/src/pages/auth/SignupPasswordPage.tsx
@@ -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
+ }
+
+ if (!state.signup.code) {
+ return
+ }
+
+ 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 (
+
+
+
+ )
+}
diff --git a/src/pages/auth/utils.ts b/src/pages/auth/utils.ts
new file mode 100644
index 0000000..32e2b02
--- /dev/null
+++ b/src/pages/auth/utils.ts
@@ -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 })
+}