Collapse the admin per-column encryption table by default

A fresh install lists well over a hundred encrypted columns, nearly all
empty, and the open table pushed every other encryption card off the
page. The card now opens on a toggle that names how many columns hold
rows, lists only those when expanded, and offers the full list behind
a second toggle.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:45:31 +02:00
1 parent 834803ff21
commit c4272343e3
9 files changed
+230 -62

No files matched your search

+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "Noch keine Sicherung hat die Schlüssel festgehalten, die sie braucht.",
"backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}",
"backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen",
"backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist."
"backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist.",
"columnsShow": "Tabelle anzeigen ({count} mit Zeilen)",
"columnsHide": "Tabelle ausblenden",
"columnsShowAll": "Alle {count} Spalten anzeigen",
"columnsShowWithRows": "Nur Spalten mit Zeilen",
"columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen."
},
"maintenance": {
"title": "Pflege der Messwert-Tabelle",
+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "No backup has recorded the keys it needs yet.",
"backupsStillNeed": "Every row is rotated, but backups still need {keys}",
"backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key",
"backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it."
"backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it.",
"columnsShow": "Show the table ({count} with rows)",
"columnsHide": "Hide the table",
"columnsShowAll": "Show all {count} columns",
"columnsShowWithRows": "Only columns with rows",
"columnsNoneWithRows": "No encrypted column holds rows yet."
},
"maintenance": {
"title": "Measurement table maintenance",
+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "Ninguna copia ha registrado todavía las claves que necesita.",
"backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}",
"backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada",
"backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo."
"backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo.",
"columnsShow": "Mostrar la tabla ({count} con filas)",
"columnsHide": "Ocultar la tabla",
"columnsShowAll": "Mostrar las {count} columnas",
"columnsShowWithRows": "Solo columnas con filas",
"columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía."
},
"maintenance": {
"title": "Mantenimiento de la tabla de mediciones",
+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "Aucune sauvegarde n'a encore enregistré les clés dont elle a besoin.",
"backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}",
"backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée",
"backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous."
"backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous.",
"columnsShow": "Afficher le tableau ({count} avec des lignes)",
"columnsHide": "Masquer le tableau",
"columnsShowAll": "Afficher les {count} colonnes",
"columnsShowWithRows": "Seulement les colonnes avec des lignes",
"columnsNoneWithRows": "Aucune colonne chiffrée ne contient encore de lignes."
},
"maintenance": {
"title": "Maintenance de la table des mesures",
+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "Nessun backup ha ancora registrato le chiavi di cui ha bisogno.",
"backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}",
"backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata",
"backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto."
"backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto.",
"columnsShow": "Mostra la tabella ({count} con righe)",
"columnsHide": "Nascondi la tabella",
"columnsShowAll": "Mostra tutte le {count} colonne",
"columnsShowWithRows": "Solo colonne con righe",
"columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe."
},
"maintenance": {
"title": "Manutenzione della tabella delle misurazioni",
+6 -1
View File
@@ -7865,7 +7865,12 @@
"backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.",
"backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다",
"backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다",
"backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요."
"backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요.",
"columnsShow": "표 보기 (행 있음 {count}개)",
"columnsHide": "표 숨기기",
"columnsShowAll": "열 {count}개 모두 보기",
"columnsShowWithRows": "행이 있는 열만",
"columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요."
},
"maintenance": {
"title": "측정값 테이블 유지 관리",
+6 -1
View File
@@ -7889,7 +7889,12 @@
"backupsNone": "Żadna kopia nie zapisała jeszcze kluczy, których potrzebuje.",
"backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}",
"backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza",
"backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej."
"backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej.",
"columnsShow": "Pokaż tabelę (z wierszami: {count})",
"columnsHide": "Ukryj tabelę",
"columnsShowAll": "Pokaż wszystkie kolumny ({count})",
"columnsShowWithRows": "Tylko kolumny z wierszami",
"columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy."
},
"maintenance": {
"title": "Konserwacja tabeli pomiarów",
@@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({
vi.mock("../key-backup-card", () => ({ KeyBackupCard: () => null }));
import { I18nProvider } from "@/lib/i18n/context";
import { EncryptionSection } from "../encryption-section";
import { ColumnCoverage, EncryptionSection } from "../encryption-section";
function statusWith(configuredKeyCount: number) {
return {
@@ -77,9 +77,64 @@ describe("encryption coverage — the legacy-key badge", () => {
});
it("names the per-column table as a focusable scroll region", () => {
status.current = statusWith(1);
expect(render()).toMatch(
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ColumnCoverage
columns={[column("User", "a", 3)]}
activeKeyId="v2"
initiallyOpen
/>
</I18nProvider>,
);
expect(html).toMatch(
/role="region"[^>]*tabindex="0"|tabindex="0"[^>]*role="region"/,
);
});
});
function column(model: string, field: string, total: number) {
return {
model,
field,
kind: "string" as const,
total,
byKeyId: { v2: total },
legacy: 0,
};
}
describe("encryption coverage — the per-column table", () => {
const columns = [
column("User", "withRows", 4),
column("User", "empty", 0),
column("Note", "alsoEmpty", 0),
];
function renderCoverage(initiallyOpen: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ColumnCoverage
columns={columns}
activeKeyId="v2"
initiallyOpen={initiallyOpen}
/>
</I18nProvider>,
);
}
it("starts collapsed on the page, naming how many columns hold rows", () => {
status.current = { ...statusWith(1), columns };
const html = render();
expect(html).toContain('data-testid="encryption-columns-toggle"');
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("Show the table (1 with rows)");
expect(html).not.toContain('data-slot="encryption-column-rows"');
});
it("lists only columns with rows when opened, with a toggle for all", () => {
const html = renderCoverage(true);
expect(html).toContain("User.withRows");
expect(html).not.toContain("User.empty");
expect(html).toContain("Show all 3 columns");
});
});
+130 -52
View File
@@ -12,9 +12,11 @@
* which case the documented CLI is the path).
*/
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Archive,
ChevronDown,
KeyRound,
Loader2,
RotateCw,
@@ -360,63 +362,139 @@ export function EncryptionSection() {
title={t("admin.section.encryption.columnsTitle")}
description={t("admin.section.encryption.columnsDescription")}
/>
{/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */}
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody>
{columns.map((c) => {
const active = c.byKeyId[s.activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">{c.total.toLocaleString()}</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<ColumnCoverage columns={columns} activeKeyId={s.activeKeyId} />
</SettingsCard>
</div>
);
}
/**
* The per-column table, collapsed by default. A fresh install lists well over
* a hundred encrypted columns, nearly all of them empty, and the table pushed
* every other card off the page. Opened, it shows the columns that hold rows
* (the ones a rotation has to touch), with a toggle for the full list.
*/
export function ColumnCoverage({
columns,
activeKeyId,
initiallyOpen = false,
}: {
columns: ColumnScan[];
activeKeyId: string;
/** Start expanded; the page always starts collapsed. */
initiallyOpen?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(initiallyOpen);
const [showAll, setShowAll] = useState(false);
const withRows = columns.filter((c) => c.total > 0);
const shown = showAll ? columns : withRows;
return (
<div className="space-y-3">
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
aria-expanded={open}
aria-controls="encryption-columns-panel"
data-testid="encryption-columns-toggle"
className="text-foreground hover:text-primary flex min-h-11 items-center gap-2 text-sm font-medium transition-colors sm:min-h-9"
>
<ChevronDown
aria-hidden="true"
className={`size-4 transition-transform ${open ? "rotate-180" : ""}`}
/>
{open
? t("admin.section.encryption.columnsHide")
: t("admin.section.encryption.columnsShow", {
count: withRows.length,
})}
</button>
{open ? (
<div id="encryption-columns-panel" className="space-y-3">
{shown.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.section.encryption.columnsNoneWithRows")}
</p>
) : (
/* Focusable, named scroll region: on a phone the table scrolls
sideways, and a keyboard user has to be able to reach it
(axe scrollable-region-focusable). */
<div
className="overflow-x-auto"
tabIndex={0}
role="region"
aria-label={t("admin.section.encryption.columnsTitle")}
>
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left">
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colColumn")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colTotal")}
</th>
<th className="py-2 pr-3 font-medium">
{t("admin.section.encryption.colActive")}
</th>
<th className="py-2 font-medium">
{t("admin.section.encryption.colStale")}
</th>
</tr>
</thead>
<tbody data-slot="encryption-column-rows">
{shown.map((c) => {
const active = c.byKeyId[activeKeyId] ?? 0;
const stale = c.total - active;
return (
<tr key={`${c.model}.${c.field}`} className="border-b">
<td className="py-2 pr-3 font-mono text-xs">
{c.model}.{c.field}
</td>
<td className="py-2 pr-3">
{c.total.toLocaleString()}
</td>
<td className="py-2 pr-3">{active.toLocaleString()}</td>
<td className="py-2">
{stale > 0 ? (
<Badge variant="secondary">
{stale.toLocaleString()}
</Badge>
) : (
<span className="text-muted-foreground">0</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{withRows.length < columns.length ? (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setShowAll((prev) => !prev)}
aria-pressed={showAll}
data-testid="encryption-columns-show-all"
>
{showAll
? t("admin.section.encryption.columnsShowWithRows")
: t("admin.section.encryption.columnsShowAll", {
count: columns.length,
})}
</Button>
) : null}
</div>
) : null}
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}