import { renderAdminMessage, type AdminMessage } from "./messages"; import { useI18n, translate } from "../../i18n"; import { useEffect, useMemo, useRef, useState } from "react"; import { Save } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { apiErrorMessage } from "../../api/client"; import { updateCatalogColumnSensitive, type CatalogColumn, type SensitivityReviewItem, } from "../../api/catalog-databases"; import { FleetLedgerDrawer } from "./FleetLedgerShell"; interface Props { open: boolean; databaseId: string | null; scopeLabel: AdminMessage; suggestions: SensitivityReviewItem[]; canManage: boolean; onClose: () => void; onSaved: (columns: CatalogColumn[]) => void; } function sensitivityReasonFor( suggestion: SensitivityReviewItem, sensitive: boolean, ): string | null { if (!sensitive) return null; if (suggestion.assessment !== "sensitive") { return "Marked sensitive during human review despite a non-sensitive local assessment."; } const evidence = suggestion.evidence.map((item) => { const confidence = item.confidence === undefined ? "" : `, confidence ${Math.round(item.confidence * 100)}%`; return item.label ? `${item.label} (${item.ruleId}${confidence})` : `${item.ruleId}${confidence}`; }).join(", "); const coverage = suggestion.coverage.replaceAll("_", " "); return `Local assessment: sensitive. Evidence: ${evidence || "policy match"}. Coverage: ${coverage}; observed values: ${suggestion.observedValues}.`; } export function SensitiveDataReviewDrawer({ open, databaseId, scopeLabel, suggestions, canManage, onClose, onSaved, }: Props) { const { t } = useI18n(); const [drafts, setDrafts] = useState>({}); const [search, setSearch] = useState(""); const [showAll, setShowAll] = useState(false); const [saving, setSaving] = useState(false); const initializedReview = useRef(null); const reviewKey = useMemo( () => suggestions.map((suggestion) => suggestion.columnId).join(":"), [suggestions, t], ); useEffect(() => { if (!open) { initializedReview.current = null; return; } if (initializedReview.current === reviewKey) return; initializedReview.current = reviewKey; setDrafts(Object.fromEntries(suggestions.map((suggestion) => [ suggestion.columnId, suggestion.sensitive, ]))); setSearch(""); setShowAll(false); }, [open, reviewKey, suggestions]); const changed = useMemo(() => suggestions.filter((suggestion) => ( drafts[suggestion.columnId] !== undefined && drafts[suggestion.columnId] !== suggestion.currentSensitive )), [drafts, suggestions, t]); const visible = useMemo(() => { const term = search.trim().toLocaleLowerCase(); return suggestions.filter((suggestion) => ( (showAll || drafts[suggestion.columnId] !== suggestion.currentSensitive) && (!term || `${suggestion.tableName}.${suggestion.columnName}`.toLocaleLowerCase().includes(term)) )); }, [drafts, search, showAll, suggestions, t]); const close = () => { if (saving) return; if (changed.length > 0 && !window.confirm(t("Discard the sensitive-field review?"))) return; onClose(); }; const save = async () => { if (!databaseId || changed.length === 0) return; setSaving(true); const saved: CatalogColumn[] = []; const failed: unknown[] = []; for (const suggestion of changed) { try { saved.push(await updateCatalogColumnSensitive( databaseId, suggestion.tableId, suggestion.columnId, suggestion.version, drafts[suggestion.columnId]!, sensitivityReasonFor(suggestion, drafts[suggestion.columnId]!), )); } catch (error) { failed.push(error); } } if (saved.length > 0) onSaved(saved); if (failed.length > 0) { toast.error((failed.length === 1 ? t("{count} sensitive flag could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) }) : t("{count} sensitive flags could not be saved: {error}", { count: failed.length, error: apiErrorMessage(failed[0]) }))); } else { toast.success((saved.length === 1 ? t("Saved {count} sensitive flag", { count: saved.length }) : t("Saved {count} sensitive flags", { count: saved.length }))); onClose(); } setSaving(false); }; if (!open || !databaseId) return null; return (

{t("Unsaved assessments never change the catalog.")}

)} >
setSearch(event.target.value)} /> {changed.length === 1 ? t("{count} change", { count: changed.length }) : t("{count} changes", { count: changed.length })}
{visible.length === 0 ? (

{t("No proposed changes in this view.")}

{t("Show all classified columns to inspect unchanged flags, or close this review.")}

) : (
    {visible.map((suggestion) => { const proposed = drafts[suggestion.columnId] ?? suggestion.sensitive; const changedFromCurrent = proposed !== suggestion.currentSensitive; return (
  • setDrafts((current) => ({ ...current, [suggestion.columnId]: event.target.checked, }))} />

    {suggestion.tableName}.{suggestion.columnName}

    {t("Current:")} {suggestion.currentSensitive ? t("protected") : t("allowed")}{t(". Proposed:")} {proposed ? t("protected") : t("allowed")}.

    {t("Assessment:")} {t(suggestion.assessment.replace("_", " "))}{t(". Evidence:")} {suggestion.evidence.length > 0 ? suggestion.evidence.map((item) => item.label ? `${item.ruleId} (${item.label}${item.confidence === undefined ? "" : ` ${Math.round(item.confidence * 100)}%`})` : item.ruleId).join(", ") : t("no sensitive match")}{t(". Coverage:")} {t(suggestion.coverage.replace("_", " "))}{t(". Observed values:")} {suggestion.observedValues}.

    {changedFromCurrent ? t("Change") : t("No change")}
  • ); })}
)}
); }