From 9caa4d804e9cf932a09cd337bff8cd0a4690136d Mon Sep 17 00:00:00 2001 From: ali_mohaghegh Date: Sat, 29 Aug 2026 14:44:13 +0330 Subject: [PATCH] feat(frontend): in-page Casdoor login form + env-gated Developer Mode panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/.env.example | 5 + frontend/Dockerfile | 5 + frontend/index.html | 1 - frontend/public/casdoor-config.js | 9 - frontend/src/lib/api.ts | 38 ++++ frontend/src/lib/casdoor.ts | 79 ------- frontend/src/pages/Auth/LoginPage.tsx | 295 ++++++++++++++++++-------- frontend/src/store/auth.ts | 4 +- 8 files changed, 262 insertions(+), 174 deletions(-) delete mode 100644 frontend/public/casdoor-config.js delete mode 100644 frontend/src/lib/casdoor.ts diff --git a/frontend/.env.example b/frontend/.env.example index 55b08b6..cd17e26 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,3 +3,8 @@ # For local dev against a remote backend, set e.g.: # VITE_API_BASE_URL=http://localhost:8000/api/v1 VITE_API_BASE_URL= + +# Show the "Developer Mode" panel on the login page. +# true (default) / false — in Docker builds this is controlled by the +# DEV_LOGIN_ENABLED build arg (see docker-compose.yml), not this file. +VITE_ENABLE_DEV_LOGIN=true diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ab32a7b..7478d7d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,6 +13,11 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com COPY package.json package-lock.json ./ RUN npm config set registry ${NPM_REGISTRY} && npm ci +# Developer Mode panel on the login page (default enabled; set to +# "false" to build a production login without the dev panel). +ARG DEV_LOGIN_ENABLED=true +ENV VITE_ENABLE_DEV_LOGIN=${DEV_LOGIN_ENABLED} + # Source code + build COPY . . RUN npm run build diff --git a/frontend/index.html b/frontend/index.html index 86905be..c810dc5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,6 @@ rel="stylesheet" /> دفتر پیشنهادهای خرید — دفترچه تأمین -
diff --git a/frontend/public/casdoor-config.js b/frontend/public/casdoor-config.js deleted file mode 100644 index fc7b156..0000000 --- a/frontend/public/casdoor-config.js +++ /dev/null @@ -1,9 +0,0 @@ -// تنظیمات اتصال به Casdoor — v2.6 (مقادیر همان نسخه‌ی ارسالی داشبورد) -window.CASDOOR_CONFIG = { - serverUrl: "http://localhost:8080", - clientId: "inventory-app-client", - clientSecret: "inventory-app-secret-2026", - organization: "inventory", - appName: "inventory-app", - redirectUri: "http://localhost:3000/login" -}; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9dd12cf..eb90346 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1340,6 +1340,44 @@ export interface UserRow { } export const apiV26 = { + auth: { + // ورود درون‌صفحه‌ای: بک‌اند با grant_type=password از Casdoor توکن می‌گیرد + // و نقش/پروفایل را از جدول users خودمان برمی‌گرداند. + login: async (body: { + username: string; + password: string; + organization?: string; + }): Promise<{ access_token: string; expires_in: number | null; user: import("../store/auth").AuthUser }> => { + const res = await request<{ + data: { + access_token: string; + token_type: string; + expires_in: number | null; + user: { + id: string; + casdoor_id: string; + first_name: string; + last_name: string; + phone: string | null; + role: "pm" | "procurement_manager" | "admin" | null; + role_label: string | null; + project_id: string | null; + project_name: string | null; + }; + }; + }>("/auth/login", { method: "POST", body: JSON.stringify(body) }); + const u = res.data.user; + return { + access_token: res.data.access_token, + expires_in: res.data.expires_in, + user: { + ...u, + display_name: `${u.first_name} ${u.last_name}`.trim() || u.casdoor_id, + }, + }; + }, + }, + lookup: { categories: async (): Promise => getData("/material-categories?page_size=100"), units: async (): Promise => getData("/units?page_size=100"), diff --git a/frontend/src/lib/casdoor.ts b/frontend/src/lib/casdoor.ts deleted file mode 100644 index 38b2348..0000000 --- a/frontend/src/lib/casdoor.ts +++ /dev/null @@ -1,79 +0,0 @@ -// ============================================================ -// Casdoor OIDC + PKCE login — v2.6 -// Config lives in /casdoor-config.js (window.CASDOOR_CONFIG), served -// statically next to index.html (same values as the shipped dash). -// ============================================================ - -const CASDOOR_CONFIG = (window as unknown as { CASDOOR_CONFIG?: CasdoorConfig }).CASDOOR_CONFIG; - -export interface CasdoorConfig { - serverUrl: string; - clientId: string; - clientSecret: string; - organization: string; - appName: string; - redirectUri: string; -} - -function b64url(bytes: Uint8Array): string { - let s = ""; - for (const b of bytes) s += String.fromCharCode(b); - return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} - -function randomBytes(n: number): Uint8Array { - const arr = new Uint8Array(n); - crypto.getRandomValues(arr); - return arr; -} - -async function sha256b64url(text: string): Promise { - const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); - return b64url(new Uint8Array(buf)); -} - -export function startCasdoorLogin(): void { - if (!CASDOOR_CONFIG) { - alert("تنظیمات Casdoor یافت نشد — فایل casdoor-config.js را بررسی کنید"); - return; - } - const verifier = b64url(randomBytes(64)); - const state = b64url(randomBytes(16)); - sessionStorage.setItem("casdoor-pkce", JSON.stringify({ verifier, state })); - void sha256b64url(verifier).then((challenge) => { - const params = new URLSearchParams({ - client_id: CASDOOR_CONFIG.clientId, - redirect_uri: CASDOOR_CONFIG.redirectUri, - response_type: "code", - scope: "read", - state, - organization: CASDOOR_CONFIG.organization, - application: CASDOOR_CONFIG.appName, - code_challenge: challenge, - code_challenge_method: "S256", - }); - window.location.href = `${CASDOOR_CONFIG.serverUrl}/login/oauth/authorize?${params.toString()}`; - }); -} - -/** Exchange the OIDC authorization code for a token (called on /callback). */ -export async function exchangeCodeForToken(code: string): Promise<{ access_token: string; id_token?: string }> { - if (!CASDOOR_CONFIG) throw new Error("تنظیمات Casdoor یافت نشد"); - const pkce = JSON.parse(sessionStorage.getItem("casdoor-pkce") || "{}") as { verifier?: string }; - const resp = await fetch(`${CASDOOR_CONFIG.serverUrl}/api/login/oauth/access_token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: CASDOOR_CONFIG.clientId, - client_secret: CASDOOR_CONFIG.clientSecret, - code, - redirect_uri: CASDOOR_CONFIG.redirectUri, - code_verifier: pkce.verifier || "", - }), - }); - if (!resp.ok) throw new Error("خطا در دریافت توکن از Casdoor"); - return resp.json(); -} - -export { CASDOOR_CONFIG }; diff --git a/frontend/src/pages/Auth/LoginPage.tsx b/frontend/src/pages/Auth/LoginPage.tsx index 7e33a9b..2274dd5 100644 --- a/frontend/src/pages/Auth/LoginPage.tsx +++ b/frontend/src/pages/Auth/LoginPage.tsx @@ -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([]); - 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 ( -
-
- -
-

