mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Show what a backup contains before the operator types RESTORE
The restore dialog asked for its typed confirmation on nothing but a filename and a date; the counts only surfaced in the audit row after the rows were already replaced. A read-only summary endpoint now decrypts and schema-validates the stored file through the same helpers the restore itself runs and answers the summarizeBackup counts, and the dialog shows them — per section, with an other-records line so a future summary key can never silently vanish from the preview. A failed preview states its own absence and never blocks the restore.
This commit is contained in:
1 parent
90bad17cc8
commit
9d64eb6aa2
10 files changed
+403
No files matched your search
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Aus Backup wiederherstellen",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
|
||||
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
|
||||
"previewTitle": "Inhalt der Sicherung",
|
||||
"previewLoading": "Inhalt wird gelesen…",
|
||||
"previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.",
|
||||
"previewEmpty": "Die Datei enthält keine Datensätze.",
|
||||
"previewOther": "Weitere Datensätze",
|
||||
"previewMeasurements": "Messwerte",
|
||||
"previewMedications": "Medikamente und Einnahmen",
|
||||
"previewMood": "Stimmungseinträge",
|
||||
"previewLabs": "Laborwerte",
|
||||
"previewDocuments": "Dokumente",
|
||||
"previewWorkouts": "Workouts",
|
||||
"previewCycles": "Zyklusdaten",
|
||||
"previewVisits": "Arztbesuche",
|
||||
"previewVaccinations": "Impfungen",
|
||||
"previewReminders": "Vorsorge-Erinnerungen",
|
||||
"previewCustomMetrics": "Eigene Messgrößen",
|
||||
"previewHealthScores": "Score-Verlauf",
|
||||
"restoreConfirm": "Jetzt wiederherstellen",
|
||||
"restoreInProgress": "Wiederherstellung läuft…",
|
||||
"restoreSuccess": "Wiederherstellung abgeschlossen",
|
||||
|
||||
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
|
||||
"restorePromptLabel": "Type RESTORE to continue",
|
||||
"previewTitle": "What this backup contains",
|
||||
"previewLoading": "Reading contents…",
|
||||
"previewUnavailable": "Contents could not be read — restoring is still possible.",
|
||||
"previewEmpty": "The file contains no records.",
|
||||
"previewOther": "Other records",
|
||||
"previewMeasurements": "Measurements",
|
||||
"previewMedications": "Medications and intakes",
|
||||
"previewMood": "Mood entries",
|
||||
"previewLabs": "Lab results",
|
||||
"previewDocuments": "Documents",
|
||||
"previewWorkouts": "Workouts",
|
||||
"previewCycles": "Cycle data",
|
||||
"previewVisits": "Doctor visits",
|
||||
"previewVaccinations": "Vaccinations",
|
||||
"previewReminders": "Preventive-care reminders",
|
||||
"previewCustomMetrics": "Custom metrics",
|
||||
"previewHealthScores": "Score history",
|
||||
"restoreConfirm": "Restore now",
|
||||
"restoreInProgress": "Restoring…",
|
||||
"restoreSuccess": "Restore complete",
|
||||
|
||||
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Restaurar desde copia de seguridad",
|
||||
"restoreDescription": "Esto reemplazará TODOS los datos actuales de {username} con la instantánea del {when}. Se eliminarán las mediciones, los medicamentos, los eventos de toma, las entradas de ánimo, los canales de notificación, las suscripciones push y las eliminaciones de Telegram en cola existentes.",
|
||||
"restorePromptLabel": "Escribe RESTORE para continuar",
|
||||
"previewTitle": "Contenido de la copia",
|
||||
"previewLoading": "Leyendo el contenido…",
|
||||
"previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.",
|
||||
"previewEmpty": "El archivo no contiene registros.",
|
||||
"previewOther": "Otros registros",
|
||||
"previewMeasurements": "Mediciones",
|
||||
"previewMedications": "Medicamentos y tomas",
|
||||
"previewMood": "Registros de ánimo",
|
||||
"previewLabs": "Resultados de laboratorio",
|
||||
"previewDocuments": "Documentos",
|
||||
"previewWorkouts": "Entrenamientos",
|
||||
"previewCycles": "Datos del ciclo",
|
||||
"previewVisits": "Visitas médicas",
|
||||
"previewVaccinations": "Vacunas",
|
||||
"previewReminders": "Recordatorios preventivos",
|
||||
"previewCustomMetrics": "Métricas propias",
|
||||
"previewHealthScores": "Historial de puntuación",
|
||||
"restoreConfirm": "Restaurar ahora",
|
||||
"restoreInProgress": "Restaurando...",
|
||||
"restoreSuccess": "Restauración completada",
|
||||
|
||||
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Restaurer à partir d'une sauvegarde",
|
||||
"restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.",
|
||||
"restorePromptLabel": "Tapez RESTORE pour continuer",
|
||||
"previewTitle": "Contenu de la sauvegarde",
|
||||
"previewLoading": "Lecture du contenu…",
|
||||
"previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.",
|
||||
"previewEmpty": "Le fichier ne contient aucun enregistrement.",
|
||||
"previewOther": "Autres enregistrements",
|
||||
"previewMeasurements": "Mesures",
|
||||
"previewMedications": "Médicaments et prises",
|
||||
"previewMood": "Entrées d'humeur",
|
||||
"previewLabs": "Résultats de laboratoire",
|
||||
"previewDocuments": "Documents",
|
||||
"previewWorkouts": "Entraînements",
|
||||
"previewCycles": "Données de cycle",
|
||||
"previewVisits": "Visites médicales",
|
||||
"previewVaccinations": "Vaccinations",
|
||||
"previewReminders": "Rappels de prévention",
|
||||
"previewCustomMetrics": "Mesures personnalisées",
|
||||
"previewHealthScores": "Historique du score",
|
||||
"restoreConfirm": "Restaurer maintenant",
|
||||
"restoreInProgress": "Restauration en cours...",
|
||||
"restoreSuccess": "Restauration terminée",
|
||||
|
||||
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Ripristina dal backup",
|
||||
"restoreDescription": "Questa operazione sostituirà TUTTI i dati attuali di {username} con l'istantanea del {when}. Misurazioni, farmaci, eventi di assunzione, voci dell'umore, canali di notifica, sottoscrizioni push ed eliminazioni Telegram in coda esistenti verranno eliminati.",
|
||||
"restorePromptLabel": "Digita RESTORE per continuare",
|
||||
"previewTitle": "Contenuto del backup",
|
||||
"previewLoading": "Lettura del contenuto…",
|
||||
"previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.",
|
||||
"previewEmpty": "Il file non contiene record.",
|
||||
"previewOther": "Altri record",
|
||||
"previewMeasurements": "Misurazioni",
|
||||
"previewMedications": "Farmaci e assunzioni",
|
||||
"previewMood": "Voci dell'umore",
|
||||
"previewLabs": "Risultati di laboratorio",
|
||||
"previewDocuments": "Documenti",
|
||||
"previewWorkouts": "Allenamenti",
|
||||
"previewCycles": "Dati del ciclo",
|
||||
"previewVisits": "Visite mediche",
|
||||
"previewVaccinations": "Vaccinazioni",
|
||||
"previewReminders": "Promemoria di prevenzione",
|
||||
"previewCustomMetrics": "Metriche personalizzate",
|
||||
"previewHealthScores": "Storico del punteggio",
|
||||
"restoreConfirm": "Ripristina ora",
|
||||
"restoreInProgress": "Ripristino in corso...",
|
||||
"restoreSuccess": "Ripristino completato",
|
||||
|
||||
@@ -6903,6 +6903,23 @@
|
||||
"restoreTitle": "Przywróć z kopii zapasowej",
|
||||
"restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.",
|
||||
"restorePromptLabel": "Wpisz RESTORE, aby kontynuować",
|
||||
"previewTitle": "Zawartość kopii",
|
||||
"previewLoading": "Odczytywanie zawartości…",
|
||||
"previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.",
|
||||
"previewEmpty": "Plik nie zawiera rekordów.",
|
||||
"previewOther": "Inne rekordy",
|
||||
"previewMeasurements": "Pomiary",
|
||||
"previewMedications": "Leki i dawki",
|
||||
"previewMood": "Wpisy nastroju",
|
||||
"previewLabs": "Wyniki badań",
|
||||
"previewDocuments": "Dokumenty",
|
||||
"previewWorkouts": "Treningi",
|
||||
"previewCycles": "Dane cyklu",
|
||||
"previewVisits": "Wizyty lekarskie",
|
||||
"previewVaccinations": "Szczepienia",
|
||||
"previewReminders": "Przypomnienia profilaktyczne",
|
||||
"previewCustomMetrics": "Własne metryki",
|
||||
"previewHealthScores": "Historia wyniku",
|
||||
"restoreConfirm": "Przywróć teraz",
|
||||
"restoreInProgress": "Przywracanie...",
|
||||
"restoreSuccess": "Przywracanie zakończone",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: the summary read decrypts and validates the
|
||||
* stored file through the SAME helpers the restore uses, and only ever
|
||||
* reads. Watched red: written against the route before its annotate landed;
|
||||
* the not-found and decrypt-failure arms were asserted first.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAdmin: vi.fn(async () => ({ user: { id: "admin-1" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const findUniqueMock = vi.fn();
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
const decryptMock = vi.fn();
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (...a: unknown[]) => decryptMock(...a),
|
||||
}));
|
||||
|
||||
const parseMock = vi.fn();
|
||||
const summarizeMock = vi.fn();
|
||||
vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
|
||||
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
|
||||
isCompatibleSchemaVersion: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/admin/backups/b1/summary",
|
||||
);
|
||||
const params = { params: Promise.resolve({ id: "b1" }) };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
findUniqueMock.mockResolvedValue({
|
||||
id: "b1",
|
||||
userId: "u1",
|
||||
data: "ciphertext",
|
||||
createdAt: new Date("2026-08-01T00:00:00Z"),
|
||||
user: { id: "u1", username: "self-hoster" },
|
||||
});
|
||||
decryptMock.mockReturnValue("{}");
|
||||
parseMock.mockReturnValue({ schemaVersion: 3 });
|
||||
summarizeMock.mockReturnValue({ measurements: 12, moodEntries: 3 });
|
||||
});
|
||||
|
||||
describe("GET /api/admin/backups/[id]/summary", () => {
|
||||
it("answers the file's counts without writing anything", async () => {
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
|
||||
expect(body.data.owner).toBe("self-hoster");
|
||||
});
|
||||
|
||||
it("404s an unknown backup id", async () => {
|
||||
findUniqueMock.mockResolvedValue(null);
|
||||
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("degrades to an error response when the file cannot be decrypted", async () => {
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("bad key");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it("422s a file that fails schema validation", async () => {
|
||||
parseMock.mockImplementation(() => {
|
||||
throw new Error("nope");
|
||||
});
|
||||
const res = await GET(request, params);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.37.20 — restore preview: what a backup file contains, BEFORE the
|
||||
* operator confirms a restore.
|
||||
*
|
||||
* The restore dialog used to ask for the typed confirmation on nothing but
|
||||
* a filename and a date; the counts only appeared in the audit row after
|
||||
* the rows were already replaced. This read decrypts and schema-validates
|
||||
* the stored file exactly the way the restore itself does (same decrypt,
|
||||
* same `parseBackupPayload`, same compatibility gate) and answers with the
|
||||
* `summarizeBackup` counts — so what the operator confirms is what the
|
||||
* restore will write, derived from the same code path. Read-only; nothing
|
||||
* is touched.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
isCompatibleSchemaVersion,
|
||||
parseBackupPayload,
|
||||
summarizeBackup,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
void admin;
|
||||
const { id } = await params;
|
||||
|
||||
const backup = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
});
|
||||
if (!backup) {
|
||||
throw new HttpError(404, "Backup not found");
|
||||
}
|
||||
|
||||
let plaintext: string;
|
||||
try {
|
||||
plaintext = decrypt(backup.data);
|
||||
} catch {
|
||||
return apiError("Failed to decrypt backup payload", 500);
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = parseBackupPayload(plaintext);
|
||||
} catch {
|
||||
return apiError("Backup payload failed schema validation", 422);
|
||||
}
|
||||
|
||||
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
|
||||
return apiError(
|
||||
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const summary = summarizeBackup(payload);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.backups.summary" },
|
||||
meta: { backupId: id, ownerId: backup.userId },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
summary,
|
||||
owner: backup.user?.username ?? null,
|
||||
createdAt: backup.createdAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -68,6 +68,46 @@ import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
* Mirrors the wipe dialog's pattern but adds the typed gate because the
|
||||
* blast radius is bigger (re-creates rows, not just deletes).
|
||||
*/
|
||||
/**
|
||||
* v1.37.20 — the sections a restore preview names, in render order. Each
|
||||
* entry sums one or more `BackupSummary` counters under one human label;
|
||||
* every counter NOT named here still reaches the operator through the
|
||||
* trailing "other records" line, so a future summary key can be forgotten
|
||||
* here without silently vanishing from the preview.
|
||||
*/
|
||||
const RESTORE_PREVIEW_SECTIONS: ReadonlyArray<{
|
||||
labelKey: string;
|
||||
keys: readonly string[];
|
||||
}> = [
|
||||
{ labelKey: "previewMeasurements", keys: ["measurements"] },
|
||||
{
|
||||
labelKey: "previewMedications",
|
||||
keys: ["medications", "intakeEvents", "medicationSideEffects"],
|
||||
},
|
||||
{ labelKey: "previewMood", keys: ["moodEntries"] },
|
||||
{ labelKey: "previewLabs", keys: ["labResults", "biomarkers"] },
|
||||
{ labelKey: "previewDocuments", keys: ["documents"] },
|
||||
{ labelKey: "previewWorkouts", keys: ["workouts"] },
|
||||
{ labelKey: "previewCycles", keys: ["cycles", "cycleDayLogs"] },
|
||||
{
|
||||
labelKey: "previewVisits",
|
||||
keys: ["encounters", "practitioners", "encounterLinks"],
|
||||
},
|
||||
{
|
||||
labelKey: "previewVaccinations",
|
||||
keys: ["vaccinations", "vaccinationLinks"],
|
||||
},
|
||||
{
|
||||
labelKey: "previewReminders",
|
||||
keys: ["measurementReminders", "measurementReminderEvents"],
|
||||
},
|
||||
{
|
||||
labelKey: "previewCustomMetrics",
|
||||
keys: ["customMetrics", "customMetricEntries"],
|
||||
},
|
||||
{ labelKey: "previewHealthScores", keys: ["healthScoreRecords"] },
|
||||
];
|
||||
|
||||
function RestoreRowDialog({
|
||||
row,
|
||||
pending,
|
||||
@@ -83,6 +123,39 @@ function RestoreRowDialog({
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === "RESTORE";
|
||||
|
||||
// v1.37.20 — restore preview: fetch what the file contains the moment the
|
||||
// dialog opens, so the typed confirmation is an informed one. Derived from
|
||||
// the same decrypt + schema path the restore itself runs. A failed preview
|
||||
// never blocks the restore — it states its own absence instead.
|
||||
const preview = useQuery({
|
||||
queryKey: queryKeys.adminBackupSummary(row.id),
|
||||
queryFn: () =>
|
||||
apiGet<{ summary: Record<string, unknown> }>(
|
||||
`/api/admin/backups/${row.id}/summary`,
|
||||
),
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const numericSummary: Record<string, number> = {};
|
||||
if (preview.data?.summary) {
|
||||
for (const [key, value] of Object.entries(preview.data.summary)) {
|
||||
if (typeof value === "number") numericSummary[key] = value;
|
||||
}
|
||||
}
|
||||
const namedKeys = new Set(
|
||||
RESTORE_PREVIEW_SECTIONS.flatMap((section) => section.keys),
|
||||
);
|
||||
const previewRows = RESTORE_PREVIEW_SECTIONS.map((section) => ({
|
||||
labelKey: section.labelKey,
|
||||
count: section.keys.reduce(
|
||||
(sum, key) => sum + (numericSummary[key] ?? 0),
|
||||
0,
|
||||
),
|
||||
})).filter((entry) => entry.count > 0);
|
||||
const otherCount = Object.entries(numericSummary)
|
||||
.filter(([key]) => !namedKeys.has(key))
|
||||
.reduce((sum, [, value]) => sum + value, 0);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
@@ -121,6 +194,57 @@ function RestoreRowDialog({
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{/* v1.37.20 — what the file contains, before the typed gate. */}
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm"
|
||||
data-slot="restore-preview"
|
||||
>
|
||||
<p className="text-muted-foreground mb-1.5 font-medium">
|
||||
{t("admin.section.backups.previewTitle")}
|
||||
</p>
|
||||
{preview.isLoading && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewLoading")}
|
||||
</p>
|
||||
)}
|
||||
{preview.isError && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
{preview.isSuccess && (
|
||||
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
|
||||
{previewRows.map((entry) => (
|
||||
<li
|
||||
key={entry.labelKey}
|
||||
className="flex items-baseline justify-between gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{t(`admin.section.backups.${entry.labelKey}`)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{fmt.integer(entry.count)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{otherCount > 0 && (
|
||||
<li className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewOther")}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{fmt.integer(otherCount)}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
{previewRows.length === 0 && otherCount === 0 && (
|
||||
<li className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewEmpty")}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`restore-prompt-${row.id}`}>
|
||||
{t("admin.section.backups.restorePromptLabel")}
|
||||
|
||||
@@ -33,6 +33,9 @@ export const adminKeys = {
|
||||
adminModuleAvailability: () =>
|
||||
["admin", "settings", "module-availability"] as const,
|
||||
adminBackups: () => ["admin", "backups"] as const,
|
||||
/** v1.37.20 — restore preview: the counts a stored backup file carries. */
|
||||
adminBackupSummary: (id: string) =>
|
||||
["admin", "backups", id, "summary"] as const,
|
||||
/** v1.23 — encryption coverage + rotation progress (admin Encryption section). */
|
||||
adminEncryptionStatus: () => ["admin", "encryption", "status"] as const,
|
||||
adminCoachFeedback: () => ["admin", "coach-feedback"] as const,
|
||||
|
||||
Reference in new issue
Block a user