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:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -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>
);