feat(frontend): in-page Casdoor login form + env-gated Developer Mode panel

- redesigned /login (Tailwind, RTL): centered username/password/organization
  form posting to POST /api/v1/auth/login — session starts without leaving
  the page; PM lands on the inventory panel, others on the procurement office
- Developer Mode button fixed top-left opens a role/project test panel
  (admin role added) gated by VITE_ENABLE_DEV_LOGIN (DEV_LOGIN_ENABLED build
  arg in Docker, default enabled)
- removed the old Casdoor redirect button, the PKCE ?code= callback, and the
  public casdoor-config.js — the client secret is no longer shipped to the
  browser; backend mediates the password grant instead
This commit is contained in:
ali_mohaghegh
2026-08-29 14:44:13 +03:30
parent fde2168606
commit 9caa4d804e
8 changed files with 262 additions and 174 deletions
+212 -83
View File
@@ -1,28 +1,50 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { Header, PageLayout } from "../../components/layout/Header";
import { startCasdoorLogin } from "../../lib/casdoor";
import { api } from "../../lib/api";
import { ClipboardList, Loader2, Wrench, X } from "lucide-react";
import { api, apiV26 } from "../../lib/api";
import { useAuthStore } from "../../store/auth";
import type { Project } from "../../lib/types";
// ورود به سامانه — Casdoor (OIDC + PKCE) + ورود آزمایشی برای توسعه
// ============================================================
// ورود به سامانه — Casdoor درون‌صفحه‌ای (بدون ریدایرکت)
// فرم اصلی: Username / Password / Organization (اختیاری) →
// 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();
const [params] = useSearchParams();
// ورود آزمایشی: پروژه‌ها از بک‌اند خوانده می‌شوند
// ---- فرم اصلی (Casdoor) ----
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [organization, setOrganization] = useState("");
const [loading, setLoading] = useState(false);
// ---- پنل حالت توسعه ----
const [devOpen, setDevOpen] = useState(false);
const [projects, setProjects] = useState<Project[]>([]);
const [devRole, setDevRole] = useState<"pm" | "procurement_manager">("pm");
const [devRole, setDevRole] = useState<"pm" | "procurement_manager" | "admin">("procurement_manager");
const [devProjectId, setDevProjectId] = useState("");
useEffect(() => {
void api.projects.list().then(setProjects).catch(() => setProjects([]));
if (DEV_MODE_ENABLED) {
void api.projects.list().then(setProjects).catch(() => setProjects([]));
}
}, []);
const devProject = useMemo(
@@ -30,90 +52,197 @@ export function LoginPage() {
[projects, devProjectId],
);
// Callback: ?code=...&state=...
useEffect(() => {
const code = params.get("code");
if (!code) return;
void (async () => {
try {
const { exchangeCodeForToken } = await import("../../lib/casdoor");
const tok = await exchangeCodeForToken(code);
setToken(tok.access_token);
// fetch profile from our backend
const { apiV26 } = await import("../../lib/api");
const profile = await apiV26.users.me();
setUser(profile);
toast.success(`خوش آمدید، ${profile.display_name}`);
// مدیر پروژه → پنل انبار؛ بقیه → دفتر پیشنهادهای خرید
navigate(profile.role === "pm" ? "/inventory" : "/");
} catch (err) {
toast.error(`خطا در ورود: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
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,
...(organization.trim() ? { organization: organization.trim() } : {}),
});
if (!user.role) {
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
return;
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params]);
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 devLogin = () => {
setDevUser(devRole, devRole === "pm" ? devProjectId || null : null, devProject?.name || null);
toast.success("ورود آزمایشی انجام شد — نقش: " + (devRole === "pm" ? "مدیر پروژه" : "مدیر تدارکات"));
// مدیر پروژه → پنل انبار؛ مدیر تدارکات → دفتر پیشنهادهای خرید
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]}`);
navigate(devRole === "pm" ? "/inventory" : "/");
};
return (
<div className="min-h-screen">
<Header title="ورود به سامانه" sub="احراز هویت با حساب سازمانی (Casdoor)" />
<PageLayout className="flex max-w-md flex-col items-start gap-4">
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
<h3 className="mb-1 text-[14px] font-bold">ورود با حساب سازمانی</h3>
<p className="mb-3 text-[12.5px] leading-6 text-muted">
برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما
(مدیر پروژه / مدیر تدارکات) مشخص و دسترسی‌ها بر اساس آن فعال می‌شود.
</p>
<Button onClick={() => void startCasdoorLogin()}>ورود با Casdoor</Button>
</div>
<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>
<div className="rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-4">
<h3 className="mb-1 text-[14px] font-bold">ورود آزمایشی (توسعه)</h3>
<p className="mb-3 text-[12.5px] leading-6 text-muted">
بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بک‌اند ارسال می‌شود
(فقط برای توسعه و تست محلی).
</p>
<div className="grid gap-3">
<div className="field">
<label>نقش</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")}
>
<option value="pm">مدیر پروژه</option>
<option value="procurement_manager">مدیر تدارکات</option>
</select>
</div>
{devRole === "pm" && (
<div className="field">
<label>پروژه</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)}
{/* ===== 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">پنل توسعه (ورود آزمایشی)</h3>
<button
type="button"
onClick={() => setDevOpen(false)}
className="cursor-pointer rounded-md p-1 text-muted hover:bg-slate-bg hover:text-ink"
aria-label="بستن پنل توسعه"
>
<option value="">— انتخاب پروژه —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<X className="h-4 w-4" />
</button>
</div>
)}
<Button variant="green" onClick={devLogin} disabled={devRole === "pm" && !devProjectId}>
ورود آزمایشی
</Button>
</div>
<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="procurement_manager">مدیر تدارکات</option>
<option value="admin">ادمین</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 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"
>
ورود آزمایشی
</button>
</div>
</div>
)}
</div>
</PageLayout>
)}
{/* ===== فرم اصلی ورود ===== */}
<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>
<input
id="login-password"
type="password"
autoComplete="current-password"
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="••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="field">
<label className="mb-1 block text-[12px] font-semibold text-muted" htmlFor="login-org">
سازمان (اختیاری)
</label>
<input
id="login-org"
type="text"
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="inventory"
value={organization}
onChange={(e) => setOrganization(e.target.value)}
/>
</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>
);
}