ورود با حساب سازمانی

-

- برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما - (مدیر پروژه / مدیر تدارکات) مشخص و دسترسی‌ها بر اساس آن فعال می‌شود. -

- -
+
+ {/* هاله‌ی تزئینی پس‌زمینه */} +
+
+
+
-
-

ورود آزمایشی (توسعه)

-

- بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بک‌اند ارسال می‌شود - (فقط برای توسعه و تست محلی). -

-
-
- - -
- {devRole === "pm" && ( -
- - + +
- )} - -
+
+
+ + +
+ {devRole === "pm" && ( +
+ + +
+ )} + +
+
+ )}
- + )} + + {/* ===== فرم اصلی ورود ===== */} +
+
+
+ + + +
+

سامانه تدارکات و انبار

+

+ برای ورود، نام کاربری و رمز عبور سازمانی خود را وارد کنید +

+
+
+ +
+
+ + setUsername(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
+ + setOrganization(e.target.value)} + /> +
+ +
+ +

+ ورود از طریق حساب سازمانی (Casdoor) انجام می‌شود. دسترسی شما بر اساس نقش تعیین‌شده + توسط مدیر سیستم فعال خواهد شد. +

+
+
); } diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index b4e3ef0..8a9d7c9 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,7 +1,7 @@ // ============================================================ -// Auth store — Casdoor OIDC + PKCE session (v2.6) +// Auth store — Casdoor session (v2.6, درون‌صفحه‌ای / password grant) // Dev mode: no token — identity sent via X-User-Role/X-Project-Id -// headers (backend dev fallback) so the dash works before Casdoor +// headers (backend dev fallback) so the app works before Casdoor // is fully seeded. // ============================================================