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 RateLimitPage from "./pages/RateLimit"
|
||||||
import Landing from "./pages/Landing"
|
import Landing from "./pages/Landing"
|
||||||
import { isRateLimitActive } from "./lib/rateLimit"
|
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 MainLayout = () => {
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
@@ -81,8 +91,27 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ path: "/", element: <Landing /> },
|
{ path: "/", element: <Landing /> },
|
||||||
{ path: "/app", element: <AppRedirect /> },
|
{ path: "/app", element: <AppRedirect /> },
|
||||||
{ path: "/auth", element: <Auth /> },
|
|
||||||
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
|
{ 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: "/terms", element: <Terms /> },
|
||||||
{ path: "/rate-limit", element: <RateLimitPage /> },
|
{ path: "/rate-limit", element: <RateLimitPage /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,49 @@ export const loginWithOtp = async (mobile: string, otp: string) => {
|
|||||||
return response.json();
|
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 = () => {
|
export const startGoogleLogin = () => {
|
||||||
window.location.assign(buildApiUrl("/api/users/oauth/google/start/"));
|
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: {
|
login: {
|
||||||
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
|
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",
|
enterPassword: "Enter your password",
|
||||||
verifyNumber: "Verify your number",
|
verifyNumber: "Verify your number",
|
||||||
enterMobileDesc: "Enter your mobile number to continue",
|
enterMobileDesc: "Enter your mobile number to continue",
|
||||||
signInDesc: "Sign in using your account password",
|
signInDesc: "Sign in using your account password",
|
||||||
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
|
sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`,
|
||||||
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
|
||||||
continueWithPassword: "Continue with Password",
|
continueWithPassword: "Continue with Password",
|
||||||
continueWithGoogle: "Continue with Google",
|
continueWithGoogle: "Continue with Google",
|
||||||
@@ -41,7 +74,7 @@ export const en = {
|
|||||||
passwordPlaceholder: "Password",
|
passwordPlaceholder: "Password",
|
||||||
signIn: "Sign In",
|
signIn: "Sign In",
|
||||||
back: "Back",
|
back: "Back",
|
||||||
otpPlaceholder: "6-digit code",
|
otpPlaceholder: "5-digit code",
|
||||||
verifyAndContinue: "Verify & Continue",
|
verifyAndContinue: "Verify & Continue",
|
||||||
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
|
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.",
|
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",
|
failedOtp: "Failed to send OTP",
|
||||||
fillAll: "Please fill all fields",
|
fillAll: "Please fill all fields",
|
||||||
successLogin: "Successfully logged in!",
|
successLogin: "Successfully logged in!",
|
||||||
|
accountCreated: "Account created successfully!",
|
||||||
|
failedSignup: "Failed to complete sign up",
|
||||||
invalidCreds: "Invalid credentials",
|
invalidCreds: "Invalid credentials",
|
||||||
enterOtp: "Please enter the OTP code",
|
enterOtp: "Please enter the OTP code",
|
||||||
invalidOtp: "Invalid OTP code"
|
invalidOtp: "Invalid OTP code",
|
||||||
|
passwordResetSuccess: "Password reset successfully.",
|
||||||
|
passwordResetFailed: "Failed to reset password.",
|
||||||
},
|
},
|
||||||
throttle: {
|
throttle: {
|
||||||
title: "Too many attempts",
|
title: "Too many attempts",
|
||||||
@@ -159,6 +196,20 @@ export const en = {
|
|||||||
upload: "Upload",
|
upload: "Upload",
|
||||||
remove: "Remove",
|
remove: "Remove",
|
||||||
imageInput: "Click to select or drag & drop",
|
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: {
|
toasts: {
|
||||||
successEdit: "Profile updated successfully!",
|
successEdit: "Profile updated successfully!",
|
||||||
successImage: "Profile picture updated!",
|
successImage: "Profile picture updated!",
|
||||||
|
|||||||
@@ -26,12 +26,45 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
login: {
|
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} خوش آمدید`,
|
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
|
||||||
enterPassword: "رمز عبور خود را وارد کنید",
|
enterPassword: "رمز عبور خود را وارد کنید",
|
||||||
verifyNumber: "تایید شماره موبایل",
|
verifyNumber: "تایید شماره موبایل",
|
||||||
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
|
||||||
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
|
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
|
||||||
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
|
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
|
||||||
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
|
||||||
continueWithPassword: "ادامه با رمز عبور",
|
continueWithPassword: "ادامه با رمز عبور",
|
||||||
continueWithGoogle: "ادامه با گوگل",
|
continueWithGoogle: "ادامه با گوگل",
|
||||||
@@ -41,19 +74,23 @@ export const fa = {
|
|||||||
passwordPlaceholder: "رمز عبور",
|
passwordPlaceholder: "رمز عبور",
|
||||||
signIn: "ورود",
|
signIn: "ورود",
|
||||||
back: "بازگشت",
|
back: "بازگشت",
|
||||||
otpPlaceholder: "کد ۶ رقمی",
|
otpPlaceholder: "کد ۵ رقمی",
|
||||||
verifyAndContinue: "تایید و ادامه",
|
verifyAndContinue: "تایید و ادامه",
|
||||||
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت میکنید.",
|
||||||
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
brandingQuote: "زمان و ورکاسپیسها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
|
||||||
toasts: {
|
toasts: {
|
||||||
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
|
||||||
verifySent: "کد تایید ارسال شد!",
|
verifySent: "کد تایید ارسال شد.",
|
||||||
failedOtp: "ارسال کد تایید با خطا مواجه شد",
|
failedOtp: "ارسال کد تایید انجام نشد.",
|
||||||
fillAll: "لطفا تمام فیلدها را پر کنید",
|
fillAll: "لطفا تمام فیلدها را پر کنید.",
|
||||||
successLogin: "با موفقیت وارد شدید!",
|
successLogin: "با موفقیت وارد شدید.",
|
||||||
invalidCreds: "اطلاعات ورود نامعتبر است",
|
accountCreated: "حساب با موفقیت ایجاد شد.",
|
||||||
enterOtp: "لطفا کد تایید را وارد کنید",
|
failedSignup: "تکمیل ثبت نام انجام نشد.",
|
||||||
invalidOtp: "کد تایید نامعتبر است"
|
invalidCreds: "اطلاعات ورود نامعتبر است.",
|
||||||
|
enterOtp: "لطفا کد تایید را وارد کنید.",
|
||||||
|
invalidOtp: "کد تایید نامعتبر است.",
|
||||||
|
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
|
||||||
|
passwordResetFailed: "تغییر رمز عبور انجام نشد."
|
||||||
},
|
},
|
||||||
throttle: {
|
throttle: {
|
||||||
title: "تعداد تلاشها بیش از حد مجاز است",
|
title: "تعداد تلاشها بیش از حد مجاز است",
|
||||||
@@ -160,11 +197,25 @@ export const fa = {
|
|||||||
remove: "حذف",
|
remove: "حذف",
|
||||||
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
|
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
|
||||||
noEmail: "ایمیلی ثبت نشده",
|
noEmail: "ایمیلی ثبت نشده",
|
||||||
|
password: {
|
||||||
|
trigger: "تغییر رمز عبور",
|
||||||
|
title: "تغییر رمز عبور",
|
||||||
|
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
|
||||||
|
currentPassword: "رمز عبور فعلی",
|
||||||
|
newPassword: "رمز عبور جدید",
|
||||||
|
confirmPassword: "تکرار رمز جدید",
|
||||||
|
submit: "ذخیره رمز عبور",
|
||||||
|
saving: "در حال ذخیره...",
|
||||||
|
toasts: {
|
||||||
|
success: "رمز عبور با موفقیت تغییر کرد.",
|
||||||
|
error: "تغییر رمز عبور انجام نشد.",
|
||||||
|
},
|
||||||
|
},
|
||||||
toasts: {
|
toasts: {
|
||||||
successEdit: "پروفایل با موفقیت بروزرسانی شد!",
|
successEdit: "پروفایل با موفقیت بهروزرسانی شد.",
|
||||||
successImage: "عکس پروفایل بروزرسانی شد!",
|
successImage: "عکس پروفایل بهروزرسانی شد.",
|
||||||
successRemoveImage: "عکس پروفایل حذف شد!",
|
successRemoveImage: "عکس پروفایل حذف شد.",
|
||||||
error: "خطایی رخ داد!"
|
error: "خطایی رخ داد."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,250 +1,24 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react"
|
import { Outlet } from "react-router-dom"
|
||||||
import { useNavigate, Link } from "react-router-dom"
|
import { Command } from "lucide-react"
|
||||||
import { Button } from "../components/ui/button"
|
|
||||||
import { Input } from "../components/ui/input"
|
|
||||||
import { SettingsMenu } from "../components/SettingsMenu"
|
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 { 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() {
|
export default function Auth() {
|
||||||
const navigate = useNavigate()
|
const { t } = useTranslation()
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
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">
|
<div className="absolute inset-e-4 top-4 z-50 md:inset-e-8 md:top-8">
|
||||||
<SettingsMenu />
|
<SettingsMenu />
|
||||||
</div>
|
</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 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 text-lg font-medium gap-2">
|
<div className="relative z-20 flex items-center gap-2 text-lg font-medium">
|
||||||
<Command className="h-6 w-6" />
|
<Command className="h-6 w-6" />
|
||||||
{t.title || "Qlockify"}
|
{t.title || "Qlockify"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
<p className="text-lg">"{t.login.brandingQuote}"</p>
|
<p className="text-lg">"{t.login.brandingQuote}"</p>
|
||||||
@@ -252,176 +26,9 @@ export default function Auth() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 lg:p-8 flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center p-8 lg:p-8">
|
||||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-87.5">
|
<div className="mx-auto flex w-full max-w-[26rem] flex-col justify-center">
|
||||||
<div className="flex flex-col space-y-2 text-center text-slate-900 dark:text-slate-50">
|
<Outlet />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
getUserProfile,
|
getUserProfile,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
updateProfilePicture,
|
updateProfilePicture,
|
||||||
removeProfilePicture
|
removeProfilePicture,
|
||||||
|
changePassword,
|
||||||
} from "../api/users"
|
} from "../api/users"
|
||||||
import { Button } from "../components/ui/button"
|
import { Button } from "../components/ui/button"
|
||||||
import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react"
|
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 { Modal } from "../components/Modal"
|
||||||
import { Input } from "../components/ui/input"
|
import { Input } from "../components/ui/input"
|
||||||
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
import { TextAreaInput } from "../components/ui/TextAreaInput"
|
||||||
|
import { AuthPasswordField } from "./auth/AuthPasswordField"
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -36,6 +38,7 @@ export default function Profile() {
|
|||||||
|
|
||||||
const { t, lang } = useTranslation()
|
const { t, lang } = useTranslation()
|
||||||
const isFa = lang === 'fa'
|
const isFa = lang === 'fa'
|
||||||
|
const passwordCopy = t.profile.password
|
||||||
|
|
||||||
const toPersianNum = (num: string | number | undefined | null) => {
|
const toPersianNum = (num: string | number | undefined | null) => {
|
||||||
if (num === null || num === undefined) return num
|
if (num === null || num === undefined) return num
|
||||||
@@ -59,6 +62,7 @@ export default function Profile() {
|
|||||||
// Modals & Editing state
|
// Modals & Editing state
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [isPicModalOpen, setIsPicModalOpen] = useState(false)
|
const [isPicModalOpen, setIsPicModalOpen] = useState(false)
|
||||||
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
@@ -66,6 +70,11 @@ export default function Profile() {
|
|||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
const [dragActive, setDragActive] = useState(false)
|
const [dragActive, setDragActive] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
|
currentPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: "",
|
||||||
|
})
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
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
|
// Drag & Drop Handlers
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -233,10 +280,14 @@ export default function Profile() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<Button onClick={handleEditClick} className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Edit2 className="h-4 w-4" />
|
<Button variant="outline" onClick={() => setIsPasswordModalOpen(true)}>
|
||||||
{t.profile?.editInfo || 'Edit Profile'}
|
{passwordCopy.trigger}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={handleEditClick}>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -446,6 +497,62 @@ export default function Profile() {
|
|||||||
</Modal>
|
</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>
|
</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