Files
ThothII/frontend/src/shell/database-management/SensitiveDataReviewDrawer.tsx
T
Codex d8a29bfbdd Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
2026-09-13 14:26:39 +02:00

227 lines
9.9 KiB
TypeScript

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<Record<string, boolean>>({});
const [search, setSearch] = useState("");
const [showAll, setShowAll] = useState(false);
const [saving, setSaving] = useState(false);
const initializedReview = useRef<string | null>(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 (
<FleetLedgerDrawer
open
ariaLabel={t("Sensitive field review")}
eyebrow={t("Sensitive data")}
title={t("Review local assessments")}
description={t("{scopeLabel}. Local rules proposed values, but only your save changes the catalog.", { scopeLabel: renderAdminMessage(t, scopeLabel) })}
onClose={close}
closeLabel={t("Close sensitive field review")}
busy={saving}
bodyClassName="thot-catalog-drawer__body--flush"
footerClassName="thot-catalog-drawer__footer--split"
footer={(
<>
<p className="text-xs text-muted-foreground">{t("Unsaved assessments never change the catalog.")}</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>{t("Cancel")}</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? t("Saving…") : (changed.length === 1 ? t("Save all {count} change", { count: changed.length }) : t("Save all {count} changes", { count: changed.length }))}
</Button>
</div>
</>
)}
>
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label={t("Search sensitivity assessments")}
placeholder={t("Search table or column")}
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{changed.length === 1 ? t("{count} change", { count: changed.length }) : t("{count} changes", { count: changed.length })}
</span>
</div>
<label className="mt-3 inline-flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
checked={showAll}
onChange={(event) => setShowAll(event.target.checked)}
/>
{t("Show all")} {suggestions.length} {t("assessed columns")}</label>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">{t("No proposed changes in this view.")}</p>
<p className="mt-1 text-muted-foreground">
{t("Show all classified columns to inspect unchanged flags, or close this review.")}</p>
</div>
) : (
<ul className="divide-y divide-border" aria-label={t("Sensitivity assessments")}>
{visible.map((suggestion) => {
const proposed = drafts[suggestion.columnId] ?? suggestion.sensitive;
const changedFromCurrent = proposed !== suggestion.currentSensitive;
return (
<li key={suggestion.columnId} className="flex items-start gap-3 py-3">
<input
type="checkbox"
className="mt-1 size-4 shrink-0 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
aria-label={t("Protect {tableName}.{columnName}", { tableName: suggestion.tableName, columnName: suggestion.columnName })}
checked={proposed}
disabled={!canManage || saving}
onChange={(event) => setDrafts((current) => ({
...current,
[suggestion.columnId]: event.target.checked,
}))}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-xs font-semibold">
{suggestion.tableName}.{suggestion.columnName}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("Current:")} {suggestion.currentSensitive ? t("protected") : t("allowed")}{t(". Proposed:")} {proposed ? t("protected") : t("allowed")}.
</p>
<p className="mt-1 text-xs text-muted-foreground">
{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}.
</p>
</div>
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
{changedFromCurrent ? t("Change") : t("No change")}
</span>
</li>
);
})}
</ul>
)}
</div>
</FleetLedgerDrawer>
);
}