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>
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|