feat(security): JWT required on every API route, gated dev fallback, role checks

- main.py: global get_current_user dependency on all routers except
  /auth/login and /health; prod fail-fast startup guard rejects wildcard
  CORS, default credentials, DEV_LOGIN_ENABLED
- inventory/pm: X-User-Role header identity fallback now only active when
  PROCUREMENT_DEV_LOGIN_ENABLED=true (was unconditional in production)
- item_decisions writes + attachment deletes: require_role(
  procurement_manager, admin) — require_role was defined but never used
- config.py: ENVIRONMENT flag, comma-separated CORS_ORIGINS, no default
  Casdoor client secret/admin password, DEV_LOGIN_ENABLED=false
- frontend: authenticated file downloads (Bearer + blob) — <a href> links
  cannot send the token; FileList/QuoteDetail/AcceptModal/EditModal/
  PurchaseHistory converted; DEV_LOGIN_ENABLED build arg defaults false
- smoke tests authenticate via POST /auth/login (SMOKE_USER/SMOKE_PASSWORD)
- traefik: security headers, API rate limit, 12MB body cap (prod overlay)
- nginx client_max_body_size aligned to 12MB
This commit is contained in:
MMD
2026-09-18 22:02:09 +03:30
parent 43e317eeba
commit f7b1f7f751
16 changed files with 225 additions and 47 deletions
+15 -6
View File
@@ -9,13 +9,13 @@ export interface FileListProps {
upload: (file: File) => Promise<Attachment>;
// حذف سمت سرور (اختیاری — بعد از حذف محلی صدا زده می‌شود)
onDelete?: (a: Attachment) => void;
// لینک دانلود؛ null یعنی هنوز آپلود نشده
urlOf?: (a: Attachment) => string | null;
// دانلود احراز هویت‌شده (توکن Bearer + blob — لینک خام توکن نمی‌فرستد)
onDownload?: (a: Attachment) => void | Promise<void>;
label?: string;
}
// مدیریت چند فاکتور: آپلود مستقیم به سرور (MinIO) + دانلود/حذف فایل‌های ذخیره‌شده
export function FileList({ files, onChange, upload, onDelete, urlOf, label = "افزودن فاکتور" }: FileListProps) {
export function FileList({ files, onChange, upload, onDelete, onDownload, label = "افزودن فاکتور" }: FileListProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
const remove = (i: number) => {
@@ -45,10 +45,19 @@ export function FileList({ files, onChange, upload, onDelete, urlOf, label = "ا
{files.map((inv, i) => (
<div key={i} className="inv-row">
<span className="inv-name">📎 {inv.name}</span>
{urlOf?.(inv) && (
<a className="inv-open" href={urlOf(inv)!} download={inv.name} title="دانلود">
{onDownload && (
<button
type="button"
className="inv-open"
title="دانلود"
onClick={() => {
Promise.resolve(onDownload(inv)).catch((err) =>
toast.error(err instanceof Error ? err.message : "دانلود فایل ناموفق بود"),
);
}}
>
↓
</a>
</button>
)}
<button type="button" className="inv-x" title="حذف" onClick={() => remove(i)}>
×
+25 -2
View File
@@ -1282,8 +1282,31 @@ export const api = {
remove: async (quoteId: string, kind: FileKind, id: string): Promise<void> => {
await deleteData(`/quotes/${quoteId}/${kind}/${id}`);
},
downloadUrl: (quoteId: string, kind: FileKind, id: string): string =>
`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`,
// دانلود احراز هویت‌شده: <a href> توکن Authorization نمی‌فرستد، پس بایت‌ها
// را با توکن می‌گیریم و به شکل blob در مرورگر ذخیره می‌کنیم.
download: async (quoteId: string, kind: FileKind, id: string, fileName: string): Promise<void> => {
let res: Response;
try {
res = await fetch(`${BASE_URL}/quotes/${quoteId}/${kind}/${id}`, {
headers: authHeaders(),
});
} catch {
throw new ApiError("اتصال به سرور برقرار نشد — آیا بک‌اند در دسترس است؟", 0);
}
if (!res.ok) throw new ApiError(faHttpError(res.status, "خطای سرور"), res.status);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = fileName || "attachment";
document.body.appendChild(a);
a.click();
a.remove();
} finally {
URL.revokeObjectURL(url);
}
},
},
// ---------- برندها (از بک‌اند) ----------
+2 -2
View File
@@ -397,7 +397,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
/>
</div>
@@ -414,7 +414,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
toast.error(err instanceof Error ? err.message : "حذف پیش‌فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
onDownload={(a) => api.files.download(q.id, "proforma", a.id!, a.name)}
label="افزودن پیش‌فاکتور"
/>
</div>
+1 -1
View File
@@ -720,7 +720,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
toast.error(err instanceof Error ? err.message : "حذف فاکتور ناموفق بود"),
)
}
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "invoice", a.id) : null)}
onDownload={(a) => api.files.download(q.id, "invoice", a.id!, a.name)}
/>
</div>
+16 -4
View File
@@ -144,19 +144,31 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) {
attachments.proformas.forEach((pf, i) => {
if (!pf.id) return;
attachParts.push(
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
<button
key={"pf" + i}
type="button"
className="detail-link"
title="دانلود پیش‌فاکتور"
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
پیش‌فاکتور: {pf.name}
</a>,
</button>,
);
});
attachments.invoices.forEach((inv, i) => {
if (!inv.id) return;
attachParts.push(
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
<button
key={"inv" + i}
type="button"
className="detail-link"
title="دانلود فاکتور"
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
فاکتور اصلی: {inv.name}
</a>,
</button>,
);
});
@@ -112,28 +112,28 @@ export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistory
) : (
<>
{profs.map((pf) => (
<a
<button
key={"pf-" + pf.id}
type="button"
className="detail-link"
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
download={pf.name}
title="دانلود پیش‌فاکتور"
onClick={() => api.files.download(q.id, "proforma", pf.id!, pf.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
پیش‌فاکتور: {pf.name}
</a>
</button>
))}
{invs.map((inv) => (
<a
<button
key={"inv-" + inv.id}
type="button"
className="detail-link"
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
download={inv.name}
title="دانلود فاکتور"
onClick={() => api.files.download(q.id, "invoice", inv.id!, inv.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
فاکتور: {inv.name}
</a>
</button>
))}
{!loading && !profs.length && !invs.length && (
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>