227 lines
9.9 KiB
TypeScript
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-xs font-semibold ${changedFromCurrent ? "bg-warning/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
|
|
{changedFromCurrent ? t("Change") : t("No change")}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</FleetLedgerDrawer>
|
|
);
|
|
}
|