TorbatYar/frontend/components/auth/AuthPanel.tsx
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

436 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { storeTokens, consumePostLoginRedirect, fetchLoginUrl } from "@/lib/auth";
import { identityApi } from "@/lib/identity";
import {
isValidMobile,
normalizeOtpCode,
normalizePhone,
persianToEnglish,
} from "@/lib/phone";
import { useAuth } from "@/hooks/useAuth";
import { Button } from "@/components/ui";
type Step = "phone" | "code";
export interface AuthPanelProps {
/** true = نمایش فیلدهای ثبت‌نام از ابتدا */
initialSignup?: boolean;
/** مسیر بعد از ورود؛ اگر نباشد از sessionStorage خوانده می‌شود */
redirectTo?: string;
}
function ProfileFields({
displayName,
setDisplayName,
email,
setEmail,
username,
setUsername,
password,
setPassword,
disabled,
}: {
displayName: string;
setDisplayName: (v: string) => void;
email: string;
setEmail: (v: string) => void;
username: string;
setUsername: (v: string) => void;
password: string;
setPassword: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-3 rounded-xl border border-gray-100 bg-gray-50/80 p-4 transition-all duration-300">
<p className="text-xs font-medium text-gray-500">اطلاعات حساب کاربری</p>
<label className="block text-sm font-medium text-secondary">
نام
<input
required
value={displayName}
disabled={disabled}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5"
placeholder="نام نمایشی"
/>
</label>
<label className="block text-sm font-medium text-secondary">
ایمیل
<input
required
type="email"
dir="ltr"
value={email}
disabled={disabled}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5"
/>
</label>
<label className="block text-sm font-medium text-secondary">
نام کاربری
<input
required
dir="ltr"
value={username}
disabled={disabled}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5"
/>
</label>
<label className="block text-sm font-medium text-secondary">
رمز عبور
<input
required
type="password"
value={password}
disabled={disabled}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5"
minLength={8}
/>
</label>
</div>
);
}
export function AuthPanel({ initialSignup = false, redirectTo }: AuthPanelProps) {
const router = useRouter();
const { isAuthenticated, reload } = useAuth();
const [passwordLoading, setPasswordLoading] = useState(false);
const [step, setStep] = useState<Step>("phone");
const [signupMode, setSignupMode] = useState(initialSignup);
const [intent, setIntent] = useState<"login" | "register">("login");
const [mobile, setMobile] = useState("");
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [countdown, setCountdown] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const verifyingRef = useRef(false);
const showProfileFields =
signupMode || (step === "code" && intent === "register");
const profileComplete =
displayName.trim().length > 0 &&
email.trim().length > 0 &&
username.trim().length >= 3 &&
password.length >= 8;
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get("signup") === "1") setSignupMode(true);
const prefilled = params.get("mobile");
if (prefilled) setMobile(prefilled);
}, []);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const finishSession = useCallback(
async (tokens: {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type: string;
}) => {
storeTokens(tokens);
await reload();
router.replace(redirectTo ?? consumePostLoginRedirect("/dashboard"));
},
[redirectTo, reload, router]
);
const validateProfile = useCallback(() => {
if (!profileComplete) {
setError("نام، ایمیل، نام کاربری (حداقل ۳ کاراکتر) و رمز (حداقل ۸) الزامی است");
return false;
}
return true;
}, [profileComplete]);
const handleRequestOtp = async (e?: FormEvent) => {
e?.preventDefault();
setError("");
const normalized = normalizePhone(mobile);
if (!isValidMobile(normalized)) {
setError("شماره موبایل صحیح نیست");
return;
}
if (signupMode && !validateProfile()) return;
setMobile(normalized);
setLoading(true);
try {
const res = await identityApi.mobileStart(normalized);
setIntent(res.intent);
if (res.intent === "login" && signupMode) {
setSignupMode(false);
setError("این شماره قبلاً ثبت شده — کد ورود ارسال شد");
}
setCountdown(res.expires_in);
setStep("code");
} catch (err) {
setError(err instanceof Error ? err.message : "ارسال کد ناموفق بود");
} finally {
setLoading(false);
}
};
const handleVerify = useCallback(
async (codeOverride?: string) => {
if (verifyingRef.current) return;
const normalizedCode = normalizeOtpCode(codeOverride ?? code);
if (normalizedCode.length !== 4) {
setError("کد تأیید باید ۴ رقم باشد");
return;
}
if (intent === "register" && !validateProfile()) return;
verifyingRef.current = true;
setLoading(true);
let keepLoading = false;
try {
const session = await identityApi.mobileComplete({
mobile,
code: normalizedCode,
display_name: displayName.trim() || undefined,
email: showProfileFields && profileComplete ? email.trim() : undefined,
username: showProfileFields && profileComplete ? username.trim() : undefined,
password: showProfileFields && profileComplete ? password : undefined,
});
keepLoading = true;
await finishSession(session);
} catch (err) {
setError(err instanceof Error ? err.message : "تأیید کد ناموفق بود");
} finally {
if (!keepLoading) {
setLoading(false);
verifyingRef.current = false;
}
}
},
[
code,
displayName,
email,
finishSession,
intent,
mobile,
password,
profileComplete,
showProfileFields,
username,
validateProfile,
]
);
const handleCodeChange = (value: string) => {
const next = normalizeOtpCode(value);
setCode(next);
const canAutoVerify =
intent === "login" || (intent === "register" && profileComplete);
if (next.length === 4 && canAutoVerify && !verifyingRef.current && !loading) {
void handleVerify(next);
}
};
if (isAuthenticated) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/95 backdrop-blur-sm">
<div className="mx-4 w-full max-w-md rounded-2xl border border-gray-100 bg-white p-8 text-center shadow-xl">
<p className="font-semibold text-green-600">شما وارد شدهاید.</p>
<Link href={redirectTo ?? "/dashboard"} className="mt-6 inline-block">
<Button>ادامه</Button>
</Link>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-[var(--color-primary-soft)] via-white to-white px-4 py-8">
<div className="pointer-events-none absolute inset-0 bg-white/40 backdrop-blur-[2px]" />
<section className="relative w-full max-w-md rounded-2xl border border-gray-100 bg-white p-8 shadow-xl">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold text-secondary">
{signupMode ? "ثبت‌نام" : "ورود"}
</h1>
<p className="mt-2 text-sm text-gray-600">
{step === "phone"
? "شماره موبایل خود را وارد کنید"
: intent === "login"
? "کد ورود را وارد کنید"
: "کد تأیید و اطلاعات حساب را تکمیل کنید"}
</p>
</div>
{error && (
<p className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
)}
{step === "phone" ? (
<form onSubmit={handleRequestOtp} className="space-y-4">
<label className="block text-sm font-medium text-secondary">
شماره موبایل
<input
required
type="tel"
dir="ltr"
value={mobile}
onChange={(e) => setMobile(persianToEnglish(e.target.value))}
className="mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5"
placeholder="09123456789"
autoFocus
/>
</label>
{signupMode && (
<ProfileFields
displayName={displayName}
setDisplayName={setDisplayName}
email={email}
setEmail={setEmail}
username={username}
setUsername={setUsername}
password={password}
setPassword={setPassword}
/>
)}
<Button type="submit" className="w-full" loading={loading} loadingText="در حال ارسال...">
دریافت کد
</Button>
{!signupMode ? (
<button
type="button"
className="w-full text-sm text-primary hover:underline"
onClick={() => {
setSignupMode(true);
setError("");
}}
>
حساب کاربری ندارید؟ ثبتنام کنید
</button>
) : (
<button
type="button"
className="w-full text-sm text-gray-500 hover:underline"
onClick={() => {
setSignupMode(false);
setError("");
}}
>
حساب دارید؟ ورود
</button>
)}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-100" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white px-2 text-gray-400">یا</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setPasswordLoading(true);
void fetchLoginUrl().then((url) => window.location.assign(url));
}}
loading={passwordLoading}
loadingText="در حال انتقال..."
>
ورود با رمز عبور
</Button>
</form>
) : (
<div className="space-y-4">
<p className="text-center text-sm text-gray-600">
کد به{" "}
<span dir="ltr" className="font-mono font-semibold text-secondary">
{mobile}
</span>{" "}
ارسال شد
</p>
{countdown > 0 && (
<p className="text-center text-sm text-primary">ارسال مجدد تا {countdown} ثانیه</p>
)}
<input
type="text"
inputMode="numeric"
dir="ltr"
maxLength={4}
value={code}
autoFocus
onChange={(e) => handleCodeChange(e.target.value)}
className="w-full rounded-xl border border-gray-200 px-4 py-3 text-center font-mono text-lg tracking-[0.4em]"
placeholder="••••"
/>
{intent === "register" && !signupMode && (
<ProfileFields
displayName={displayName}
setDisplayName={setDisplayName}
email={email}
setEmail={setEmail}
username={username}
setUsername={setUsername}
password={password}
setPassword={setPassword}
/>
)}
<Button
className="w-full"
onClick={() => handleVerify(code)}
loading={loading}
loadingText={intent === "login" ? "در حال ورود..." : "در حال ثبت‌نام..."}
disabled={code.length !== 4 || (intent === "register" && !profileComplete)}
>
{intent === "login" ? "ورود" : "ثبت‌نام و ورود"}
</Button>
<button
type="button"
className="w-full text-sm text-primary hover:underline"
onClick={() => {
setStep("phone");
setCode("");
setError("");
}}
>
تغییر شماره موبایل
</button>
</div>
)}
<Link
href="/"
className="mt-6 block text-center text-sm text-gray-400 hover:text-gray-600 hover:underline"
>
بازگشت به صفحه اصلی
</Link>
</section>
</div>
);
}