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

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

View File

@@ -27,6 +27,16 @@ import NotificationsPage from "./pages/Notifications"
import RateLimitPage from "./pages/RateLimit"
import Landing from "./pages/Landing"
import { isRateLimitActive } from "./lib/rateLimit"
import { AuthFlowProvider } from "./context/AuthFlowContext"
import { LoginMobilePage } from "./pages/auth/LoginMobilePage"
import { LoginOtpPage } from "./pages/auth/LoginOtpPage"
import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage"
import { SignupMobilePage } from "./pages/auth/SignupMobilePage"
import { SignupOtpPage } from "./pages/auth/SignupOtpPage"
import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage"
import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage"
import { ForgotPasswordOtpPage } from "./pages/auth/ForgotPasswordOtpPage"
import { ForgotPasswordPasswordPage } from "./pages/auth/ForgotPasswordPasswordPage"
const MainLayout = () => {
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
@@ -81,8 +91,27 @@ const router = createBrowserRouter([
children: [
{ path: "/", element: <Landing /> },
{ path: "/app", element: <AppRedirect /> },
{ path: "/auth", element: <Auth /> },
{ path: "/auth/google/callback", element: <GoogleAuthCallback /> },
{
path: "/auth",
element: (
<AuthFlowProvider>
<Auth />
</AuthFlowProvider>
),
children: [
{ index: true, element: <Navigate to="/auth/login" replace /> },
{ path: "login", element: <LoginMobilePage /> },
{ path: "login/verify", element: <LoginOtpPage /> },
{ path: "login/password", element: <LoginPasswordPage /> },
{ path: "signup", element: <SignupMobilePage /> },
{ path: "signup/verify", element: <SignupOtpPage /> },
{ path: "signup/password", element: <SignupPasswordPage /> },
{ path: "forgot-password", element: <ForgotPasswordMobilePage /> },
{ path: "forgot-password/verify", element: <ForgotPasswordOtpPage /> },
{ path: "forgot-password/password", element: <ForgotPasswordPasswordPage /> },
],
},
{ path: "/terms", element: <Terms /> },
{ path: "/rate-limit", element: <RateLimitPage /> },
{

View File

@@ -29,6 +29,49 @@ export const loginWithOtp = async (mobile: string, otp: string) => {
return response.json();
};
export const registerWithOtp = async (
mobile: string,
code: string,
password: string,
re_password: string,
first_name = "",
last_name = "",
) => {
const response = await authFetch("/api/users/register/", {
method: "POST",
body: JSON.stringify({ mobile, code, password, re_password, first_name, last_name }),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const resetPasswordWithOtp = async (
mobile: string,
code: string,
password: string,
re_password: string,
) => {
const response = await authFetch("/api/users/password/reset/", {
method: "POST",
body: JSON.stringify({ mobile, code, password, re_password }),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const changePassword = async (
old_password: string,
new_password: string,
re_password: string,
) => {
const response = await authFetch("/api/users/password/change/", {
method: "PATCH",
body: JSON.stringify({ old_password, new_password, re_password }),
})
if (!response.ok) throw await buildApiError(response)
return response.json()
}
export const startGoogleLogin = () => {
window.location.assign(buildApiUrl("/api/users/oauth/google/start/"));
};

View 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
}

View File

@@ -6,11 +6,11 @@ export const en = {
confirmLogoutMessage: "Are you sure you want to log out of your account?",
confirmLeave: "You have unsaved changes. Are you sure you want to leave?",
cancel: "Cancel",
save: "Save",
lightMode: "Light Mode",
darkMode: "Dark Mode",
settings: "Settings",
loadingText: "Loading...",
save: "Save",
lightMode: "Light Mode",
darkMode: "Dark Mode",
settings: "Settings",
loadingText: "Loading...",
loading: "Loading...",
add: "Add",
create: "Create",
@@ -27,21 +27,54 @@ export const en = {
login: {
welcome: (title: string = "Qlockifiy") => `Welcome to ${title}`,
loginTitle: "Sign in to your account",
loginDescription: "Enter your mobile number and we will send a one-time code.",
loginCta: "Login",
createAccount: "Create account",
haveNoAccount: "Need an account?",
haveAccount: "Already have an account?",
loginOtpTitle: "Verify your login code",
passwordLoginTitle: "Login with password",
passwordLoginDescription: (mobile: string) => `Enter the password for ${mobile}`,
usePasswordInstead: "Use password instead",
useOtpInstead: "Use OTP instead",
backToMobile: "Back to mobile step",
backToPasswordLogin: "Back to password login",
forgotPassword: "Forgot password?",
signupTitle: "Create your account",
signupDescription: "Start with your mobile number to receive a verification code.",
sendSignupCode: "Send verification code",
signupVerifyTitle: "Verify your mobile number",
continueToPassword: "Continue to password",
signupPasswordTitle: "Set your password",
signupPasswordDescription: "Choose a password for your new account.",
createAccountPasswordCta: "Create account",
forgotPasswordTitle: "Recover your password",
forgotPasswordDescription: "Enter your mobile number and we will send a verification code for password reset.",
sendResetCode: "Send reset code",
forgotPasswordVerifyTitle: "Enter your reset code",
continueToResetPassword: "Continue to reset password",
resetPasswordTitle: "Choose a new password",
resetPasswordDescription: "Set a new password for your account and confirm it.",
resetPasswordCta: "Reset password",
newPasswordPlaceholder: "New password",
confirmPasswordPlaceholder: "Confirm password",
passwordMismatch: "The password confirmation does not match.",
enterPassword: "Enter your password",
verifyNumber: "Verify your number",
enterMobileDesc: "Enter your mobile number to continue",
signInDesc: "Sign in using your account password",
sentCodeDesc: (mobile: string) => `We sent a 6-digit code to ${mobile}`,
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
continueWithPassword: "Continue with Password",
continueWithGoogle: "Continue with Google",
orContinueWith: "Or continue with",
sentCodeDesc: (mobile: string) => `We sent a 5-digit code to ${mobile}`,
mobilePlaceholder: "Mobile Number (e.g. 09123456789)",
continueWithPassword: "Continue with Password",
continueWithGoogle: "Continue with Google",
orContinueWith: "Or continue with",
otpLogin: "OTP Login",
register: "Register",
passwordPlaceholder: "Password",
signIn: "Sign In",
back: "Back",
otpPlaceholder: "6-digit code",
otpPlaceholder: "5-digit code",
verifyAndContinue: "Verify & Continue",
terms: "By clicking continue, you agree to our Terms of Service and Privacy Policy.",
brandingQuote: "Manage your time and workspaces efficiently with our minimal, fast, and secure platform.",
@@ -51,40 +84,44 @@ export const en = {
failedOtp: "Failed to send OTP",
fillAll: "Please fill all fields",
successLogin: "Successfully logged in!",
accountCreated: "Account created successfully!",
failedSignup: "Failed to complete sign up",
invalidCreds: "Invalid credentials",
enterOtp: "Please enter the OTP code",
invalidOtp: "Invalid OTP code"
invalidOtp: "Invalid OTP code",
passwordResetSuccess: "Password reset successfully.",
passwordResetFailed: "Failed to reset password.",
},
throttle: {
title: "Too many attempts",
throttle: {
title: "Too many attempts",
genericMessage: (time: string) => `Too many requests. Try again in ${time}.`,
otpSendMessage: (time: string) => `Too many OTP requests. Try again in ${time}.`,
passwordLoginMessage: (time: string) => `Too many password login attempts. Try again in ${time}.`,
otpLoginMessage: (time: string) => `Too many OTP login attempts. Try again in ${time}.`,
countdownLabel: (time: string) => `Retry in ${time}`,
fallback: "Too many requests. Please wait and try again.",
},
google: {
loadingTitle: "Completing Google sign in",
loadingDescription: "We are validating your Google account and preparing the next step.",
collectMobileTitle: "Finish your account setup",
collectMobileDescription: (email: string) =>
`Google verified ${email}. Enter your mobile number to finish creating your account.`,
claimTitle: "Verify your existing account",
claimDescription: (mobile: string) =>
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
errorTitle: "Google sign in could not be completed",
missingFlow: "The Google sign-in flow is missing or has expired.",
loadFailed: "We could not load your Google sign-in state.",
completeFailed: "We could not finish your Google account setup.",
claimOtpSent: "Verification code sent successfully.",
googleAccount: "Google account",
completeButton: "Continue and create account",
verifyClaimButton: "Verify and continue",
resendClaimOtp: "Resend verification code",
restartGoogle: "Start Google sign in again",
},
},
fallback: "Too many requests. Please wait and try again.",
},
google: {
loadingTitle: "Completing Google sign in",
loadingDescription: "We are validating your Google account and preparing the next step.",
collectMobileTitle: "Finish your account setup",
collectMobileDescription: (email: string) =>
`Google verified ${email}. Enter your mobile number to finish creating your account.`,
claimTitle: "Verify your existing account",
claimDescription: (mobile: string) =>
`An account with ${mobile} already exists. Enter the verification code sent to that number to attach Google.`,
errorTitle: "Google sign in could not be completed",
missingFlow: "The Google sign-in flow is missing or has expired.",
loadFailed: "We could not load your Google sign-in state.",
completeFailed: "We could not finish your Google account setup.",
claimOtpSent: "Verification code sent successfully.",
googleAccount: "Google account",
completeButton: "Continue and create account",
verifyClaimButton: "Verify and continue",
resendClaimOtp: "Resend verification code",
restartGoogle: "Start Google sign in again",
},
},
loginTerms: {
prefix: "By logging in, you agree to our ",
@@ -159,6 +196,20 @@ export const en = {
upload: "Upload",
remove: "Remove",
imageInput: "Click to select or drag & drop",
password: {
trigger: "Change password",
title: "Change password",
description: "Enter your current password and choose a new one.",
currentPassword: "Current password",
newPassword: "New password",
confirmPassword: "Confirm new password",
submit: "Save password",
saving: "Saving...",
toasts: {
success: "Password changed successfully.",
error: "Failed to change password.",
},
},
toasts: {
successEdit: "Profile updated successfully!",
successImage: "Profile picture updated!",
@@ -313,108 +364,108 @@ export const en = {
next: "Next",
},
sidebar: {
timesheet: "Timesheet",
reports: "Reports",
logs: "Logs",
sidebar: {
timesheet: "Timesheet",
reports: "Reports",
logs: "Logs",
workspaces: 'Workspaces',
clients: 'Clients',
projects: "Projects",
tags: "Tags",
expand: 'Expand',
collapse: 'Collapse',
},
landing: {
brandLabel: "Operating system for time",
eyebrow: "Built for high-discipline teams that need clean time intelligence",
nav: {
demo: "Product demo",
features: "Core capabilities",
workflow: "How it works",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "Sign in",
openApp: "Open app",
openWorkspace: "Open workspace",
startNow: "Start tracking with control",
watchDemo: "See the product demo",
readTerms: "Read terms",
},
hero: {
titleTop: "Turn every working hour into a reliable operating signal.",
titleAccent: "Qlockify makes time visible, accountable, and billable.",
description:
"A focused workspace for modern teams that need fast time capture, trustworthy project tracking, structured reports, and a log trail that management can actually use.",
},
metrics: {
capture: "cleaner billable capture",
visibility: "faster reporting visibility",
decision: "from raw entries to management context",
},
trust: {
first: "Precise timers with manual control when needed",
second: "Workspace permissions, logs, and rate-aware reporting",
third: "Built for agencies, consultancies, product teams, and operators",
},
capabilities: {
time: {
title: "Capture work without friction",
description:
"Start a timer, adjust historical entries, and keep project and tag context attached to every hour without slowing the team down.",
},
reports: {
title: "Read the business in minutes",
description:
"See daily output, billable performance, project distribution, and exportable report packs without spreadsheet cleanup.",
},
control: {
title: "Keep operations explainable",
description:
"Track who changed what, keep workspace roles explicit, and give management a cleaner operational trail than ad hoc chat or manual files.",
},
},
demo: {
timerTag: "Live timer",
timerTitle: "Current execution window",
timerText: "Design system refinement synced to the correct project, tags, and billable rate.",
panelLabel: "Interactive product preview",
panelTitle: "One surface for tracking, reporting, and operational clarity",
runningCard: "Active entry",
currentTask: "Enterprise landing page rollout",
currentTaskMeta: "Project: Qlockify Marketing · Tags: Design, Review, Delivery",
billableLabel: "Live billable rate",
reportCard: "Daily report trend",
opsCard: "Operational health",
opsLabels: ["Coverage", "Team focus", "Billing readiness"],
logCard: "Recent workspace activity",
logItems: [
{ title: "Rate updated for product design", meta: "Owner action · 3 minutes ago" },
{ title: "Client-facing project moved to archived", meta: "Admin action · 18 minutes ago" },
{ title: "Historic tag preserved on edited entry", meta: "Member action · 41 minutes ago" },
],
outcomeTag: "Management result",
outcomeText: "Less ambiguity at month end, fewer missing billable hours, and faster operational reviews.",
},
workflowTag: "Operational workflow",
workflowTitle: "A tighter loop from raw effort to usable management data.",
workflowDescription:
"Qlockify is designed to keep the path short: capture accurately, structure context once, and reuse the result everywhere from timesheets to reports to workspace-level decisions.",
workflow: {
capture: "Capture time at the source with project, tags, and billing context attached immediately.",
structure: "Keep every workspace action, membership change, and rate update visible and reviewable.",
improve: "Review daily and monthly performance with reports that are ready to export or act on.",
},
finalCtaTag: "Ready for production teams",
finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.",
finalCtaDescription:
"Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.",
},
ordering: {
createdAtDesc: "Newest First",
collapse: 'Collapse',
},
landing: {
brandLabel: "Operating system for time",
eyebrow: "Built for high-discipline teams that need clean time intelligence",
nav: {
demo: "Product demo",
features: "Core capabilities",
workflow: "How it works",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "Sign in",
openApp: "Open app",
openWorkspace: "Open workspace",
startNow: "Start tracking with control",
watchDemo: "See the product demo",
readTerms: "Read terms",
},
hero: {
titleTop: "Turn every working hour into a reliable operating signal.",
titleAccent: "Qlockify makes time visible, accountable, and billable.",
description:
"A focused workspace for modern teams that need fast time capture, trustworthy project tracking, structured reports, and a log trail that management can actually use.",
},
metrics: {
capture: "cleaner billable capture",
visibility: "faster reporting visibility",
decision: "from raw entries to management context",
},
trust: {
first: "Precise timers with manual control when needed",
second: "Workspace permissions, logs, and rate-aware reporting",
third: "Built for agencies, consultancies, product teams, and operators",
},
capabilities: {
time: {
title: "Capture work without friction",
description:
"Start a timer, adjust historical entries, and keep project and tag context attached to every hour without slowing the team down.",
},
reports: {
title: "Read the business in minutes",
description:
"See daily output, billable performance, project distribution, and exportable report packs without spreadsheet cleanup.",
},
control: {
title: "Keep operations explainable",
description:
"Track who changed what, keep workspace roles explicit, and give management a cleaner operational trail than ad hoc chat or manual files.",
},
},
demo: {
timerTag: "Live timer",
timerTitle: "Current execution window",
timerText: "Design system refinement synced to the correct project, tags, and billable rate.",
panelLabel: "Interactive product preview",
panelTitle: "One surface for tracking, reporting, and operational clarity",
runningCard: "Active entry",
currentTask: "Enterprise landing page rollout",
currentTaskMeta: "Project: Qlockify Marketing · Tags: Design, Review, Delivery",
billableLabel: "Live billable rate",
reportCard: "Daily report trend",
opsCard: "Operational health",
opsLabels: ["Coverage", "Team focus", "Billing readiness"],
logCard: "Recent workspace activity",
logItems: [
{ title: "Rate updated for product design", meta: "Owner action · 3 minutes ago" },
{ title: "Client-facing project moved to archived", meta: "Admin action · 18 minutes ago" },
{ title: "Historic tag preserved on edited entry", meta: "Member action · 41 minutes ago" },
],
outcomeTag: "Management result",
outcomeText: "Less ambiguity at month end, fewer missing billable hours, and faster operational reviews.",
},
workflowTag: "Operational workflow",
workflowTitle: "A tighter loop from raw effort to usable management data.",
workflowDescription:
"Qlockify is designed to keep the path short: capture accurately, structure context once, and reuse the result everywhere from timesheets to reports to workspace-level decisions.",
workflow: {
capture: "Capture time at the source with project, tags, and billing context attached immediately.",
structure: "Keep every workspace action, membership change, and rate update visible and reviewable.",
improve: "Review daily and monthly performance with reports that are ready to export or act on.",
},
finalCtaTag: "Ready for production teams",
finalCtaTitle: "If your team sells expertise or ships client work, your time system should look this serious.",
finalCtaDescription:
"Open the app, create a workspace, and see how fast your reporting discipline improves when the product stops leaking context.",
},
ordering: {
createdAtDesc: "Newest First",
createdAt: "Olders First",
updatedAtDesc: "Recently Updated",
name: "Name (A-Z)",

View File

@@ -11,8 +11,8 @@ export const fa = {
save: "ذخیره",
remove: "حذف",
lightMode: "حالت روشن",
darkMode: "حالت تاریک",
settings: "تنظیمات",
darkMode: "حالت تاریک",
settings: "تنظیمات",
loadingText: "در حال بارگذاری...",
loading: "در حال بارگذاری...",
noMoreResults: "نتیجه دیگری نیست.",
@@ -26,65 +26,102 @@ export const fa = {
},
login: {
loginTitle: "ورود به حساب",
loginDescription: "شماره موبایل خود را وارد کنید تا کد یکبار مصرف برای شما ارسال شود.",
loginCta: "ورود",
createAccount: "ایجاد حساب",
haveNoAccount: "حساب ندارید؟",
haveAccount: "قبلا حساب دارید؟",
loginOtpTitle: "کد ورود را تایید کنید",
passwordLoginTitle: "ورود با رمز عبور",
passwordLoginDescription: (mobile: string) => `رمز عبور مربوط به ${mobile} را وارد کنید`,
usePasswordInstead: "استفاده از رمز عبور",
useOtpInstead: "استفاده از کد یکبار مصرف",
backToMobile: "بازگشت به مرحله موبایل",
backToPasswordLogin: "بازگشت به ورود با رمز عبور",
forgotPassword: "رمز عبور را فراموش کرده‌اید؟",
signupTitle: "ساخت حساب جدید",
signupDescription: "با شماره موبایل شروع کنید تا کد تایید برای شما ارسال شود.",
sendSignupCode: "ارسال کد تایید",
signupVerifyTitle: "تایید شماره موبایل",
continueToPassword: "ادامه به مرحله رمز عبور",
signupPasswordTitle: "تعیین رمز عبور",
signupPasswordDescription: "برای حساب جدید خود یک رمز عبور انتخاب کنید.",
createAccountPasswordCta: "ایجاد حساب",
forgotPasswordTitle: "بازیابی رمز عبور",
forgotPasswordDescription: "شماره موبایل خود را وارد کنید تا کد تایید برای تغییر رمز عبور ارسال شود.",
sendResetCode: "ارسال کد بازیابی",
forgotPasswordVerifyTitle: "کد بازیابی را وارد کنید",
continueToResetPassword: "ادامه برای تعیین رمز جدید",
resetPasswordTitle: "انتخاب رمز عبور جدید",
resetPasswordDescription: "رمز عبور جدید خود را وارد کنید و آن را تایید کنید.",
resetPasswordCta: "تغییر رمز عبور",
newPasswordPlaceholder: "رمز عبور جدید",
confirmPasswordPlaceholder: "تکرار رمز عبور",
passwordMismatch: "رمز عبور و تکرار آن یکسان نیستند.",
welcome: (title: string = "Qlockifiy") => `به ${title} خوش آمدید`,
enterPassword: "رمز عبور خود را وارد کنید",
verifyNumber: "تایید شماره موبایل",
enterMobileDesc: "برای ادامه، شماره موبایل خود را وارد کنید",
signInDesc: "با استفاده از رمز عبور خود وارد شوید",
sentCodeDesc: (mobile: string) => `کد ۶ رقمی به ${mobile} ارسال شد`,
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
continueWithPassword: "ادامه با رمز عبور",
continueWithGoogle: "ادامه با گوگل",
orContinueWith: "یا ادامه با",
sentCodeDesc: (mobile: string) => `کد تایید به ${mobile} ارسال شد`,
mobilePlaceholder: "شماره موبایل (مثلا ۰۹۱۲۳۴۵۶۷۸۹)",
continueWithPassword: "ادامه با رمز عبور",
continueWithGoogle: "ادامه با گوگل",
orContinueWith: "یا ادامه با",
otpLogin: "ورود با کد یکبار مصرف",
register: "ثبت نام",
passwordPlaceholder: "رمز عبور",
signIn: "ورود",
back: "بازگشت",
otpPlaceholder: "کد ۶ رقمی",
otpPlaceholder: "کد ۵ رقمی",
verifyAndContinue: "تایید و ادامه",
terms: "با کلیک روی ادامه، شما با قوانین و مقررات و حریم خصوصی ما موافقت می‌کنید.",
brandingQuote: "زمان و ورک‌اسپیس‌ها خود را با پلتفرم مینیمال، سریع و امن ما بهینه مدیریت کنید.",
toasts: {
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
verifySent: "کد تایید ارسال شد!",
failedOtp: "ارسال کد تایید با خطا مواجه شد",
fillAll: "لطفا تمام فیلدها را پر کنید",
successLogin: "با موفقیت وارد شدید!",
invalidCreds: "اطلاعات ورود نامعتبر است",
enterOtp: "لطفا کد تایید را وارد کنید",
invalidOtp: "کد تایید نامعتبر است"
},
throttle: {
title: "تعداد تلاش‌ها بیش از حد مجاز است",
toasts: {
enterMobile: "لطفا شماره موبایل خود را وارد کنید",
verifySent: "کد تایید ارسال شد.",
failedOtp: "ارسال کد تایید انجام نشد.",
fillAll: "لطفا تمام فیلدها را پر کنید.",
successLogin: "با موفقیت وارد شدید.",
accountCreated: "حساب با موفقیت ایجاد شد.",
failedSignup: "تکمیل ثبت نام انجام نشد.",
invalidCreds: "اطلاعات ورود نامعتبر است.",
enterOtp: "لطفا کد تایید را وارد کنید.",
invalidOtp: "کد تایید نامعتبر است.",
passwordResetSuccess: "رمز عبور با موفقیت تغییر کرد.",
passwordResetFailed: "تغییر رمز عبور انجام نشد."
},
throttle: {
title: "تعداد تلاش‌ها بیش از حد مجاز است",
genericMessage: (time: string) => `درخواست‌های زیادی ارسال شده است. ${time} دیگر دوباره تلاش کنید.`,
otpSendMessage: (time: string) => `ارسال کد یکبار مصرف بیش از حد مجاز انجام شده است. ${time} دیگر دوباره تلاش کنید.`,
passwordLoginMessage: (time: string) => `تلاش برای ورود با رمز عبور بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
otpLoginMessage: (time: string) => `تلاش برای ورود با کد یکبار مصرف بیش از حد مجاز بوده است. ${time} دیگر دوباره تلاش کنید.`,
countdownLabel: (time: string) => `تلاش دوباره تا ${time}`,
fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
},
google: {
loadingTitle: "در حال تکمیل ورود با گوگل",
loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.",
collectMobileTitle: "ساخت حساب را کامل کنید",
collectMobileDescription: (email: string) =>
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
claimTitle: "حساب موجود خود را تایید کنید",
claimDescription: (mobile: string) =>
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
errorTitle: "ورود با گوگل کامل نشد",
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
googleAccount: "حساب گوگل",
completeButton: "ادامه و ایجاد حساب",
verifyClaimButton: "تایید و ادامه",
resendClaimOtp: "ارسال دوباره کد تایید",
restartGoogle: "شروع دوباره ورود با گوگل",
}
},
fallback: "درخواست‌های زیادی ارسال شده است. کمی صبر کنید و دوباره تلاش کنید.",
},
google: {
loadingTitle: "در حال تکمیل ورود با گوگل",
loadingDescription: "در حال بررسی حساب گوگل شما و آماده‌سازی مرحله بعد هستیم.",
collectMobileTitle: "ساخت حساب را کامل کنید",
collectMobileDescription: (email: string) =>
`حساب گوگل ${email} تایید شد. برای تکمیل ساخت حساب، شماره موبایل خود را وارد کنید.`,
claimTitle: "حساب موجود خود را تایید کنید",
claimDescription: (mobile: string) =>
`حسابی با شماره ${mobile} از قبل وجود دارد. کد ارسال‌شده به این شماره را وارد کنید تا گوگل به همان حساب متصل شود.`,
errorTitle: "ورود با گوگل کامل نشد",
missingFlow: "جریان ورود با گوگل پیدا نشد یا منقضی شده است.",
loadFailed: "بارگذاری وضعیت ورود با گوگل انجام نشد.",
completeFailed: "تکمیل ساخت حساب با گوگل انجام نشد.",
claimOtpSent: "کد تایید با موفقیت ارسال شد.",
googleAccount: "حساب گوگل",
completeButton: "ادامه و ایجاد حساب",
verifyClaimButton: "تایید و ادامه",
resendClaimOtp: "ارسال دوباره کد تایید",
restartGoogle: "شروع دوباره ورود با گوگل",
}
},
loginTerms: {
prefix: "با ورود به سیستم، شما با ",
@@ -156,17 +193,31 @@ export const fa = {
changePicture: "تغییر تصویر",
save: "ذخیره",
cancel: "لغو",
upload: "آپلود",
remove: "حذف",
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
noEmail: "ایمیلی ثبت نشده",
toasts: {
successEdit: "پروفایل با موفقیت بروزرسانی شد!",
successImage: "عکس پروفایل بروزرسانی شد!",
successRemoveImage: "عکس پروفایل حذف شد!",
error: "خطایی رخ داد!"
}
},
upload: "آپلود",
remove: "حذف",
imageInput: "برای انتخاب کلیک کنید یا فایل را بکشید",
noEmail: "ایمیلی ثبت نشده",
password: {
trigger: "تغییر رمز عبور",
title: "تغییر رمز عبور",
description: "رمز عبور فعلی خود را وارد کنید و یک رمز جدید انتخاب کنید.",
currentPassword: "رمز عبور فعلی",
newPassword: "رمز عبور جدید",
confirmPassword: "تکرار رمز جدید",
submit: "ذخیره رمز عبور",
saving: "در حال ذخیره...",
toasts: {
success: "رمز عبور با موفقیت تغییر کرد.",
error: "تغییر رمز عبور انجام نشد.",
},
},
toasts: {
successEdit: "پروفایل با موفقیت به‌روزرسانی شد.",
successImage: "عکس پروفایل به‌روزرسانی شد.",
successRemoveImage: "عکس پروفایل حذف شد.",
error: "خطایی رخ داد."
}
},
workspace: {
title: "مدیریت ورک‌اسپیس‌ها",
@@ -322,95 +373,95 @@ export const fa = {
collapse: 'جمع کردن',
},
landing: {
brandLabel: "زیرساخت عملیاتی زمان",
eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند",
nav: {
demo: "دموی محصول",
features: "قابلیت‌ها",
workflow: "فرآیند کار",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "ورود",
openApp: "ورود به اپ",
openWorkspace: "باز کردن ورک‌اسپیس",
startNow: "شروع با کنترل کامل",
watchDemo: "مشاهده دموی محصول",
readTerms: "مطالعه قوانین",
},
hero: {
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
titleAccent: "Qlockify زمان را شفاف، پاسخ‌گو و قابل‌صورتحساب می‌کند.",
description:
"یک محیط متمرکز برای تیم‌های مدرن که به ثبت سریع زمان، رهگیری دقیق پروژه، گزارش‌های قابل اتکا و لاگ عملیاتی واقعی برای مدیریت نیاز دارند.",
},
metrics: {
capture: "ثبت تمیزتر ساعات قابل‌صورتحساب",
visibility: "دسترسی سریع‌تر به دید گزارش‌دهی",
decision: "از ورودی خام تا تصمیم مدیریتی",
},
trust: {
first: "تایمر دقیق با امکان ویرایش دستی در زمان لازم",
second: "دسترسی‌ها، لاگ‌ها و گزارش‌های مبتنی بر نرخ",
third: "مناسب آژانس‌ها، شرکت‌های مشاوره، تیم‌های محصول و عملیات",
},
capabilities: {
time: {
title: "ثبت کار بدون اصطکاک",
description:
"تایمر را شروع کنید، ورودی‌های گذشته را اصلاح کنید و پروژه و تگ را بدون ایجاد اصطکاک برای تیم به هر ساعت متصل نگه دارید.",
},
reports: {
title: "کسب‌وکار را در چند دقیقه بخوانید",
description:
"خروجی روزانه، عملکرد قابل‌صورتحساب، توزیع پروژه‌ها و بسته‌های گزارشی قابل خروجی را بدون پاک‌سازی دستی فایل‌ها ببینید.",
},
control: {
title: "عملیات را قابل توضیح نگه دارید",
description:
"ببینید چه کسی چه چیزی را تغییر داده، نقش‌ها را شفاف نگه دارید و برای مدیریت یک رد عملیاتی تمیزتر از چت و فایل دستی بسازید.",
},
},
demo: {
timerTag: "تایمر زنده",
timerTitle: "بازه اجرای فعلی",
timerText: "بهبود دیزاین سیستم، متصل به پروژه درست، تگ‌های صحیح و نرخ قابل‌صورتحساب.",
panelLabel: "پیش‌نمایش تعاملی محصول",
panelTitle: "یک سطح واحد برای رهگیری، گزارش‌دهی و شفافیت عملیاتی",
runningCard: "ورودی فعال",
currentTask: "پیاده‌سازی لندینگ سازمانی",
currentTaskMeta: "پروژه: بازاریابی Qlockify · تگ‌ها: طراحی، بازبینی، تحویل",
billableLabel: "نرخ زنده قابل‌صورتحساب",
reportCard: "روند گزارش روزانه",
opsCard: "سلامت عملیات",
opsLabels: ["پوشش", "تمرکز تیم", "آمادگی صورتحساب"],
logCard: "آخرین فعالیت‌های ورک‌اسپیس",
logItems: [
{ title: "نرخ تیم طراحی محصول به‌روزرسانی شد", meta: "اقدام مالک · ۳ دقیقه پیش" },
{ title: "پروژه مشتری‌محور بایگانی شد", meta: "اقدام ادمین · ۱۸ دقیقه پیش" },
{ title: "تگ تاریخی روی ورودی ویرایش‌شده حفظ شد", meta: "اقدام عضو · ۴۱ دقیقه پیش" },
],
outcomeTag: "خروجی مدیریتی",
outcomeText: "ابهام کمتر در پایان ماه، ساعات قابل‌صورتحساب از‌دست‌رفته کمتر و بازبینی عملیاتی سریع‌تر.",
},
workflowTag: "فرآیند عملیاتی",
workflowTitle: "از تلاش خام تا داده مدیریتی قابل استفاده، با یک حلقه کوتاه‌تر.",
workflowDescription:
"Qlockify مسیر را کوتاه نگه می‌دارد: دقیق ثبت کنید، یک‌بار بستر را درست بسازید و همان نتیجه را در تایم‌شیت، گزارش و تصمیم‌گیری مدیریتی مصرف کنید.",
workflow: {
capture: "زمان را در مبدأ، همراه با پروژه، تگ و بستر مالی ثبت کنید.",
structure: "هر تغییر در اعضا، نرخ‌ها و تنظیمات ورک‌اسپیس را قابل مشاهده و قابل بررسی نگه دارید.",
improve: "عملکرد روزانه و ماهانه را با گزارش‌هایی بخوانید که آماده خروجی و اقدام هستند.",
},
finalCtaTag: "آماده برای تیم‌های جدی",
finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.",
finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
},
ordering: {
landing: {
brandLabel: "زیرساخت عملیاتی زمان",
eyebrow: "طراحی‌شده برای تیم‌های دقیق که به داده زمانی قابل اتکا نیاز دارند",
nav: {
demo: "دموی محصول",
features: "قابلیت‌ها",
workflow: "فرآیند کار",
},
actions: {
switchToEnglish: "English",
switchToPersian: "فارسی",
signIn: "ورود",
openApp: "ورود به اپ",
openWorkspace: "باز کردن ورک‌اسپیس",
startNow: "شروع با کنترل کامل",
watchDemo: "مشاهده دموی محصول",
readTerms: "مطالعه قوانین",
},
hero: {
titleTop: "هر ساعت کاری را به یک سیگنال عملیاتی قابل اعتماد تبدیل کنید.",
titleAccent: "Qlockify زمان را شفاف، پاسخ‌گو و قابل‌صورتحساب می‌کند.",
description:
"یک محیط متمرکز برای تیم‌های مدرن که به ثبت سریع زمان، رهگیری دقیق پروژه، گزارش‌های قابل اتکا و لاگ عملیاتی واقعی برای مدیریت نیاز دارند.",
},
metrics: {
capture: "ثبت تمیزتر ساعات قابل‌صورتحساب",
visibility: "دسترسی سریع‌تر به دید گزارش‌دهی",
decision: "از ورودی خام تا تصمیم مدیریتی",
},
trust: {
first: "تایمر دقیق با امکان ویرایش دستی در زمان لازم",
second: "دسترسی‌ها، لاگ‌ها و گزارش‌های مبتنی بر نرخ",
third: "مناسب آژانس‌ها، شرکت‌های مشاوره، تیم‌های محصول و عملیات",
},
capabilities: {
time: {
title: "ثبت کار بدون اصطکاک",
description:
"تایمر را شروع کنید، ورودی‌های گذشته را اصلاح کنید و پروژه و تگ را بدون ایجاد اصطکاک برای تیم به هر ساعت متصل نگه دارید.",
},
reports: {
title: "کسب‌وکار را در چند دقیقه بخوانید",
description:
"خروجی روزانه، عملکرد قابل‌صورتحساب، توزیع پروژه‌ها و بسته‌های گزارشی قابل خروجی را بدون پاک‌سازی دستی فایل‌ها ببینید.",
},
control: {
title: "عملیات را قابل توضیح نگه دارید",
description:
"ببینید چه کسی چه چیزی را تغییر داده، نقش‌ها را شفاف نگه دارید و برای مدیریت یک رد عملیاتی تمیزتر از چت و فایل دستی بسازید.",
},
},
demo: {
timerTag: "تایمر زنده",
timerTitle: "بازه اجرای فعلی",
timerText: "بهبود دیزاین سیستم، متصل به پروژه درست، تگ‌های صحیح و نرخ قابل‌صورتحساب.",
panelLabel: "پیش‌نمایش تعاملی محصول",
panelTitle: "یک سطح واحد برای رهگیری، گزارش‌دهی و شفافیت عملیاتی",
runningCard: "ورودی فعال",
currentTask: "پیاده‌سازی لندینگ سازمانی",
currentTaskMeta: "پروژه: بازاریابی Qlockify · تگ‌ها: طراحی، بازبینی، تحویل",
billableLabel: "نرخ زنده قابل‌صورتحساب",
reportCard: "روند گزارش روزانه",
opsCard: "سلامت عملیات",
opsLabels: ["پوشش", "تمرکز تیم", "آمادگی صورتحساب"],
logCard: "آخرین فعالیت‌های ورک‌اسپیس",
logItems: [
{ title: "نرخ تیم طراحی محصول به‌روزرسانی شد", meta: "اقدام مالک · ۳ دقیقه پیش" },
{ title: "پروژه مشتری‌محور بایگانی شد", meta: "اقدام ادمین · ۱۸ دقیقه پیش" },
{ title: "تگ تاریخی روی ورودی ویرایش‌شده حفظ شد", meta: "اقدام عضو · ۴۱ دقیقه پیش" },
],
outcomeTag: "خروجی مدیریتی",
outcomeText: "ابهام کمتر در پایان ماه، ساعات قابل‌صورتحساب از‌دست‌رفته کمتر و بازبینی عملیاتی سریع‌تر.",
},
workflowTag: "فرآیند عملیاتی",
workflowTitle: "از تلاش خام تا داده مدیریتی قابل استفاده، با یک حلقه کوتاه‌تر.",
workflowDescription:
"Qlockify مسیر را کوتاه نگه می‌دارد: دقیق ثبت کنید، یک‌بار بستر را درست بسازید و همان نتیجه را در تایم‌شیت، گزارش و تصمیم‌گیری مدیریتی مصرف کنید.",
workflow: {
capture: "زمان را در مبدأ، همراه با پروژه، تگ و بستر مالی ثبت کنید.",
structure: "هر تغییر در اعضا، نرخ‌ها و تنظیمات ورک‌اسپیس را قابل مشاهده و قابل بررسی نگه دارید.",
improve: "عملکرد روزانه و ماهانه را با گزارش‌هایی بخوانید که آماده خروجی و اقدام هستند.",
},
finalCtaTag: "آماده برای تیم‌های جدی",
finalCtaTitle: "اگر تیم شما تخصص می‌فروشد یا پروژه مشتری تحویل می‌دهد، سیستم زمان شما هم باید همین‌قدر جدی باشد.",
finalCtaDescription:
"اپ را باز کنید، ورک‌اسپیس بسازید و ببینید وقتی محصول نشت بستر را متوقف می‌کند، انضباط گزارش‌دهی چقدر سریع بهتر می‌شود.",
},
ordering: {
createdAtDesc: "جدیدترین",
createdAt: "قدیمی‌ترین",
updatedAtDesc: "اخیراً بروزرسانی شده",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import { useState } from "react"
import { Eye, EyeOff } from "lucide-react"
import { Input } from "../../components/ui/input"
interface AuthPasswordFieldProps {
id: string
value: string
onChange: (value: string) => void
placeholder: string
disabled?: boolean
}
export function AuthPasswordField({
id,
value,
onChange,
placeholder,
disabled = false,
}: AuthPasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<div className="relative w-full" dir="ltr">
<Input
id={id}
value={value}
type={showPassword ? "text" : "password"}
placeholder={placeholder}
autoComplete="new-password"
dir="ltr"
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
className="h-11 pe-10 text-start"
/>
<button
type="button"
tabIndex={-1}
onClick={() => setShowPassword((current) => !current)}
className="absolute inset-y-0 end-0 flex items-center pe-3 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { Navigate, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { registerWithOtp } from "../../api/users"
import { Button } from "../../components/ui/button"
import { useAuthFlow } from "../../context/AuthFlowContext"
import { useTranslation } from "../../hooks/useTranslation"
import { AuthPanel } from "./AuthPanel"
import { AuthPasswordField } from "./AuthPasswordField"
import { completeAuthentication, getApiErrorMessage } from "./utils"
export function SignupPasswordPage() {
const navigate = useNavigate()
const { t } = useTranslation()
const { state, resetFlow } = useAuthFlow()
const [password, setPassword] = useState("")
const [confirmation, setConfirmation] = useState("")
const [loading, setLoading] = useState(false)
if (!state.signup.mobile) {
return <Navigate to="/auth/signup" replace />
}
if (!state.signup.code) {
return <Navigate to="/auth/signup/verify" replace />
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!password || !confirmation) {
toast.error(t.login.toasts.fillAll)
return
}
if (password !== confirmation) {
toast.error(t.login.passwordMismatch)
return
}
setLoading(true)
try {
const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation)
resetFlow("signup")
completeAuthentication({
access: data.access,
refresh: data.refresh,
successMessage: t.login.toasts.accountCreated,
redirectTo: "/timesheet",
navigate,
})
} catch (error) {
toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup))
} finally {
setLoading(false)
}
}
return (
<AuthPanel
title={t.login.signupPasswordTitle}
description={t.login.signupPasswordDescription}
>
<form onSubmit={handleSubmit} className="grid gap-4">
<AuthPasswordField
id="signup-password"
value={password}
onChange={setPassword}
placeholder={t.login.newPasswordPlaceholder}
disabled={loading}
/>
<AuthPasswordField
id="signup-password-confirmation"
value={confirmation}
onChange={setConfirmation}
placeholder={t.login.confirmPasswordPlaceholder}
disabled={loading}
/>
<Button type="submit" className="h-11 w-full" disabled={loading}>
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t.login.createAccountPasswordCta}
</Button>
<Button type="button" variant="outline" className="h-11 w-full" onClick={() => navigate("/auth/signup/verify")}>
{t.login.back}
</Button>
</form>
</AuthPanel>
)
}

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

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