mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
834803ff21
commit
c4272343e3
9 files changed
+230
-62
No files matched your search
+6
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" />;
|
||||
}
|
||||
Reference in new issue
Block a user