From 380b794ab1c445470e25cbfed3e4cdf19d0be24d Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sun, 3 May 2026 17:10:02 +0330 Subject: [PATCH] feat(auth): add stepped auth and password recovery flows --- src/App.tsx | 31 +- src/api/users.ts | 43 ++ src/context/AuthFlowContext.tsx | 196 +++++++++ src/locales/en.ts | 319 ++++++++------ src/locales/fa.ts | 337 ++++++++------ src/pages/Auth.tsx | 415 +----------------- src/pages/Profile.tsx | 117 ++++- src/pages/auth/AuthPanel.tsx | 58 +++ src/pages/auth/AuthPasswordField.tsx | 46 ++ src/pages/auth/ForgotPasswordMobilePage.tsx | 107 +++++ src/pages/auth/ForgotPasswordOtpPage.tsx | 57 +++ src/pages/auth/ForgotPasswordPasswordPage.tsx | 89 ++++ src/pages/auth/LoginMobilePage.tsx | 151 +++++++ src/pages/auth/LoginOtpPage.tsx | 113 +++++ src/pages/auth/LoginPasswordPage.tsx | 119 +++++ src/pages/auth/SignupMobilePage.tsx | 108 +++++ src/pages/auth/SignupOtpPage.tsx | 57 +++ src/pages/auth/SignupPasswordPage.tsx | 93 ++++ src/pages/auth/utils.ts | 88 ++++ 19 files changed, 1857 insertions(+), 687 deletions(-) create mode 100644 src/context/AuthFlowContext.tsx create mode 100644 src/pages/auth/AuthPanel.tsx create mode 100644 src/pages/auth/AuthPasswordField.tsx create mode 100644 src/pages/auth/ForgotPasswordMobilePage.tsx create mode 100644 src/pages/auth/ForgotPasswordOtpPage.tsx create mode 100644 src/pages/auth/ForgotPasswordPasswordPage.tsx create mode 100644 src/pages/auth/LoginMobilePage.tsx create mode 100644 src/pages/auth/LoginOtpPage.tsx create mode 100644 src/pages/auth/LoginPasswordPage.tsx create mode 100644 src/pages/auth/SignupMobilePage.tsx create mode 100644 src/pages/auth/SignupOtpPage.tsx create mode 100644 src/pages/auth/SignupPasswordPage.tsx create mode 100644 src/pages/auth/utils.ts diff --git a/src/App.tsx b/src/App.tsx index f873901..e38f078 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,16 @@ import NotificationsPage from "./pages/Notifications" import RateLimitPage from "./pages/RateLimit" import Landing from "./pages/Landing" import { isRateLimitActive } from "./lib/rateLimit" +import { AuthFlowProvider } from "./context/AuthFlowContext" +import { LoginMobilePage } from "./pages/auth/LoginMobilePage" +import { LoginOtpPage } from "./pages/auth/LoginOtpPage" +import { LoginPasswordPage } from "./pages/auth/LoginPasswordPage" +import { SignupMobilePage } from "./pages/auth/SignupMobilePage" +import { SignupOtpPage } from "./pages/auth/SignupOtpPage" +import { SignupPasswordPage } from "./pages/auth/SignupPasswordPage" +import { ForgotPasswordMobilePage } from "./pages/auth/ForgotPasswordMobilePage" +import { ForgotPasswordOtpPage } from "./pages/auth/ForgotPasswordOtpPage" +import { ForgotPasswordPasswordPage } from "./pages/auth/ForgotPasswordPasswordPage" const MainLayout = () => { const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false) @@ -81,8 +91,27 @@ const router = createBrowserRouter([ children: [ { path: "/", element: }, { path: "/app", element: }, - { path: "/auth", element: }, { path: "/auth/google/callback", element: }, + { + path: "/auth", + element: ( + + + + ), + children: [ + { index: true, element: }, + { path: "login", element: }, + { path: "login/verify", element: }, + { path: "login/password", element: }, + { path: "signup", element: }, + { path: "signup/verify", element: }, + { path: "signup/password", element: }, + { path: "forgot-password", element: }, + { path: "forgot-password/verify", element: }, + { path: "forgot-password/password", element: }, + ], + }, { path: "/terms", element: }, { path: "/rate-limit", element: }, { diff --git a/src/api/users.ts b/src/api/users.ts index b6fc06a..6312076 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -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/")); }; diff --git a/src/context/AuthFlowContext.tsx b/src/context/AuthFlowContext.tsx new file mode 100644 index 0000000..33a820d --- /dev/null +++ b/src/context/AuthFlowContext.tsx @@ -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(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 + + 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(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( + () => ({ + 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 {children} +} + +export function useAuthFlow() { + const context = useContext(AuthFlowContext) + + if (!context) { + throw new Error("useAuthFlow must be used within an AuthFlowProvider") + } + + return context +} diff --git a/src/locales/en.ts b/src/locales/en.ts index b96cecc..19b81ab 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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)", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index e7500de..dcd92ec 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -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: "اخیراً بروزرسانی شده", diff --git a/src/pages/Auth.tsx b/src/pages/Auth.tsx index a3b558d..f40b58b 100644 --- a/src/pages/Auth.tsx +++ b/src/pages/Auth.tsx @@ -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 - -const PERSIAN_DIGITS = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"] - -const toPersianDigits = (value: string) => - value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) - -const GoogleIcon = () => ( - -) export default function Auth() { - const navigate = useNavigate() - const { t, lang } = useTranslation() - const isRtl = lang === "fa" - - const [step, setStep] = useState("mobile") - const [mode, setMode] = useState("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({ - 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 ( -
+
-
-
+
+
{t.title || "Qlockify"}
+

"{t.login.brandingQuote}"

@@ -252,176 +26,9 @@ export default function Auth() {
-
-
-
-
- -
-

- {step === "mobile" && t.login.welcome(t.title)} - {step === "password" && t.login.enterPassword} - {step === "otp" && t.login.verifyNumber} -

-

- {step === "mobile" && t.login.enterMobileDesc} - {step === "password" && t.login.signInDesc} - {step === "otp" && t.login.sentCodeDesc(mobile)} -

-
- - {activeCooldownMessage && ( -
-
- -
-

{activeCooldownMessage.title}

-

{activeCooldownMessage.description}

-
-
-
- )} - -
- {step === "mobile" && ( -
- setMobile(e.target.value)} - maxLength={11} - disabled={loading} - className={`h-11 ${isRtl ? "text-end" : "text-start"}`} - /> - - -
-
- -
-
- - {t.login.orContinueWith} - -
-
- - - -
- - -
-
- )} - - {step === "password" && ( -
-
- setPassword(e.target.value)} - disabled={loading} - className={`h-11 pr-10 ${isRtl ? "text-end" : "text-start"}`} - /> - -
- - -
- )} - - {step === "otp" && ( -
- setOtpCode(e.target.value)} - maxLength={6} - disabled={loading} - className="h-11 text-center tracking-widest text-lg" - /> - - -
- )} -
- -
- {t.loginTerms?.prefix} - - {t.loginTerms?.link} - - {t.loginTerms?.suffix} -
+
+
+
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index c3799f1..9d8341d 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -5,7 +5,8 @@ import { getUserProfile, updateUserProfile, updateProfilePicture, - removeProfilePicture + removeProfilePicture, + changePassword, } from "../api/users" import { Button } from "../components/ui/button" import { Camera, Edit2, Trash2, User as UserIcon, UploadCloud, X, Check } from "lucide-react" @@ -14,6 +15,7 @@ import { toast } from "sonner" import { Modal } from "../components/Modal" import { Input } from "../components/ui/input" import { TextAreaInput } from "../components/ui/TextAreaInput" +import { AuthPasswordField } from "./auth/AuthPasswordField" export interface UserProfile { id?: string; @@ -36,6 +38,7 @@ export default function Profile() { const { t, lang } = useTranslation() const isFa = lang === 'fa' + const passwordCopy = t.profile.password const toPersianNum = (num: string | number | undefined | null) => { if (num === null || num === undefined) return num @@ -59,6 +62,7 @@ export default function Profile() { // Modals & Editing state const [isEditing, setIsEditing] = useState(false) const [isPicModalOpen, setIsPicModalOpen] = useState(false) + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false) const [isSaving, setIsSaving] = useState(false) // Form states @@ -66,6 +70,11 @@ export default function Profile() { const [selectedFile, setSelectedFile] = useState(null) const [dragActive, setDragActive] = useState(false) const fileInputRef = useRef(null) + const [passwordForm, setPasswordForm] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) const fetchProfile = async () => { try { @@ -162,6 +171,44 @@ export default function Profile() { } } + const resetPasswordForm = () => { + setPasswordForm({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }) + } + + const handleChangePassword = async (event: React.FormEvent) => { + event.preventDefault() + + if (!passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) { + toast.error(t.login.toasts.fillAll) + return + } + + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + toast.error(t.login.passwordMismatch) + return + } + + setIsSaving(true) + try { + await changePassword( + passwordForm.currentPassword, + passwordForm.newPassword, + passwordForm.confirmPassword, + ) + resetPasswordForm() + setIsPasswordModalOpen(false) + toast.success(passwordCopy.toasts.success) + } catch (error) { + toast.error(error instanceof Error ? error.message : passwordCopy.toasts.error) + } finally { + setIsSaving(false) + } + } + // Drag & Drop Handlers const handleDrag = (e: React.DragEvent) => { e.preventDefault() @@ -233,10 +280,14 @@ export default function Profile() { {!isEditing && ( - +
+ + +
)}
@@ -446,6 +497,62 @@ export default function Profile() { )} + {isPasswordModalOpen && ( + { + if (isSaving) return + setIsPasswordModalOpen(false) + resetPasswordForm() + }} + title={passwordCopy.title} + description={passwordCopy.description} + maxWidth="max-w-md" + > +
+ setPasswordForm((current) => ({ ...current, currentPassword: value }))} + placeholder={passwordCopy.currentPassword} + disabled={isSaving} + /> + setPasswordForm((current) => ({ ...current, newPassword: value }))} + placeholder={passwordCopy.newPassword} + disabled={isSaving} + /> + setPasswordForm((current) => ({ ...current, confirmPassword: value }))} + placeholder={passwordCopy.confirmPassword} + disabled={isSaving} + /> + +
+ + +
+ +
+ )} +
) diff --git a/src/pages/auth/AuthPanel.tsx b/src/pages/auth/AuthPanel.tsx new file mode 100644 index 0000000..e7c8a91 --- /dev/null +++ b/src/pages/auth/AuthPanel.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from "react" +import { Link } from "react-router-dom" +import { Command, AlertTriangle } from "lucide-react" + +import { useTranslation } from "../../hooks/useTranslation" + +interface AuthPanelProps { + title: string + description: string + children: ReactNode + alert?: { + title: string + description: string + } | null +} + +export function AuthPanel({ title, description, children, alert = null }: AuthPanelProps) { + const { t } = useTranslation() + + return ( +
+
+
+ +
+

{title}

+

{description}

+
+ + {alert && ( +
+
+ +
+

{alert.title}

+

{alert.description}

+
+
+
+ )} + +
+ {children} +
+ +
+ {t.loginTerms?.prefix} + + {t.loginTerms?.link} + + {t.loginTerms?.suffix} +
+
+ ) +} diff --git a/src/pages/auth/AuthPasswordField.tsx b/src/pages/auth/AuthPasswordField.tsx new file mode 100644 index 0000000..742b1b2 --- /dev/null +++ b/src/pages/auth/AuthPasswordField.tsx @@ -0,0 +1,46 @@ +import { useState } from "react" +import { Eye, EyeOff } from "lucide-react" + +import { Input } from "../../components/ui/input" + +interface AuthPasswordFieldProps { + id: string + value: string + onChange: (value: string) => void + placeholder: string + disabled?: boolean +} + +export function AuthPasswordField({ + id, + value, + onChange, + placeholder, + disabled = false, +}: AuthPasswordFieldProps) { + const [showPassword, setShowPassword] = useState(false) + + return ( +
+ onChange(event.target.value)} + className="h-11 pe-10 text-start" + /> + +
+ ) +} diff --git a/src/pages/auth/ForgotPasswordMobilePage.tsx b/src/pages/auth/ForgotPasswordMobilePage.tsx new file mode 100644 index 0000000..01f9bb5 --- /dev/null +++ b/src/pages/auth/ForgotPasswordMobilePage.tsx @@ -0,0 +1,107 @@ +import { Loader2 } from "lucide-react" +import { useMemo, useState } from "react" +import { Link, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { sendOtp } from "../../api/users" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" + +export function ForgotPasswordMobilePage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() + const isRtl = lang === "fa" + const [loading, setLoading] = useState(false) + + const alert = useMemo(() => { + if (state.cooldowns.forgotPasswordOtpSend <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.otpSendMessage(formatted), + } + }, [isRtl, state.cooldowns.forgotPasswordOtpSend, t.login.throttle]) + + const cooldownLabel = + state.cooldowns.forgotPasswordOtpSend > 0 + ? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.forgotPasswordOtpSend, isRtl)) + : null + + const handleContinue = async () => { + if (!state.forgotPassword.mobile) { + toast.error(t.login.toasts.enterMobile) + return + } + + setLoading(true) + try { + await sendOtp(state.forgotPassword.mobile, "forget_password") + clearCooldown("forgotPasswordOtpSend") + setCode("forgotPassword", "") + navigate("/auth/forgot-password/verify") + toast.success(t.login.toasts.verifySent) + } catch (error) { + if ( + !handleThrottleError({ + error, + cooldownKey: "forgotPasswordOtpSend", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ setMobile("forgotPassword", event.target.value)} + className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + /> + + + +
+ + {t.login.backToPasswordLogin} + +
+
+
+ ) +} diff --git a/src/pages/auth/ForgotPasswordOtpPage.tsx b/src/pages/auth/ForgotPasswordOtpPage.tsx new file mode 100644 index 0000000..8005357 --- /dev/null +++ b/src/pages/auth/ForgotPasswordOtpPage.tsx @@ -0,0 +1,57 @@ +import { useState } from "react" +import { Link, Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" + +export function ForgotPasswordOtpPage() { + const navigate = useNavigate() + const { t } = useTranslation() + const { state, setCode } = useAuthFlow() + const [loading, setLoading] = useState(false) + + if (!state.forgotPassword.mobile) { + return + } + + const handleContinue = async (event: React.FormEvent) => { + event.preventDefault() + + if (!state.forgotPassword.code) { + toast.error(t.login.toasts.enterOtp) + return + } + + setLoading(true) + navigate("/auth/forgot-password/password") + } + + return ( + +
+ setCode("forgotPassword", event.target.value)} + className="h-11 text-center text-lg tracking-widest" + /> + + +
+
+ ) +} diff --git a/src/pages/auth/ForgotPasswordPasswordPage.tsx b/src/pages/auth/ForgotPasswordPasswordPage.tsx new file mode 100644 index 0000000..4b0e1e0 --- /dev/null +++ b/src/pages/auth/ForgotPasswordPasswordPage.tsx @@ -0,0 +1,89 @@ +import { Loader2 } from "lucide-react" +import { useState } from "react" +import { Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { resetPasswordWithOtp } from "../../api/users" +import { Button } from "../../components/ui/button" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { AuthPasswordField } from "./AuthPasswordField" +import { getApiErrorMessage } from "./utils" + +export function ForgotPasswordPasswordPage() { + const navigate = useNavigate() + const { t } = useTranslation() + const { state, resetFlow, setMobile } = useAuthFlow() + const [password, setPassword] = useState("") + const [confirmation, setConfirmation] = useState("") + const [loading, setLoading] = useState(false) + + if (!state.forgotPassword.mobile) { + return + } + + if (!state.forgotPassword.code) { + return + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!password || !confirmation) { + toast.error(t.login.toasts.fillAll) + return + } + + if (password !== confirmation) { + toast.error(t.login.passwordMismatch) + return + } + + setLoading(true) + try { + await resetPasswordWithOtp(state.forgotPassword.mobile, state.forgotPassword.code, password, confirmation) + setMobile("login", state.forgotPassword.mobile) + resetFlow("forgotPassword") + toast.success(t.login.toasts.passwordResetSuccess) + navigate("/auth/login/password", { replace: true }) + } catch (error) { + toast.error(getApiErrorMessage(error, t.login.toasts.passwordResetFailed)) + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + + + + +
+ ) +} diff --git a/src/pages/auth/LoginMobilePage.tsx b/src/pages/auth/LoginMobilePage.tsx new file mode 100644 index 0000000..0608208 --- /dev/null +++ b/src/pages/auth/LoginMobilePage.tsx @@ -0,0 +1,151 @@ +import { Loader2 } from "lucide-react" +import { useMemo, useState } from "react" +import { Link, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { sendOtp, startGoogleLogin } from "../../api/users" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" + +const GoogleIcon = () => ( + +) + +export function LoginMobilePage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const { state, setMobile, setCooldown, clearCooldown, resetFlow } = useAuthFlow() + const isRtl = lang === "fa" + const [loading, setLoading] = useState(false) + + const alert = useMemo(() => { + if (state.cooldowns.loginOtpSend <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.loginOtpSend, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.otpSendMessage(formatted), + } + }, [isRtl, state.cooldowns.loginOtpSend, t.login.throttle]) + + const cooldownLabel = + state.cooldowns.loginOtpSend > 0 + ? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpSend, isRtl)) + : null + + const handleLogin = async () => { + if (!state.login.mobile) { + toast.error(t.login.toasts.enterMobile) + return + } + + setLoading(true) + try { + await sendOtp(state.login.mobile, "login") + clearCooldown("loginOtpSend") + resetFlow("forgotPassword") + navigate("/auth/login/verify") + toast.success(t.login.toasts.verifySent) + } catch (error) { + if ( + !handleThrottleError({ + error, + cooldownKey: "loginOtpSend", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ setMobile("login", event.target.value)} + className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + /> + + + +
+
+ +
+
+ + {t.login.orContinueWith} + +
+
+ + + +
+ {t.login.haveNoAccount}{" "} + + {t.login.register} + +
+
+
+ ) +} diff --git a/src/pages/auth/LoginOtpPage.tsx b/src/pages/auth/LoginOtpPage.tsx new file mode 100644 index 0000000..7736958 --- /dev/null +++ b/src/pages/auth/LoginOtpPage.tsx @@ -0,0 +1,113 @@ +import { Loader2 } from "lucide-react" +import { useMemo, useState } from "react" +import { Link, Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { loginWithOtp } from "../../api/users" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" + +export function LoginOtpPage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const { state, setCode, setCooldown, clearCooldown } = useAuthFlow() + const isRtl = lang === "fa" + const [loading, setLoading] = useState(false) + + if (!state.login.mobile) { + return + } + + const alert = useMemo(() => { + if (state.cooldowns.loginOtpVerify <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.loginOtpVerify, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.otpLoginMessage(formatted), + } + }, [isRtl, state.cooldowns.loginOtpVerify, t.login.throttle]) + + const cooldownLabel = + state.cooldowns.loginOtpVerify > 0 + ? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginOtpVerify, isRtl)) + : null + + const handleVerify = async (event: React.FormEvent) => { + event.preventDefault() + + if (!state.login.code) { + toast.error(t.login.toasts.enterOtp) + return + } + + setLoading(true) + try { + const data = await loginWithOtp(state.login.mobile, state.login.code) + clearCooldown("loginOtpVerify") + completeAuthentication({ + access: data.access, + refresh: data.refresh, + successMessage: t.login.toasts.successLogin, + redirectTo: "/profile", + navigate, + }) + } catch (error) { + if ( + !handleThrottleError({ + error, + cooldownKey: "loginOtpVerify", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.invalidOtp)) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ setCode("login", event.target.value)} + className="h-11 text-center text-lg tracking-widest" + /> + + + +
+ + {t.login.usePasswordInstead} + +
+
+
+ ) +} diff --git a/src/pages/auth/LoginPasswordPage.tsx b/src/pages/auth/LoginPasswordPage.tsx new file mode 100644 index 0000000..11362ef --- /dev/null +++ b/src/pages/auth/LoginPasswordPage.tsx @@ -0,0 +1,119 @@ +import { Loader2 } from "lucide-react" +import { useMemo, useState } from "react" +import { Link, Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { loginWithPassword } from "../../api/users" +import { Button } from "../../components/ui/button" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { AuthPasswordField } from "./AuthPasswordField" +import { completeAuthentication, formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" + +export function LoginPasswordPage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const { state, setMobile, clearCooldown, setCooldown, setCode, resetFlow } = useAuthFlow() + const isRtl = lang === "fa" + const [password, setPassword] = useState("") + const [loading, setLoading] = useState(false) + + if (!state.login.mobile) { + return + } + + const alert = useMemo(() => { + if (state.cooldowns.loginPassword <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.loginPassword, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.passwordLoginMessage(formatted), + } + }, [isRtl, state.cooldowns.loginPassword, t.login.throttle]) + + const cooldownLabel = + state.cooldowns.loginPassword > 0 + ? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.loginPassword, isRtl)) + : null + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!password) { + toast.error(t.login.toasts.fillAll) + return + } + + setLoading(true) + try { + const data = await loginWithPassword(state.login.mobile, password) + clearCooldown("loginPassword") + completeAuthentication({ + access: data.access, + refresh: data.refresh, + successMessage: t.login.toasts.successLogin, + redirectTo: "/profile", + navigate, + }) + } catch (error) { + if ( + !handleThrottleError({ + error, + cooldownKey: "loginPassword", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.invalidCreds)) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + + + +
+ +
+ +
+ ) +} diff --git a/src/pages/auth/SignupMobilePage.tsx b/src/pages/auth/SignupMobilePage.tsx new file mode 100644 index 0000000..2381c90 --- /dev/null +++ b/src/pages/auth/SignupMobilePage.tsx @@ -0,0 +1,108 @@ +import { Loader2 } from "lucide-react" +import { useMemo, useState } from "react" +import { Link, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { sendOtp } from "../../api/users" +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { formatCooldown, getApiErrorMessage, handleThrottleError } from "./utils" + +export function SignupMobilePage() { + const navigate = useNavigate() + const { t, lang } = useTranslation() + const { state, setMobile, setCode, setCooldown, clearCooldown } = useAuthFlow() + const isRtl = lang === "fa" + const [loading, setLoading] = useState(false) + + const alert = useMemo(() => { + if (state.cooldowns.signupOtpSend <= 0) { + return null + } + + const formatted = formatCooldown(state.cooldowns.signupOtpSend, isRtl) + return { + title: t.login.throttle.title, + description: t.login.throttle.otpSendMessage(formatted), + } + }, [isRtl, state.cooldowns.signupOtpSend, t.login.throttle]) + + const cooldownLabel = + state.cooldowns.signupOtpSend > 0 + ? t.login.throttle.countdownLabel(formatCooldown(state.cooldowns.signupOtpSend, isRtl)) + : null + + const handleContinue = async () => { + if (!state.signup.mobile) { + toast.error(t.login.toasts.enterMobile) + return + } + + setLoading(true) + try { + await sendOtp(state.signup.mobile, "register") + clearCooldown("signupOtpSend") + setCode("signup", "") + navigate("/auth/signup/verify") + toast.success(t.login.toasts.verifySent) + } catch (error) { + if ( + !handleThrottleError({ + error, + cooldownKey: "signupOtpSend", + setCooldown, + formatTime: (seconds) => formatCooldown(seconds, isRtl), + throttleCopy: t.login.throttle, + }) + ) { + toast.error(getApiErrorMessage(error, t.login.toasts.failedOtp)) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ setMobile("signup", event.target.value)} + className={`h-11 ${isRtl ? "text-end" : "text-start"}`} + /> + + + +
+ {t.login.haveAccount}{" "} + + {t.login.signIn} + +
+
+
+ ) +} diff --git a/src/pages/auth/SignupOtpPage.tsx b/src/pages/auth/SignupOtpPage.tsx new file mode 100644 index 0000000..f5aee39 --- /dev/null +++ b/src/pages/auth/SignupOtpPage.tsx @@ -0,0 +1,57 @@ +import { useState } from "react" +import { Link, Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { Button } from "../../components/ui/button" +import { Input } from "../../components/ui/input" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" + +export function SignupOtpPage() { + const navigate = useNavigate() + const { t } = useTranslation() + const { state, setCode } = useAuthFlow() + const [loading, setLoading] = useState(false) + + if (!state.signup.mobile) { + return + } + + const handleContinue = async (event: React.FormEvent) => { + event.preventDefault() + + if (!state.signup.code) { + toast.error(t.login.toasts.enterOtp) + return + } + + setLoading(true) + navigate("/auth/signup/password") + } + + return ( + +
+ setCode("signup", event.target.value)} + className="h-11 text-center text-lg tracking-widest" + /> + + +
+
+ ) +} diff --git a/src/pages/auth/SignupPasswordPage.tsx b/src/pages/auth/SignupPasswordPage.tsx new file mode 100644 index 0000000..4517d21 --- /dev/null +++ b/src/pages/auth/SignupPasswordPage.tsx @@ -0,0 +1,93 @@ +import { Loader2 } from "lucide-react" +import { useState } from "react" +import { Navigate, useNavigate } from "react-router-dom" +import { toast } from "sonner" + +import { registerWithOtp } from "../../api/users" +import { Button } from "../../components/ui/button" +import { useAuthFlow } from "../../context/AuthFlowContext" +import { useTranslation } from "../../hooks/useTranslation" +import { AuthPanel } from "./AuthPanel" +import { AuthPasswordField } from "./AuthPasswordField" +import { completeAuthentication, getApiErrorMessage } from "./utils" + +export function SignupPasswordPage() { + const navigate = useNavigate() + const { t } = useTranslation() + const { state, resetFlow } = useAuthFlow() + const [password, setPassword] = useState("") + const [confirmation, setConfirmation] = useState("") + const [loading, setLoading] = useState(false) + + if (!state.signup.mobile) { + return + } + + if (!state.signup.code) { + return + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + + if (!password || !confirmation) { + toast.error(t.login.toasts.fillAll) + return + } + + if (password !== confirmation) { + toast.error(t.login.passwordMismatch) + return + } + + setLoading(true) + try { + const data = await registerWithOtp(state.signup.mobile, state.signup.code, password, confirmation) + resetFlow("signup") + completeAuthentication({ + access: data.access, + refresh: data.refresh, + successMessage: t.login.toasts.accountCreated, + redirectTo: "/timesheet", + navigate, + }) + } catch (error) { + toast.error(getApiErrorMessage(error, t.login.toasts.failedSignup)) + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + + + + +
+ ) +} diff --git a/src/pages/auth/utils.ts b/src/pages/auth/utils.ts new file mode 100644 index 0000000..32e2b02 --- /dev/null +++ b/src/pages/auth/utils.ts @@ -0,0 +1,88 @@ +import { toast } from "sonner" + +import { ApiError } from "../../api/client" +import { setSessionTokens } from "../../lib/session" + +const PERSIAN_DIGITS = ["\u06f0", "\u06f1", "\u06f2", "\u06f3", "\u06f4", "\u06f5", "\u06f6", "\u06f7", "\u06f8", "\u06f9"] + +export const localizeDigits = (value: string, isRtl: boolean) => + isRtl ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number.parseInt(digit, 10)] ?? digit) : value + +export const formatCooldown = (seconds: number, isRtl: boolean) => { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + const base = + minutes > 0 ? `${minutes}:${remainingSeconds.toString().padStart(2, "0")}` : `${remainingSeconds}s` + + return localizeDigits(base, isRtl) +} + +export const getApiErrorMessage = (error: unknown, fallbackMessage: string) => { + if (error instanceof ApiError) { + return error.message + } + + if (error instanceof Error) { + return error.message + } + + return fallbackMessage +} + +export const handleThrottleError = ({ + error, + cooldownKey, + setCooldown, + formatTime, + throttleCopy, +}: { + error: unknown + cooldownKey: "loginOtpSend" | "signupOtpSend" | "forgotPasswordOtpSend" | "loginPassword" | "loginOtpVerify" + setCooldown: (key: "loginOtpSend" | "signupOtpSend" | "forgotPasswordOtpSend" | "loginPassword" | "loginOtpVerify", seconds: number) => void + formatTime: (seconds: number) => string + throttleCopy: { + otpSendMessage: (time: string) => string + passwordLoginMessage: (time: string) => string + otpLoginMessage: (time: string) => string + countdownLabel: (time: string) => string + } +}) => { + if (!(error instanceof ApiError) || error.code !== "throttled") { + return false + } + + const seconds = Math.max(1, error.retryAfterSeconds ?? 0) + const formatted = formatTime(seconds) + setCooldown(cooldownKey, seconds) + + const message = + cooldownKey === "loginPassword" + ? throttleCopy.passwordLoginMessage(formatted) + : cooldownKey === "loginOtpVerify" + ? throttleCopy.otpLoginMessage(formatted) + : throttleCopy.otpSendMessage(formatted) + + toast.error(message, { + description: throttleCopy.countdownLabel(formatted), + }) + + return true +} + +export const completeAuthentication = ({ + access, + refresh, + successMessage, + redirectTo, + navigate, +}: { + access: string + refresh: string + successMessage: string + redirectTo: string + navigate: (path: string, options?: { replace?: boolean }) => void +}) => { + setSessionTokens(access, refresh) + toast.success(successMessage) + navigate(redirectTo, { replace: true }) +}