Part 1 — show/hide password: an eye/eye-off button inside the login password input switches its type between password and text. It sits on the right (the input is LTR) with pr-10 clearance so dots never overlap the icon; type=button so a click cannot submit the form, aria-label Show/Hide password, aria-pressed + aria-controls, plus a Persian tooltip.
Part 2 — Organization field removed end-to-end after the audit proved it was a no-op: LoginPage input/state/payload spread (main + dev-mode admin login), api.ts payload type, backend LoginRequest.organization (declared but never forwarded to Casdoor — a bogus org value still logged in successfully), and the casdoor-auth.md flow diagram. The organization actually used remains CASDOOR_ORGANIZATION=inventory for /users/sync.
Verified: api + frontend images rebuilt and redeployed; login works with {username, password}; the served LoginPage chunk contains the toggle and zero org-field leftovers.
346 lines
17 KiB
TypeScript
346 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { toast } from "sonner";
|
|
import { ClipboardList, Eye, EyeOff, Loader2, Lock, ShieldCheck, Wrench, X } from "lucide-react";
|
|
import { api, apiV26 } from "../../lib/api";
|
|
import { useAuthStore } from "../../store/auth";
|
|
import type { Project } from "../../lib/types";
|
|
|
|
// ============================================================
|
|
// ورود به سامانه — Casdoor درونصفحهای (بدون ریدایرکت)
|
|
// فرم اصلی: Username / Password →
|
|
// POST /api/v1/auth/login (بکاند با grant_type=password از
|
|
// Casdoor توکن میگیرد و نقش را از جدول users خودمان برمیگرداند)
|
|
// حالت توسعه (فقط ادمین — سوپر یوزر) — چرخهی ۳ مرحلهای:
|
|
// مرحله ۱: درگاه احراز هویت ادمین (نام کاربری/رمز — فقط ادمین رد میشود)
|
|
// مرحله ۲: هاب انتخاب نقش (ادمین / مدیر تدارکات / مدیر پروژه + پروژه)
|
|
// مرحله ۳: ورود با نقش انتخابشده — ادمین → پنل مدیریت ادمین،
|
|
// مدیر تدارکات → دفتر، مدیر پروژه → پنل انبار پروژهی خودش
|
|
// با VITE_ENABLE_DEV_LOGIN=false غیرفعال میشود.
|
|
// ============================================================
|
|
|
|
const DEV_MODE_ENABLED = import.meta.env.VITE_ENABLE_DEV_LOGIN !== "false";
|
|
|
|
const DEV_ROLE_LABELS: Record<"pm" | "procurement_manager" | "admin", string> = {
|
|
procurement_manager: "مدیر تدارکات",
|
|
admin: "ادمین",
|
|
pm: "مدیر پروژه",
|
|
};
|
|
|
|
export function LoginPage() {
|
|
const setToken = useAuthStore((s) => s.setToken);
|
|
const setUser = useAuthStore((s) => s.setUser);
|
|
const setDevUser = useAuthStore((s) => s.setDevUser);
|
|
const navigate = useNavigate();
|
|
|
|
// ---- فرم اصلی (Casdoor) ----
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// ---- پنل حالت توسعه (۳ مرحله) ----
|
|
const [devOpen, setDevOpen] = useState(false);
|
|
const [devStep, setDevStep] = useState<"auth" | "hub">("auth");
|
|
const [devAdminUser, setDevAdminUser] = useState("");
|
|
const [devAdminPass, setDevAdminPass] = useState("");
|
|
const [devAuthLoading, setDevAuthLoading] = useState(false);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [devRole, setDevRole] = useState<"pm" | "procurement_manager" | "admin">("procurement_manager");
|
|
const [devProjectId, setDevProjectId] = useState("");
|
|
|
|
useEffect(() => {
|
|
if (DEV_MODE_ENABLED) {
|
|
void api.projects.list().then(setProjects).catch(() => setProjects([]));
|
|
}
|
|
}, []);
|
|
|
|
const devProject = useMemo(
|
|
() => projects.find((p) => p.id === devProjectId) || null,
|
|
[projects, devProjectId],
|
|
);
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!username.trim() || !password) {
|
|
toast.error("نام کاربری و رمز عبور را وارد کنید");
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const { access_token, user } = await apiV26.auth.login({
|
|
username: username.trim(),
|
|
password,
|
|
});
|
|
if (!user.role) {
|
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
|
return;
|
|
}
|
|
setToken(access_token);
|
|
setUser(user);
|
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
|
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
|
navigate(user.role === "pm" ? "/inventory" : "/");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// ---- مرحله ۱ حالت توسعه: درگاه ادمین ----
|
|
const devAuth = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!devAdminUser.trim() || !devAdminPass) {
|
|
toast.error("نام کاربری و رمز عبور ادمین را وارد کنید");
|
|
return;
|
|
}
|
|
setDevAuthLoading(true);
|
|
try {
|
|
const { user: adminUser } = await apiV26.auth.login({
|
|
username: devAdminUser.trim(),
|
|
password: devAdminPass,
|
|
});
|
|
if (adminUser.role !== "admin") {
|
|
toast.error("حالت توسعه فقط برای ادمین (سوپر یوزر) در دسترس است");
|
|
return;
|
|
}
|
|
setDevStep("hub");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "خطا در ورود ادمین");
|
|
} finally {
|
|
setDevAuthLoading(false);
|
|
}
|
|
};
|
|
|
|
// ---- مرحله ۳ حالت توسعه: ورود با نقش انتخابشده ----
|
|
const devLogin = () => {
|
|
if (devRole === "pm" && !devProjectId) {
|
|
toast.error("برای نقش مدیر پروژه، پروژه را انتخاب کنید");
|
|
return;
|
|
}
|
|
setDevUser(devRole, devRole === "pm" ? devProjectId : null, devRole === "pm" ? devProject?.name || null : null);
|
|
toast.success(`ورود آزمایشی انجام شد — نقش: ${DEV_ROLE_LABELS[devRole]}`);
|
|
// ادمین → پنل مدیریت ادمین؛ مدیر تدارکات → دفتر؛ مدیر پروژه → پنل انبار
|
|
if (devRole === "admin") navigate("/admin");
|
|
else if (devRole === "pm") navigate("/inventory");
|
|
else navigate("/");
|
|
};
|
|
|
|
const closeDev = () => {
|
|
setDevOpen(false);
|
|
setDevStep("auth");
|
|
setDevAdminUser("");
|
|
setDevAdminPass("");
|
|
};
|
|
|
|
return (
|
|
<div className="relative flex min-h-screen items-center justify-center bg-bg px-4 py-10">
|
|
{/* هالهی تزئینی پسزمینه */}
|
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
|
<div className="absolute -top-40 left-1/2 h-80 w-[42rem] -translate-x-1/2 rounded-full bg-accent/10 blur-3xl" />
|
|
<div className="absolute -bottom-48 right-1/4 h-72 w-96 rounded-full bg-green/10 blur-3xl" />
|
|
</div>
|
|
|
|
{/* ===== Developer Mode (بالا-چپ، جدا از فرم اصلی) ===== */}
|
|
{DEV_MODE_ENABLED && (
|
|
<div className="fixed left-4 top-4 z-50">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDevOpen((v) => !v)}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-dashed border-amber/50 bg-amber-bg/60 px-3 py-1.5 text-[12px] font-semibold text-amber transition-colors hover:bg-amber-bg"
|
|
>
|
|
<Wrench className="h-3.5 w-3.5" />
|
|
Developer Mode
|
|
</button>
|
|
{devOpen && (
|
|
<div className="absolute left-0 top-full mt-2 w-80 rounded-xl border border-dashed border-amber/40 bg-panel p-4 shadow-lg">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-[13px] font-bold text-amber">
|
|
{devStep === "auth" ? "مرحله ۱ — درگاه ادمین (سوپر یوزر)" : "مرحله ۲ — انتخاب نقش"}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={closeDev}
|
|
className="cursor-pointer rounded-md p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
|
aria-label="بستن پنل توسعه"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{devStep === "auth" ? (
|
|
<form onSubmit={devAuth} className="grid gap-3">
|
|
<p className="text-[11.5px] leading-5 text-muted">
|
|
ورود به حالت توسعه فقط با حساب <b>ادمین</b> (سوپر یوزر) ممکن است — سپس نقش دلخواه را
|
|
برای تست انتخاب کنید.
|
|
</p>
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted">نام کاربری ادمین</label>
|
|
<input
|
|
type="text"
|
|
dir="ltr"
|
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
placeholder="admin"
|
|
value={devAdminUser}
|
|
onChange={(e) => setDevAdminUser(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted">رمز عبور ادمین</label>
|
|
<input
|
|
type="password"
|
|
dir="ltr"
|
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
placeholder="••••••"
|
|
value={devAdminPass}
|
|
onChange={(e) => setDevAdminPass(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={devAuthLoading}
|
|
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg bg-amber px-3 text-[13px] font-bold text-white transition-colors hover:bg-amber/90 disabled:opacity-50"
|
|
>
|
|
{devAuthLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
<Lock className="h-3.5 w-3.5" />
|
|
تأیید هویت ادمین
|
|
</button>
|
|
</form>
|
|
) : (
|
|
<div className="grid gap-3">
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted">نقش</label>
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
value={devRole}
|
|
onChange={(e) => setDevRole(e.target.value as "pm" | "procurement_manager" | "admin")}
|
|
>
|
|
<option value="admin">ادمین — پنل مدیریت ادمین</option>
|
|
<option value="procurement_manager">مدیر تدارکات</option>
|
|
<option value="pm">مدیر پروژه</option>
|
|
</select>
|
|
</div>
|
|
{devRole === "pm" && (
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted">پروژه</label>
|
|
<select
|
|
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
value={devProjectId}
|
|
onChange={(e) => setDevProjectId(e.target.value)}
|
|
>
|
|
<option value="">— انتخاب پروژه —</option>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={devLogin}
|
|
disabled={devRole === "pm" && !devProjectId}
|
|
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg bg-amber px-3 text-[13px] font-bold text-white transition-colors hover:bg-amber/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|
ورود به حالت توسعه
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDevStep("auth")}
|
|
className="cursor-pointer text-center text-[11.5px] text-muted hover:text-ink"
|
|
>
|
|
→ بازگشت به درگاه ادمین
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ===== فرم اصلی ورود ===== */}
|
|
<main className="relative w-full max-w-md">
|
|
<div className="rounded-2xl border border-line bg-panel p-8 shadow-sm">
|
|
<div className="mb-7 flex flex-col items-center gap-3 text-center">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-accent-bg text-accent">
|
|
<ClipboardList className="h-6 w-6" strokeWidth={2} />
|
|
</span>
|
|
<div>
|
|
<h1 className="text-[19px] font-extrabold leading-7">سامانه تدارکات و انبار</h1>
|
|
<p className="mt-1 text-[12.5px] leading-6 text-muted">
|
|
برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={submit} className="grid gap-4">
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-username">
|
|
نام کاربری
|
|
</label>
|
|
<input
|
|
id="login-username"
|
|
type="text"
|
|
autoComplete="username"
|
|
dir="ltr"
|
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
placeholder="username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="field">
|
|
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-password">
|
|
رمز عبور
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id="login-password"
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="current-password"
|
|
dir="ltr"
|
|
className="h-10 w-full rounded-lg border border-line bg-panel px-3 pr-10 text-left text-sm shadow-sm placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
placeholder="••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((v) => !v)}
|
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
|
aria-pressed={showPassword}
|
|
aria-controls="login-password"
|
|
title={showPassword ? "پنهان کردن رمز" : "نمایش رمز"}
|
|
className="absolute inset-y-0 right-0 flex cursor-pointer items-center rounded-lg px-3 text-muted transition-colors hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
|
) : (
|
|
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={loading}
|
|
className="mt-1 inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 text-[13.5px] font-bold text-white shadow-sm transition-colors hover:bg-accent-dark disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{loading ? "در حال ورود…" : "ورود"}
|
|
</button>
|
|
</form>
|
|
|
|
<p className="mt-6 text-center text-[11px] leading-5 text-muted">
|
|
ورود از طریق حساب سازمانی (Casdoor) انجام میشود. دسترسی شما بر اساس نقش تعیینشده
|
|
توسط مدیر سیستم فعال خواهد شد.
|
|
</p>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|