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:
@@ -3,3 +3,8 @@
|
|||||||
# For local dev against a remote backend, set e.g.:
|
# For local dev against a remote backend, set e.g.:
|
||||||
# VITE_API_BASE_URL=http://localhost:8000/api/v1
|
# VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||||
VITE_API_BASE_URL=
|
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
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ ARG NPM_REGISTRY=https://registry.npmmirror.com
|
|||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm config set registry ${NPM_REGISTRY} && npm ci
|
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
|
# Source code + build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
<title>دفتر پیشنهادهای خرید — دفترچه تأمین</title>
|
||||||
<script src="/casdoor-config.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
};
|
|
||||||
@@ -1340,6 +1340,44 @@ export interface UserRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const apiV26 = {
|
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: {
|
lookup: {
|
||||||
categories: async (): Promise<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
|
categories: async (): Promise<RawCat[]> => getData<RawCat[]>("/material-categories?page_size=100"),
|
||||||
units: async (): Promise<RawUnitL[]> => getData<RawUnitL[]>("/units?page_size=100"),
|
units: async (): Promise<RawUnitL[]> => getData<RawUnitL[]>("/units?page_size=100"),
|
||||||
|
|||||||
@@ -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<string> {
|
|
||||||
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 };
|
|
||||||
@@ -1,28 +1,50 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "../../components/ui/button";
|
import { ClipboardList, Loader2, Wrench, X } from "lucide-react";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { api, apiV26 } from "../../lib/api";
|
||||||
import { startCasdoorLogin } from "../../lib/casdoor";
|
|
||||||
import { api } from "../../lib/api";
|
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
import type { Project } from "../../lib/types";
|
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() {
|
export function LoginPage() {
|
||||||
const setToken = useAuthStore((s) => s.setToken);
|
const setToken = useAuthStore((s) => s.setToken);
|
||||||
const setUser = useAuthStore((s) => s.setUser);
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
const setDevUser = useAuthStore((s) => s.setDevUser);
|
const setDevUser = useAuthStore((s) => s.setDevUser);
|
||||||
const navigate = useNavigate();
|
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 [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("");
|
const [devProjectId, setDevProjectId] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.projects.list().then(setProjects).catch(() => setProjects([]));
|
if (DEV_MODE_ENABLED) {
|
||||||
|
void api.projects.list().then(setProjects).catch(() => setProjects([]));
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const devProject = useMemo(
|
const devProject = useMemo(
|
||||||
@@ -30,90 +52,197 @@ export function LoginPage() {
|
|||||||
[projects, devProjectId],
|
[projects, devProjectId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Callback: ?code=...&state=...
|
const submit = async (e: React.FormEvent) => {
|
||||||
useEffect(() => {
|
e.preventDefault();
|
||||||
const code = params.get("code");
|
if (!username.trim() || !password) {
|
||||||
if (!code) return;
|
toast.error("نام کاربری و رمز عبور را وارد کنید");
|
||||||
void (async () => {
|
return;
|
||||||
try {
|
}
|
||||||
const { exchangeCodeForToken } = await import("../../lib/casdoor");
|
setLoading(true);
|
||||||
const tok = await exchangeCodeForToken(code);
|
try {
|
||||||
setToken(tok.access_token);
|
const { access_token, user } = await apiV26.auth.login({
|
||||||
// fetch profile from our backend
|
username: username.trim(),
|
||||||
const { apiV26 } = await import("../../lib/api");
|
password,
|
||||||
const profile = await apiV26.users.me();
|
...(organization.trim() ? { organization: organization.trim() } : {}),
|
||||||
setUser(profile);
|
});
|
||||||
toast.success(`خوش آمدید، ${profile.display_name}`);
|
if (!user.role) {
|
||||||
// مدیر پروژه → پنل انبار؛ بقیه → دفتر پیشنهادهای خرید
|
toast.error("نقش شما هنوز در سیستم تعیین نشده است — با مدیر سیستم تماس بگیرید");
|
||||||
navigate(profile.role === "pm" ? "/inventory" : "/");
|
return;
|
||||||
} catch (err) {
|
|
||||||
toast.error(`خطا در ورود: ${err instanceof Error ? err.message : "خطای ناشناخته"}`);
|
|
||||||
}
|
}
|
||||||
})();
|
setToken(access_token);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setUser(user);
|
||||||
}, [params]);
|
toast.success(`خوش آمدید، ${user.display_name}`);
|
||||||
|
// مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر پیشنهادهای خرید
|
||||||
|
navigate(user.role === "pm" ? "/inventory" : "/");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "خطا در ورود");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const devLogin = () => {
|
const devLogin = () => {
|
||||||
setDevUser(devRole, devRole === "pm" ? devProjectId || null : null, devProject?.name || null);
|
if (devRole === "pm" && !devProjectId) {
|
||||||
toast.success("ورود آزمایشی انجام شد — نقش: " + (devRole === "pm" ? "مدیر پروژه" : "مدیر تدارکات"));
|
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" : "/");
|
navigate(devRole === "pm" ? "/inventory" : "/");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="relative flex min-h-screen items-center justify-center bg-bg px-4 py-10">
|
||||||
<Header title="ورود به سامانه" sub="احراز هویت با حساب سازمانی (Casdoor)" />
|
{/* هالهی تزئینی پسزمینه */}
|
||||||
<PageLayout className="flex max-w-md flex-col items-start gap-4">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
<div className="rounded-xl border border-line bg-panel p-4 shadow-sm">
|
<div className="absolute -top-40 left-1/2 h-80 w-[42rem] -translate-x-1/2 rounded-full bg-accent/10 blur-3xl" />
|
||||||
<h3 className="mb-1 text-[14px] font-bold">ورود با حساب سازمانی</h3>
|
<div className="absolute -bottom-48 right-1/4 h-72 w-96 rounded-full bg-green/10 blur-3xl" />
|
||||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
</div>
|
||||||
برای ورود به سامانه تدارکات و انبار، با حساب سازمانی خود وارد شوید. پس از ورود، نقش شما
|
|
||||||
(مدیر پروژه / مدیر تدارکات) مشخص و دسترسیها بر اساس آن فعال میشود.
|
|
||||||
</p>
|
|
||||||
<Button onClick={() => void startCasdoorLogin()}>ورود با Casdoor</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-dashed border-accent/40 bg-blue-bg/20 p-4">
|
{/* ===== Developer Mode (بالا-چپ، جدا از فرم اصلی) ===== */}
|
||||||
<h3 className="mb-1 text-[14px] font-bold">ورود آزمایشی (توسعه)</h3>
|
{DEV_MODE_ENABLED && (
|
||||||
<p className="mb-3 text-[12.5px] leading-6 text-muted">
|
<div className="fixed left-4 top-4 z-50">
|
||||||
بدون Casdoor وارد شوید — نقش و پروژه از طریق هدرهای توسعه به بکاند ارسال میشود
|
<button
|
||||||
(فقط برای توسعه و تست محلی).
|
type="button"
|
||||||
</p>
|
onClick={() => setDevOpen((v) => !v)}
|
||||||
<div className="grid gap-3">
|
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"
|
||||||
<div className="field">
|
>
|
||||||
<label>نقش</label>
|
<Wrench className="h-3.5 w-3.5" />
|
||||||
<select
|
Developer Mode
|
||||||
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"
|
</button>
|
||||||
value={devRole}
|
{devOpen && (
|
||||||
onChange={(e) => setDevRole(e.target.value as "pm" | "procurement_manager")}
|
<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">
|
||||||
<option value="pm">مدیر پروژه</option>
|
<h3 className="text-[13px] font-bold text-amber">پنل توسعه (ورود آزمایشی)</h3>
|
||||||
<option value="procurement_manager">مدیر تدارکات</option>
|
<button
|
||||||
</select>
|
type="button"
|
||||||
</div>
|
onClick={() => setDevOpen(false)}
|
||||||
{devRole === "pm" && (
|
className="cursor-pointer rounded-md p-1 text-muted hover:bg-slate-bg hover:text-ink"
|
||||||
<div className="field">
|
aria-label="بستن پنل توسعه"
|
||||||
<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)}
|
|
||||||
>
|
>
|
||||||
<option value="">— انتخاب پروژه —</option>
|
<X className="h-4 w-4" />
|
||||||
{projects.map((p) => (
|
</button>
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="grid gap-3">
|
||||||
<Button variant="green" onClick={devLogin} disabled={devRole === "pm" && !devProjectId}>
|
<div className="field">
|
||||||
ورود آزمایشی
|
<label className="mb-1 block text-[12px] font-semibold text-muted">نقش</label>
|
||||||
</Button>
|
<select
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// is fully seeded.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user