Spaces:
Running
Running
| 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`; | |
| } | |