Files
ERP/frontend/src/pages/Auth/LoginPage.tsx
T
whqreza 593c149478 feat(auth): add password visibility toggle and remove the unused Organization field
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.
2026-09-15 16:29:54 +03:30

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>
);
}