From c4272343e364bcb19231a9005102175ff1e60630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:45:31 +0200 Subject: [PATCH] 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. --- messages/de.json | 7 +- messages/en.json | 7 +- messages/es.json | 7 +- messages/fr.json | 7 +- messages/it.json | 7 +- messages/ko.json | 7 +- messages/pl.json | 7 +- .../encryption-legacy-badge.test.tsx | 61 +++++- src/components/admin/encryption-section.tsx | 182 +++++++++++++----- 9 files changed, 230 insertions(+), 62 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2baa2f32e..637089669 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3f2d82afa..185bd2226 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index be3c7baf3..787114de2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index b6cf1a77b..7803ab4f4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index d3a70449a..e959745d9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 8c101e880..b6abad7a8 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7865,7 +7865,12 @@ "backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.", "backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다", "backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다", - "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요." + "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요.", + "columnsShow": "표 보기 (행 있음 {count}개)", + "columnsHide": "표 숨기기", + "columnsShowAll": "열 {count}개 모두 보기", + "columnsShowWithRows": "행이 있는 열만", + "columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요." }, "maintenance": { "title": "측정값 테이블 유지 관리", diff --git a/messages/pl.json b/messages/pl.json index d78363ee2..a6f21206f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx index 2043f5049..07c461003 100644 --- a/src/components/admin/__tests__/encryption-legacy-badge.test.tsx +++ b/src/components/admin/__tests__/encryption-legacy-badge.test.tsx @@ -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( + + + , + ); + 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( + + + , + ); + } + + 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"); + }); +}); diff --git a/src/components/admin/encryption-section.tsx b/src/components/admin/encryption-section.tsx index e2755e3af..8879cea70 100644 --- a/src/components/admin/encryption-section.tsx +++ b/src/components/admin/encryption-section.tsx @@ -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). */} -
- - - - - - - - - - - {columns.map((c) => { - const active = c.byKeyId[s.activeKeyId] ?? 0; - const stale = c.total - active; - return ( - - - - - - - ); - })} - -
- {t("admin.section.encryption.colColumn")} - - {t("admin.section.encryption.colTotal")} - - {t("admin.section.encryption.colActive")} - - {t("admin.section.encryption.colStale")} -
- {c.model}.{c.field} - {c.total.toLocaleString()}{active.toLocaleString()} - {stale > 0 ? ( - - {stale.toLocaleString()} - - ) : ( - 0 - )} -
-
+ ); } +/** + * 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 ( +
+ + {open ? ( +
+ {shown.length === 0 ? ( +

+ {t("admin.section.encryption.columnsNoneWithRows")} +

+ ) : ( + /* 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). */ +
+ + + + + + + + + + + {shown.map((c) => { + const active = c.byKeyId[activeKeyId] ?? 0; + const stale = c.total - active; + return ( + + + + + + + ); + })} + +
+ {t("admin.section.encryption.colColumn")} + + {t("admin.section.encryption.colTotal")} + + {t("admin.section.encryption.colActive")} + + {t("admin.section.encryption.colStale")} +
+ {c.model}.{c.field} + + {c.total.toLocaleString()} + {active.toLocaleString()} + {stale > 0 ? ( + + {stale.toLocaleString()} + + ) : ( + 0 + )} +
+
+ )} + {withRows.length < columns.length ? ( + + ) : null} +
+ ) : null} +
+ ); +} + function Stat({ label, value }: { label: string; value: string }) { return ; }