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.
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-[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>
|
|
);
|
|
}
|