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.
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -13,7 +15,7 @@ import { FleetLedgerDrawer } from "./FleetLedgerShell";
|
||||
interface Props {
|
||||
open: boolean;
|
||||
databaseId: string | null;
|
||||
scopeLabel: string;
|
||||
scopeLabel: AdminMessage;
|
||||
suggestions: SensitivityReviewItem[];
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
@@ -49,6 +51,7 @@ export function SensitiveDataReviewDrawer({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [drafts, setDrafts] = useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
@@ -56,7 +59,7 @@ export function SensitiveDataReviewDrawer({
|
||||
const initializedReview = useRef<string | null>(null);
|
||||
const reviewKey = useMemo(
|
||||
() => suggestions.map((suggestion) => suggestion.columnId).join(":"),
|
||||
[suggestions],
|
||||
[suggestions, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,18 +80,18 @@ export function SensitiveDataReviewDrawer({
|
||||
const changed = useMemo(() => suggestions.filter((suggestion) => (
|
||||
drafts[suggestion.columnId] !== undefined
|
||||
&& drafts[suggestion.columnId] !== suggestion.currentSensitive
|
||||
)), [drafts, suggestions]);
|
||||
)), [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]);
|
||||
}, [drafts, search, showAll, suggestions, t]);
|
||||
|
||||
const close = () => {
|
||||
if (saving) return;
|
||||
if (changed.length > 0 && !window.confirm("Discard the sensitive-field review?")) return;
|
||||
if (changed.length > 0 && !window.confirm(t("Discard the sensitive-field review?"))) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -113,9 +116,9 @@ export function SensitiveDataReviewDrawer({
|
||||
}
|
||||
if (saved.length > 0) onSaved(saved);
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[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 ${saved.length} sensitive flag${saved.length === 1 ? "" : "s"}`);
|
||||
toast.success((saved.length === 1 ? t("Saved {count} sensitive flag", { count: saved.length }) : t("Saved {count} sensitive flags", { count: saved.length })));
|
||||
onClose();
|
||||
}
|
||||
setSaving(false);
|
||||
@@ -125,22 +128,22 @@ export function SensitiveDataReviewDrawer({
|
||||
return (
|
||||
<FleetLedgerDrawer
|
||||
open
|
||||
ariaLabel="Sensitive field review"
|
||||
eyebrow="Sensitive data"
|
||||
title="Review local assessments"
|
||||
description={`${scopeLabel}. Local rules proposed values, but only your save changes the catalog.`}
|
||||
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="Close sensitive field review"
|
||||
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">Unsaved assessments never change the catalog.</p>
|
||||
<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}>Cancel</Button>
|
||||
<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 ? "Saving…" : `Save all ${changed.length} change${changed.length === 1 ? "" : "s"}`}
|
||||
<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>
|
||||
</>
|
||||
@@ -150,13 +153,13 @@ export function SensitiveDataReviewDrawer({
|
||||
<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="Search sensitivity assessments"
|
||||
placeholder="Search table or column"
|
||||
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} change{changed.length === 1 ? "" : "s"}
|
||||
{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">
|
||||
@@ -166,20 +169,18 @@ export function SensitiveDataReviewDrawer({
|
||||
checked={showAll}
|
||||
onChange={(event) => setShowAll(event.target.checked)}
|
||||
/>
|
||||
Show all {suggestions.length} assessed columns
|
||||
</label>
|
||||
{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">No proposed changes in this view.</p>
|
||||
<p className="font-semibold">{t("No proposed changes in this view.")}</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Show all classified columns to inspect unchanged flags, or close this review.
|
||||
</p>
|
||||
{t("Show all classified columns to inspect unchanged flags, or close this review.")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border" aria-label="Sensitivity assessments">
|
||||
<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;
|
||||
@@ -188,7 +189,7 @@ export function SensitiveDataReviewDrawer({
|
||||
<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={`Protect ${suggestion.tableName}.${suggestion.columnName}`}
|
||||
aria-label={t("Protect {tableName}.{columnName}", { tableName: suggestion.tableName, columnName: suggestion.columnName })}
|
||||
checked={proposed}
|
||||
disabled={!canManage || saving}
|
||||
onChange={(event) => setDrafts((current) => ({
|
||||
@@ -201,18 +202,18 @@ export function SensitiveDataReviewDrawer({
|
||||
{suggestion.tableName}.{suggestion.columnName}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Current: {suggestion.currentSensitive ? "protected" : "allowed"}. Proposed: {proposed ? "protected" : "allowed"}.
|
||||
{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">
|
||||
Assessment: {suggestion.assessment.replace("_", " ")}. Evidence: {suggestion.evidence.length > 0
|
||||
{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(", ")
|
||||
: "no sensitive match"}. Coverage: {suggestion.coverage.replace("_", " ")}. Observed values: {suggestion.observedValues}.
|
||||
: 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 ? "Change" : "No change"}
|
||||
{changedFromCurrent ? t("Change") : t("No change")}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user