OpenVuln / src /features /project /OwnerFindings.tsx
zRzRzRzRzRzRzR
feat: sync latest OpenVuln frontend
d22337b
Raw
History Blame Contribute Delete
13.2 kB
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ChevronDown,
Download,
FileCode2,
LoaderCircle,
Lock,
ShieldCheck,
} from "lucide-react";
import {
api,
apiUrl,
type OwnerFindingSummary,
} from "../../shared/api/client";
import { Button } from "../../components/Button";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { ReportBody, type StructuredReport } from "../../components/ReportBody";
import { SeverityChip } from "../../components/SeverityChip";
import type { ViewJob } from "./VersionBar";
/**
* Owner 专属「Manage findings」:全部 findings(含未披露)+ 勾选披露 + 全量报告下载。
* 仅在后端鉴权通过(父组件 owner 查询成功)时渲染。
*/
export function OwnerFindings({
projectId,
currentFindings,
viewJob,
onViewJob,
}: {
projectId: string;
/** 父级 access probe 已拉的当前版本 findings(兼作 current 的 initialData)。 */
currentFindings: OwnerFindingSummary[];
/** 版本查看状态(页面级,头部 VersionBar 控制)。 */
viewJob: ViewJob | null;
onViewJob: (v: ViewJob | null) => void;
}) {
const qc = useQueryClient();
const setViewJob = onViewJob;
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
const [openKey, setOpenKey] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [flash, setFlash] = useState<string | null>(null);
// 版本化查询:viewJob=null → 当前版本
const findingsQ = useQuery({
queryKey: ["owner-findings", projectId, viewJob?.id ?? "current"],
queryFn: () => api.ownerFindings(projectId, viewJob?.id),
initialData: viewJob === null ? { project_id: projectId, findings: currentFindings } : undefined,
retry: false,
});
const findings = findingsQ.data?.findings ?? currentFindings;
const viewingHistorical = viewJob !== null;
const disclosable = useMemo(
() => findings.filter((f) => f.disclosure_state !== "disclosed"),
[findings],
);
const allChecked = disclosable.length > 0 && disclosable.every((f) => selected.has(f.id));
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleAll = () => {
setSelected(allChecked ? new Set() : new Set(disclosable.map((f) => f.id)));
};
const discloseM = useMutation({
mutationFn: (ids: string[]) => api.ownerDisclose(projectId, ids),
onSuccess: (res) => {
setConfirmOpen(false);
setSelected(new Set());
setFlash(
`${res.disclosed_count} finding${res.disclosed_count === 1 ? "" : "s"} disclosed — now publicly visible with full report content.`,
);
void qc.invalidateQueries({ queryKey: ["owner-findings", projectId] });
void qc.invalidateQueries({ queryKey: ["public", "project"] });
void qc.invalidateQueries({ queryKey: ["public", "overview"] });
},
});
return (
<div className="space-y-4">
{/* 工具行 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="font-display text-base font-semibold text-ink">
All findings ({findings.length})
</h2>
<a
href={apiUrl(`/api/projects/${projectId}/report-full?format=md${viewJob ? `&scan_job_id=${viewJob.id}` : ""}`)}
className="inline-flex h-9 items-center gap-1.5 rounded-md border border-line bg-surface-raised px-3.5 text-sm font-medium text-ink transition-colors hover:bg-surface-sunken focus-ring"
title="Download the complete report for all findings (markdown)"
>
<Download size={14} /> Full report (.md)
</a>
</div>
{viewingHistorical && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-accent-200 bg-accent-50 px-4 py-2.5 text-[13px] text-accent-600">
<span>
Viewing version <span className="font-mono font-medium">{viewJob.label}</span> — disclosure is
only available on the current version.
</span>
<button
type="button"
onClick={() => {
setViewJob(null);
setSelected(new Set());
setOpenKey(null);
}}
className="shrink-0 font-medium underline underline-offset-2 hover:text-accent-700"
>
Back to current
</button>
</div>
)}
{/* 披露操作条(历史版本只读,披露仅当前版本) */}
{!viewingHistorical && disclosable.length > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-line bg-surface-sunken/50 px-4 py-2.5">
<label className="flex cursor-pointer items-center gap-2 text-[13px] text-ink-secondary">
<input
type="checkbox"
checked={allChecked}
onChange={toggleAll}
className="h-4 w-4 rounded border-line accent-[#EDEFF4]"
/>
Select undisclosed ({disclosable.length})
</label>
<span className="text-[13px] text-ink-tertiary" aria-live="polite">
{selected.size > 0 ? `${selected.size} selected` : ""}
</span>
<div className="ml-auto flex items-center gap-3">
<Button
size="md"
disabled={selected.size === 0}
onClick={() => setConfirmOpen(true)}
>
<ShieldCheck size={15} />
Disclose{selected.size > 0 ? ` (${selected.size})` : ""}
</Button>
</div>
</div>
)}
{flash && (
<p
className="flex items-start gap-1.5 rounded-lg border border-success/30 bg-success-bg px-4 py-2.5 text-[13px] text-success-ink"
role="status"
>
<CheckCircle2 size={15} className="mt-0.5 shrink-0" />
{flash}
</p>
)}
{discloseM.isError && (
<p className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-2.5 text-[13px] text-danger" role="alert">
Disclosure failed. Please try again — if it persists, contact an operator.
</p>
)}
{/* findings 卡片 */}
{findings.length === 0 ? (
<div className="rounded-xl border border-dashed border-line px-6 py-10 text-center text-sm text-ink-secondary">
No findings on the current scan.
</div>
) : (
<div className="space-y-3">
{findings.map((f) => {
const disclosed = f.disclosure_state === "disclosed";
const open = openKey === f.finding_key;
return (
<article
key={f.id}
className="overflow-hidden rounded-xl border border-line bg-surface"
>
<div
role="button"
tabIndex={0}
onClick={() => setOpenKey(open ? null : f.finding_key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpenKey(open ? null : f.finding_key);
}
}}
aria-expanded={open}
className="flex w-full cursor-pointer items-start gap-3 px-5 py-3 text-left transition-colors hover:bg-surface-sunken/50 focus-ring"
>
<input
type="checkbox"
checked={selected.has(f.id)}
disabled={disclosed || viewingHistorical}
onClick={(e) => e.stopPropagation()}
onChange={() => toggle(f.id)}
aria-label={disclosed ? `${f.finding_key} already disclosed` : `Select ${f.finding_key}`}
className="mt-1 h-4 w-4 shrink-0 rounded border-line accent-[#EDEFF4] disabled:opacity-40"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SeverityChip severity={f.severity} />
{f.cwe && (
<span className="rounded bg-surface-sunken px-1.5 py-0.5 font-mono text-[11px] text-ink-secondary">
{f.cwe}
</span>
)}
{f.cvss_score != null && (
<span className="font-mono text-[11px] text-ink-tertiary">
CVSS {f.cvss_score.toFixed(1)}
</span>
)}
</div>
<h3
className="mt-1 line-clamp-2 font-display text-[15px] font-semibold leading-snug text-ink"
title={f.title}
>
{f.title}
</h3>
<p className="mt-0.5 truncate font-mono text-[11px] text-ink-tertiary">
{f.finding_key}
</p>
</div>
<span className="flex shrink-0 items-center gap-2 self-center">
{disclosed ? (
<span className="inline-flex items-center gap-1 text-[12px] font-medium text-success-ink">
<CheckCircle2 size={13} /> Disclosed
</span>
) : (
<span className="inline-flex items-center gap-1 text-[12px] text-ink-tertiary">
<Lock size={12} /> Owner only
</span>
)}
<ChevronDown
size={16}
className={`text-ink-tertiary transition-transform ${open ? "rotate-180" : ""}`}
/>
</span>
</div>
{open && <OwnerFindingDetailBody projectId={projectId} findingKey={f.finding_key} />}
</article>
);
})}
</div>
)}
<ConfirmDialog
open={confirmOpen}
title={`Disclose ${selected.size} finding${selected.size === 1 ? "" : "s"}?`}
body="The full report content of the selected findings — description, file paths, code and analysis — becomes publicly visible on OpenVuln. This cannot be undone from the site; an operator can reverse an accidental disclosure."
confirmLabel="Disclose permanently"
busy={discloseM.isPending}
onConfirm={() => discloseM.mutate([...selected])}
onCancel={() => setConfirmOpen(false)}
/>
</div>
);
}
/** 展开后按需取单条全文(report + artifacts)。 */
function OwnerFindingDetailBody({
projectId,
findingKey,
}: {
projectId: string;
findingKey: string;
}) {
const detailQ = useQuery({
queryKey: ["owner-finding", projectId, findingKey],
queryFn: () => api.ownerFinding(projectId, findingKey),
staleTime: 60_000,
retry: false,
});
if (detailQ.isPending) {
return (
<div className="flex items-center gap-2 border-t border-line px-5 py-6 text-[13px] text-ink-secondary">
<LoaderCircle size={15} className="animate-spin" /> Loading full report…
</div>
);
}
if (detailQ.isError || !detailQ.data) {
return (
<div className="border-t border-line px-5 py-6 text-[13px] text-danger" role="alert">
Failed to load the full report.
</div>
);
}
const f = detailQ.data.finding;
const arts = (f.artifacts ?? []).filter((a) => a.has_content || a.size_bytes > 0);
return (
<div className="border-t border-line px-5 py-4">
<ReportBody report={(f.report as StructuredReport | null) ?? undefined} yaml={f.report_yaml ?? undefined} />
{arts.length > 0 && (
<div className="mt-5 border-t border-line pt-4">
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-ink-tertiary">
Artifacts ({arts.length})
</h4>
<ul className="mt-2 space-y-1">
{arts.slice(0, 20).map((a) => (
<li
key={a.rel_path}
className="flex items-center gap-2 font-mono text-[12px] text-ink-secondary"
title={a.rel_path}
>
<FileCode2 size={13} className="shrink-0 text-ink-tertiary" />
<span className="truncate">{a.file_name}</span>
<span className="shrink-0 text-ink-tertiary">
{a.kind} · {formatSize(a.size_bytes)}
</span>
</li>
))}
{arts.length > 20 && (
<li className="text-[12px] text-ink-tertiary">…and {arts.length - 20} more</li>
)}
</ul>
</div>
)}
</div>
);
}
function formatSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}