feat(auth): add stepped auth and password recovery flows
This commit is contained in:
31
src/App.tsx
31
src/App.tsx
@@ -27,6 +27,16 @@ import NotificationsPage from "./pages/Notifications"
|
||||
import RateLimitPage from "./pages/RateLimit"
|
||||
import Landing from "./pages/Landing"
|
||||
import { isRateLimitActive } from "./lib/rateLimit"
|
||||
import { AuthFlowProvider } from "./context/AuthFlowContext"
|
||||
import { LoginMobilePage } from "./pages/auth/LoginMobilePage"
|
||||
import { LoginOtpPage } from "./pages/auth/LoginOtpPage"
|
||||
import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage"
|
||||
import { SignupMobilePage } from "./pages/auth/SignupMobilePage"
|
||||
import { SignupOtpPage } from "./pages/auth/SignupOtpPage"
|
||||
import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage"
|
||||
import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage"
|
||||
import { ForgotPasswordOtpPage } from "./pages/auth/ForgotPasswordOtpPage"
|
||||
import { ForgotPasswordPasswordPage } from "./pages/auth/ForgotPasswordPasswordPage"
|
||||
|
||||
const MainLayout = () => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
@@ -81,8 +91,27 @@ const router = createBrowserRouter([
|
||||
children: [
|
||||
{ path: "/", element: <Landing /> },
|
||||
{ path: "/app", element: <AppRedirect /> },
|
||||
{ path: "/auth", element: <Auth /> },
|
||||
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
|
||||
{
|
||||
path: "/auth",
|
||||
element: (
|
||||
<AuthFlowProvider>
|
||||
<Auth />
|
||||
</AuthFlowProvider>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/auth/login" replace /> },
|
||||
{ path: "login", element: <LoginMobilePage /> },
|
||||
{ path: "login/verify", element: <LoginOtpPage /> },
|
||||
{ path: "login/password", element: <LoginPasswordPage /> },
|
||||
{ path: "signup", element: <SignupMobilePage /> },
|
||||
{ path: "signup/verify", element: <SignupOtpPage /> },
|
||||
{ path: "signup/password", element: <SignupPasswordPage /> },
|
||||
{ path: "forgot-password", element: <ForgotPasswordMobilePage /> },
|
||||
{ path: "forgot-password/verify", element: <ForgotPasswordOtpPage /> },
|
||||
{ path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "/terms", element: <Terms /> },
|
||||
{ path: "/rate-limit", element: <RateLimitPage /> },
|
||||
{
|
||||
|
||||
@@ -29,6 +29,49 @@ export const loginWithOtp = async (mobile: string, otp: string) => {
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const registerWithOtp = async (
|
||||
mobile: string,
|
||||
code: string,
|
||||
password: string,
|
||||
re_password: string,
|
||||
first_name = "",
|
||||
last_name = "",
|
||||
) => {
|
||||
const response = await authFetch("/api/users/register/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mobile, code, password, re_password, first_name, last_name }),
|
||||
})
|
||||
if (!response.ok) throw await buildApiError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const resetPasswordWithOtp = async (
|
||||
mobile: string,
|
||||
code: string,
|
||||
password: string,
|
||||
re_password: string,
|
||||
) => {
|
||||
const response = await authFetch("/api/users/password/reset/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mobile, code, password, re_password }),
|
||||
})
|
||||
if (!response.ok) throw await buildApiError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const changePassword = async (
|
||||
old_password: string,
|
||||
new_password: string,
|
||||
re_password: string,
|
||||
) => {
|
||||
const response = await authFetch("/api/users/password/change/", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ old_password, new_password, re_password }),
|
||||
})
|
||||
if (!response.ok) throw await buildApiError(response)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export const startGoogleLogin = () => {
|
||||
window.location.assign(buildApiUrl("/api/users/oauth/google/start/"));
|
||||
};
|
||||
|
||||
196
src/context/AuthFlowContext.tsx
Normal file
196
src/context/AuthFlowContext.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
|
||||
type FlowName = "login" | "signup" | "forgotPassword"
|
||||
export type CooldownKey =
|
||||
| "loginOtpSend"
|
||||
| "signupOtpSend"
|
||||
| "forgotPasswordOtpSend"
|
||||
| "loginPassword"
|
||||
| "loginOtpVerify"
|
||||
|
||||
interface FlowBranchState {
|
||||
mobile: string
|
||||
code: string
|
||||
}
|
||||
|
||||
interface CooldownState {
|
||||
loginOtpSend: number
|
||||
signupOtpSend: number
|
||||
forgotPasswordOtpSend: number
|
||||
loginPassword: number
|
||||
loginOtpVerify: number
|
||||
}
|
||||
|
||||
interface AuthFlowState {
|
||||
login: FlowBranchState
|
||||
signup: FlowBranchState
|
||||
forgotPassword: FlowBranchState
|
||||
cooldowns: CooldownState
|
||||
}
|
||||
|
||||
interface AuthFlowContextValue {
|
||||
state: AuthFlowState
|
||||
setMobile: (flow: FlowName, mobile: string) => void
|
||||
setCode: (flow: FlowName, code: string) => void
|
||||
setCooldown: (key: CooldownKey, seconds: number) => void
|
||||
clearCooldown: (key: CooldownKey) => void
|
||||
resetFlow: (flow: FlowName) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "auth_flow_state:v1"
|
||||
|
||||
const defaultState: AuthFlowState = {
|
||||
login: {
|
||||
mobile: "",
|
||||
code: "",
|
||||
},
|
||||
signup: {
|
||||
mobile: "",
|
||||
code: "",
|
||||
},
|
||||
forgotPassword: {
|
||||
mobile: "",
|
||||
code: "",
|
||||
},
|
||||
cooldowns: {
|
||||
loginOtpSend: 0,
|
||||
signupOtpSend: 0,
|
||||
forgotPasswordOtpSend: 0,
|
||||
loginPassword: 0,
|
||||
loginOtpVerify: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const AuthFlowContext = createContext<AuthFlowContextValue | null>(null)
|
||||
|
||||
const parseStoredState = (): AuthFlowState => {
|
||||
if (typeof window === "undefined") {
|
||||
return defaultState
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return defaultState
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<AuthFlowState>
|
||||
|
||||
return {
|
||||
login: {
|
||||
mobile: parsed.login?.mobile ?? "",
|
||||
code: parsed.login?.code ?? "",
|
||||
},
|
||||
signup: {
|
||||
mobile: parsed.signup?.mobile ?? "",
|
||||
code: parsed.signup?.code ?? "",
|
||||
},
|
||||
forgotPassword: {
|
||||
mobile: parsed.forgotPassword?.mobile ?? "",
|
||||
code: parsed.forgotPassword?.code ?? "",
|
||||
},
|
||||
cooldowns: {
|
||||
loginOtpSend: parsed.cooldowns?.loginOtpSend ?? 0,
|
||||
signupOtpSend: parsed.cooldowns?.signupOtpSend ?? 0,
|
||||
forgotPasswordOtpSend: parsed.cooldowns?.forgotPasswordOtpSend ?? 0,
|
||||
loginPassword: parsed.cooldowns?.loginPassword ?? 0,
|
||||
loginOtpVerify: parsed.cooldowns?.loginOtpVerify ?? 0,
|
||||
},
|
||||
}
|
||||
} catch {
|
||||
return defaultState
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthFlowProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthFlowState>(parseStoredState)
|
||||
|
||||
useEffect(() => {
|
||||
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
if (!Object.values(state.cooldowns).some((value) => value > 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
cooldowns: {
|
||||
loginOtpSend: Math.max(0, current.cooldowns.loginOtpSend - 1),
|
||||
signupOtpSend: Math.max(0, current.cooldowns.signupOtpSend - 1),
|
||||
forgotPasswordOtpSend: Math.max(0, current.cooldowns.forgotPasswordOtpSend - 1),
|
||||
loginPassword: Math.max(0, current.cooldowns.loginPassword - 1),
|
||||
loginOtpVerify: Math.max(0, current.cooldowns.loginOtpVerify - 1),
|
||||
},
|
||||
}))
|
||||
}, 1000)
|
||||
|
||||
return () => window.clearInterval(timer)
|
||||
}, [state.cooldowns])
|
||||
|
||||
const value = useMemo<AuthFlowContextValue>(
|
||||
() => ({
|
||||
state,
|
||||
setMobile: (flow, mobile) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
[flow]: {
|
||||
...current[flow],
|
||||
mobile,
|
||||
},
|
||||
}))
|
||||
},
|
||||
setCode: (flow, code) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
[flow]: {
|
||||
...current[flow],
|
||||
code,
|
||||
},
|
||||
}))
|
||||
},
|
||||
setCooldown: (key, seconds) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[key]: Math.max(current.cooldowns[key], seconds),
|
||||
},
|
||||
}))
|
||||
},
|
||||
clearCooldown: (key) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
cooldowns: {
|
||||
...current.cooldowns,
|
||||
[key]: 0,
|
||||
},
|
||||
}))
|
||||
},
|
||||
resetFlow: (flow) => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
[flow]: {
|
||||
mobile: "",
|
||||
code: "",
|
||||
},
|
||||
}))
|
||||
},
|
||||
}),
|
||||
[state],
|
||||
)
|
||||
|
||||
return <AuthFlowContext.Provider value={value}>{children}</AuthFlowContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuthFlow() {
|
||||
const context = useContext(AuthFlowContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAuthFlow must be used within an AuthFlowProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -27,11 +27,44 @@ export const en = {
|
||||
|
||||
login: {
|
||||
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
|
||||
loginTitle: "Sign in to your account",
|
||||
loginDescription: "Enter your mobile number and we will send a one-time code.",
|
||||
loginCta: "Login",
|
||||
createAccount: "Create account",
|
||||
haveNoAccount: "Need an account?",
|
||||
haveAccount: "Already have an account?",
|
||||
loginOtpTitle: "Verify your login code",
|
||||
passwordLoginTitle: "Login with password",
|
||||
passwordLoginDescription: (mobile: string) => `Enter the password for ${mobile}`,
|
||||
usePasswordInstead: "Use password instead",
|
||||
useOtpInstead: "Use OTP instead",
|
||||
backToMobile: "Back to mobile step",
|
||||
backToPasswordLogin: "Back to password login",
|
||||
forgotPassword: "Forgot password?",
|
||||
signupTitle: "Create your account",
|
||||
signupDescription: "Start with your mobile number to receive a verification code.",
|
||||
sendSignupCode: "Send verification code",
|
||||
signupVerifyTitle: "Verify your mobile number",
|
||||
continueToPassword: "Continue to password",
|
||||
signupPasswordTitle: "Set your password",
|
||||
signupPasswordDescription: "Choose a password for your new account.",
|
||||
createAccountPasswordCta: "Create account",
|
||||
forgotPasswordTitle: "Recover your password",
|
||||
forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.",
|
||||
sendResetCode: "Send reset code",
|
||||
forgotPasswordVerifyTitle: "Enter your reset code",
|
||||
continueToResetPassword: "Continue to reset password",
|
||||
resetPasswordTitle: "Choose a new password",
|
||||
resetPasswordDescription: "Set a new password for your account and confirm it.",
|
||||
resetPasswordCta: "Reset password",
|
||||
newPasswordPlaceholder: "New password",
|
||||
confirmPasswordPlaceholder: "Confirm password",
|
||||
passwordMismatch: "The password confirmation does not match.",
|
||||
enterPassword: "Enter your password",
|
||||
verifyNumber: "Verify your number",
|
||||
enterMobileDesc: "Enter your mobile number to continue",
|
||||
signInDesc: "Sign in using your account password",
|
||||
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
||||
sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`,
|
||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||
continueWithPassword: "Continue with Password",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
@@ -41,7 +74,7 @@ export const en = {
|
||||
passwordPlaceholder: "Password",
|
||||
signIn: "Sign In",
|
||||
back: "Back",
|
||||
otpPlaceholder: "6-digit code",
|
||||
otpPlaceholder: "5-digit code",
|
||||
verifyAndContinue: "Verify & Continue",
|
||||
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
|
||||
brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.",
|
||||
@@ -51,9 +84,13 @@ export const en = {
|
||||
failedOtp: "Failed to send OTP",
|
||||
fillAll: "Please fill all fields",
|
||||
successLogin: "Successfully logged in!",
|
||||
accountCreated: "Account created successfully!",
|
||||
failedSignup: "Failed to complete sign up",
|
||||
invalidCreds: "Invalid credentials",
|
||||
enterOtp: "Please enter the OTP code",
|
||||
invalidOtp: "Invalid OTP code"
|
||||
invalidOtp: "Invalid OTP code",
|
||||
passwordResetSuccess: "Password reset successfully.",
|
||||
passwordResetFailed: "Failed to reset password.",
|
||||
},
|
||||
throttle: {
|
||||
title: "Too many attempts",
|
||||
@@ -159,6 +196,20 @@ export const en = {
|
||||
upload: "Upload",
|
||||
remove: "Remove",
|
||||
imageInput: "Click to select or drag & drop",
|
||||
password: {
|
||||
trigger: "Change password",
|
||||
title: "Change password",
|
||||
description: "Enter your current password and choose a new one.",
|
||||
currentPassword: "Current password",
|
||||
newPassword: "New password",
|
||||
confirmPassword: "Confirm new password",
|
||||
submit: "Save password",
|
||||
saving: "Saving...",
|
||||
toasts: {
|
||||
success: "Password changed successfully.",
|
||||
error: "Failed to change password.",
|
||||
},
|
||||
},
|
||||
toasts: {
|
||||
successEdit: "Profile updated successfully!",
|
||||
successImage: "Profile picture updated!",
|
||||
|
||||
@@ -26,12 +26,45 @@ export const fa = {
|
||||
},
|
||||
|
||||
login: {
|
||||
loginTitle: "ورود به حساب",
|
||||
loginDescription: "شماره موبایل خود را وارد کنید تا کد یکبار مصرف برای شما ارسال شود.",
|
||||
loginCta: "ورود",
|
||||
createAccount: "ایجاد حساب",
|
||||
haveNoAccount: "حساب ندارید؟",
|
||||
haveAccount: "قبلا حساب دارید؟",
|
||||
loginOtpTitle: "کد ورود را تایید کنید",
|
||||
passwordLoginTitle: "ورود با رمز عبور",
|
||||
passwordLoginDescription: (mobile: string) => `رمز عبور مربوط به ${mobile} را وارد کنید`,
|
||||
usePasswordInstead: "استفاده از رمز عبور",
|
||||
useOtpInstead: "استفاده از کد یکبار مصرف",
|
||||
backToMobile: "بازگشت به مرحله موبایل",
|
||||
backToPasswordLogin: "بازگشت به ورود با رمز عبور",
|
||||
forgotPassword: "رمز عبور را فراموش کردهاید؟",
|
||||
signupTitle: "ساخت حساب جدید",
|
||||
signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.",
|
||||
sendSignupCode: "ارسال کد تایید",
|
||||
signupVerifyTitle: "تایید شماره موبایل",
|
||||
continueToPassword: "ادامه به مرحله رمز عبور",
|
||||
signupPasswordTitle: "تعیین رمز عبور",
|
||||
signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.",
|
||||
createAccountPasswordCta: "ایجاد حساب",
|
||||
forgotPasswordTitle: "بازیابی رمز عبور",
|
||||
forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.",
|
||||
sendResetCode: "ارسال کد بازیابی",
|
||||
forgotPasswordVerifyTitle: "کد بازیابی را وارد کنید",
|
||||
continueToResetPassword: "ادامه برای تعیین رمز جدید",
|
||||
resetPasswordTitle: "انتخاب رمز عبور جدید",
|
||||
resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.",
|
||||
resetPasswordCta: "تغییر رمز عبور",
|
||||
newPasswordPlaceholder: "رمز عبور جدید",
|
||||
confirmPasswordPlaceholder: "تکرار رمز عبور",
|
||||
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
|
||||
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
||||
enterPassword: "رمز عبور خود را وارد کنید",
|
||||
verifyNumber: "تایید شماره موبایل",
|
||||
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
||||
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
|
||||
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
||||
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
|
||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||
continueWithPassword: "ادامه با رمز عبور",
|
||||
continueWithGoogle: "ادامه با گوگل",
|
||||
@@ -41,19 +74,23 @@ export const fa = {
|
||||
passwordPlaceholder: "رمز عبور",
|
||||
signIn: "ورود",
|
||||
back: "بازگشت",
|
||||
otpPlaceholder: "کد ۶ رقمی",
|
||||
otpPlaceholder: "کد ۵ رقمی",
|
||||
verifyAndContinue: "تایید و ادامه",
|
||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||
toasts: {
|
||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||
verifySent: "کد تایید ارسال شد!",
|
||||
failedOtp: "ارسال کد تایید با خطا مواجه شد",
|
||||
fillAll: "لطفا تمام فیلدها را پر کنید",
|
||||
successLogin: "با موفقیت وارد شدید!",
|
||||
invalidCreds: "اطلاعات ورود نامعتبر است",
|
||||
enterOtp: "لطفا کد تایید را وارد کنید",
|
||||
invalidOtp: "کد تایید نامعتبر است"
|
||||
verifySent: "کد تایید ارسال شد.",
|
||||
failedOtp: "ارسال کد تایید انجام نشد.",
|
||||
fillAll: "لطفا تمام فیلدها را پر کنید.",
|
||||
successLogin: "با موفقیت وارد شدید.",
|
||||
accountCreated: "حساب با موفقیت ایجاد شد.",
|
||||
failedSignup: "تکمیل ثبت نام انجام نشد.",
|
||||
invalidCreds: "اطلاعات ورود نامعتبر است.",
|
||||
enterOtp: "لطفا کد تایید را وارد کنید.",
|
||||
invalidOtp: "کد تایید نامعتبر است.",
|
||||
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
|
||||
passwordResetFailed: "تغییر رمز عبور انجام نشد."
|
||||
},
|
||||
throttle: {
|
||||
title: "تعداد تلاشها بیش از حد مجاز است",
|
||||
@@ -160,11 +197,25 @@ export const fa = {
|
||||
remove: "حذف",
|
||||
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
|
||||
noEmail: "ایمیلی ثبت نشده",
|
||||
password: {
|
||||
trigger: "تغییر رمز عبور",
|
||||
title: "تغییر رمز عبور",
|
||||
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
|
||||
currentPassword: "رمز عبور فعلی",
|
||||
newPassword: "رمز عبور جدید",
|
||||
confirmPassword: "تکرار رمز جدید",
|
||||
submit: "ذخیره رمز عبور",
|
||||
saving: "در حال ذخیره...",
|
||||
toasts: {
|
||||
success: "رمز عبور با موفقیت تغییر کرد.",
|
||||
error: "تغییر رمز عبور انجام نشد.",
|
||||
},
|
||||
},
|
||||
toasts: {
|
||||
successEdit: "پروفایل با موفقیت بروزرسانی شد!",
|
||||
successImage: "عکس پروفایل بروزرسانی شد!",
|
||||
successRemoveImage: "عکس پروفایل حذف شد!",
|
||||
error: "خطایی رخ داد!"
|
||||
successEdit: "پروفایل با موفقیت بهروزرسانی شد.",
|
||||
successImage: "عکس پروفایل بهروزرسانی شد.",
|
||||
successRemoveImage: "عکس پروفایل حذف شد.",
|
||||
error: "خطایی رخ داد."
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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