From 6b366b4f27fd463a01ebdd1e6e176ae5c5848e87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 05:16:59 +0200 Subject: [PATCH] fix(backup): make the console restore leave instance settings alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disaster-recovery snapshot carries the singleton `app_settings` row — registration, the MFA requirement, the default locale and timezone, module availability, the notification and AI configuration, the document size cap and quota — and the restore wrote it back as a side effect of restoring one account. An operator putting a single record back on Wednesday reconfigured the whole host from Monday's file, silently, with nothing on screen saying so. Rebuilding a host from a snapshot is a real case, so the write stays: it is now `restoreInstanceSettings: true` on the request and a checkbox beside the typed gate, off unless the operator ticks it. Restoring an account and reconfiguring the installation are two decisions, so they are two answers. The 422 confirmation sentence and the dialog copy drop the instance-wide claim; the opt-in carries it instead, in all seven catalogues. Integration coverage runs both arms: without the flag the settings row keeps its own values, with it the file's values land. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/ko.json | 4 +- messages/pl.json | 2 + .../api/admin/backups/[id]/restore/route.ts | 39 +++++++++-- .../__tests__/backups-section-empty.test.tsx | 17 +++-- src/components/admin/backups-section.tsx | 59 +++++++++++++--- .../admin-backups-canonical-roundtrip.test.ts | 6 +- .../integration/admin-backups-restore.test.ts | 70 ++++++++++++++++++- 12 files changed, 189 insertions(+), 22 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6256bc25b..0250283cd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6959,7 +6959,9 @@ "restore": "Wiederherstellen", "restoreAria": "Backup für {username} wiederherstellen", "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.", + "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.", + "restoreInstanceSettingsLabel": "Instanzweite Einstellungen mitwiederherstellen", + "restoreInstanceSettingsHint": "Registrierung, MFA-Pflicht, Standardsprache, Modulverfügbarkeit, Benachrichtigungs- und KI-Konfiguration — aus dem Snapshot, gültig für alle auf dieser Installation. Leer lassen, um nur dieses Konto wiederherzustellen.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "previewTitle": "Inhalt der Sicherung", "previewLoading": "Inhalt wird gelesen…", diff --git a/messages/en.json b/messages/en.json index 3ccc18318..edc7470b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6959,7 +6959,9 @@ "restore": "Restore", "restoreAria": "Restore from backup for {username}", "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.", + "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.", + "restoreInstanceSettingsLabel": "Also restore instance settings", + "restoreInstanceSettingsHint": "Registration, MFA requirement, default locale, module availability, notification and AI configuration — taken from the snapshot and applied to everybody on this installation. Leave it clear to restore this account alone.", "restorePromptLabel": "Type RESTORE to continue", "previewTitle": "What this backup contains", "previewLoading": "Reading contents…", diff --git a/messages/es.json b/messages/es.json index 7ba0835c3..55614e377 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6960,6 +6960,8 @@ "restoreAria": "Restaurar desde copia de seguridad de {username}", "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.", + "restoreInstanceSettingsLabel": "Restaurar también los ajustes de la instancia", + "restoreInstanceSettingsHint": "Registro, requisito de MFA, idioma predeterminado, disponibilidad de módulos, configuración de notificaciones e IA: se toman de la instantánea y se aplican a todos en esta instalación. Déjalo sin marcar para restaurar solo esta cuenta.", "restorePromptLabel": "Escribe RESTORE para continuar", "previewTitle": "Contenido de la copia", "previewLoading": "Leyendo el contenido…", diff --git a/messages/fr.json b/messages/fr.json index 047d580a1..d27bdaa27 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6960,6 +6960,8 @@ "restoreAria": "Restaurer à partir de la sauvegarde de {username}", "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.", + "restoreInstanceSettingsLabel": "Restaurer aussi les paramètres de l'instance", + "restoreInstanceSettingsHint": "Inscription, exigence MFA, langue par défaut, disponibilité des modules, configuration des notifications et de l'IA — repris de l'instantané et appliqués à tout le monde sur cette installation. Laissez décoché pour ne restaurer que ce compte.", "restorePromptLabel": "Tapez RESTORE pour continuer", "previewTitle": "Contenu de la sauvegarde", "previewLoading": "Lecture du contenu…", diff --git a/messages/it.json b/messages/it.json index c8b4e49d4..0c7e55a20 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6960,6 +6960,8 @@ "restoreAria": "Ripristina dal backup di {username}", "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.", + "restoreInstanceSettingsLabel": "Ripristina anche le impostazioni dell'istanza", + "restoreInstanceSettingsHint": "Registrazione, obbligo di MFA, lingua predefinita, disponibilità dei moduli, configurazione di notifiche e IA: presi dall'istantanea e applicati a tutti su questa installazione. Lascia deselezionato per ripristinare solo questo account.", "restorePromptLabel": "Digita RESTORE per continuare", "previewTitle": "Contenuto del backup", "previewLoading": "Lettura del contenuto…", diff --git a/messages/ko.json b/messages/ko.json index 4eb23051d..8e1982dfd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6939,7 +6939,9 @@ "restore": "복원", "restoreAria": "{username}의 백업에서 복원", "restoreTitle": "백업에서 복원", - "restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요. 스냅샷에 인스턴스 전체 설정이 들어 있으면 설치본 전체 설정을 덮어써요.", + "restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요.", + "restoreInstanceSettingsLabel": "인스턴스 설정도 함께 복원", + "restoreInstanceSettingsHint": "가입, MFA 필수 여부, 기본 언어, 모듈 사용 여부, 알림과 AI 설정 — 스냅샷에서 가져와 이 설치본의 모든 사용자에게 적용돼요. 이 계정만 복원하려면 선택하지 마세요.", "restorePromptLabel": "계속하려면 RESTORE를 입력해 주세요", "previewTitle": "이 백업에 담긴 내용", "previewLoading": "내용을 읽는 중…", diff --git a/messages/pl.json b/messages/pl.json index ae08e819e..3ee8e1c1b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6960,6 +6960,8 @@ "restoreAria": "Przywróć z kopii zapasowej dla {username}", "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.", + "restoreInstanceSettingsLabel": "Przywróć też ustawienia instancji", + "restoreInstanceSettingsHint": "Rejestracja, wymóg MFA, domyślny język, dostępność modułów, konfiguracja powiadomień i AI — brane z migawki i stosowane do wszystkich w tej instalacji. Zostaw niezaznaczone, aby przywrócić tylko to konto.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", "previewTitle": "Zawartość kopii", "previewLoading": "Odczytywanie zawartości…", diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 3deab49d4..d775e8411 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -11,6 +11,10 @@ * Metadata-only portable document exports are rejected before mutation; the * importer never fabricates content. Audit rows remain outside the wipe, and * cache invalidation runs only after the complete restore transaction. + * + * Everything above is scoped to ONE account. The instance-wide settings a + * disaster-recovery payload also carries are not, and they are opt-in for that + * reason: see the `restoreInstanceSettings` block below. */ import { Buffer } from "node:buffer"; @@ -142,17 +146,26 @@ const handler = apiHandler( const { id } = await params; annotate({ action: { name: "admin.backups.restore" }, meta: { id } }); - let body: { confirm?: string } = {}; + let body: { confirm?: string; restoreInstanceSettings?: boolean } = {}; try { const raw = await request.text(); if (raw.length > 64 * 1024) { return apiError(`Request body exceeds ${64 * 1024} bytes`, 413); } - body = JSON.parse(raw) as { confirm?: string }; + body = JSON.parse(raw) as { + confirm?: string; + restoreInstanceSettings?: boolean; + }; } catch { return apiError("Invalid JSON body", 400); } + // Restoring one account's data and reconfiguring the whole host are two + // different decisions, so they are two different answers. Anything but a + // literal `true` restores the account alone. + const restoreInstanceSettings = body.restoreInstanceSettings === true; + annotate({ meta: { restore_instance_settings: restoreInstanceSettings } }); + if (body.confirm !== "RESTORE") { await auditLog("admin.backups.restore.denied", { userId: admin.id, @@ -160,7 +173,7 @@ const handler = apiHandler( details: { reason: "missing_confirmation", backupId: id }, }); return apiError( - "Confirmation token missing — restoring user data and included instance-wide settings requires confirm: 'RESTORE'", + "Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'", 422, ); } @@ -292,6 +305,7 @@ const handler = apiHandler( ownerId, ownerUsername: owner.username, snapshotExportedAt: payload.exportedAt, + restoreInstanceSettings, }, }); @@ -441,7 +455,20 @@ const handler = apiHandler( where: { userId: ownerId }, }); - if (payload.appSettings) { + // The one section of the file that is not this account's. + // + // A disaster-recovery payload carries the singleton `app_settings` + // row — registration, the MFA requirement, the default locale and + // timezone, module availability, the notification and AI + // configuration, the document size cap and quota. Writing it back as + // a side effect of restoring ONE account reconfigures the host for + // everybody on it, silently and hours or weeks out of date: an + // operator putting a single record back on Wednesday has no reason to + // expect Monday's registration switch and Monday's upload cap to come + // with it. Rebuilding a host from a snapshot is a real case, so the + // write stays available — as an answer the operator gives, not one + // the account restore assumes. + if (payload.appSettings && restoreInstanceSettings) { const settings = payload.appSettings; const settingsData = { registrationEnabled: settings.registrationEnabled, @@ -2084,6 +2111,10 @@ const handler = apiHandler( backupId: id, ownerId, ownerUsername: owner.username, + // Whether this restore also rewrote the host's own settings. The one + // effect of a restore that reaches accounts other than `ownerId`, so + // the trail has to be able to answer it later. + restoreInstanceSettings, cleared, // The durable half of the report. The response reaches whoever was // looking at the screen; the audit row is still here next week when diff --git a/src/components/admin/__tests__/backups-section-empty.test.tsx b/src/components/admin/__tests__/backups-section-empty.test.tsx index 6a8b2afd5..a29f39741 100644 --- a/src/components/admin/__tests__/backups-section-empty.test.tsx +++ b/src/components/admin/__tests__/backups-section-empty.test.tsx @@ -140,12 +140,21 @@ describe("BackupsSection — empty state", () => { expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/); }); - it("warns in both catalogs that restore overwrites instance-wide settings", () => { - expect(de.admin.section.backups.restoreDescription).toMatch( + it("names the instance-wide effect on the opt-in that carries it, in both catalogs", () => { + // The restore rewrites the host's own settings only when the operator + // ticks the box, so the warning belongs to the box rather than to the + // sentence above it — and the box has to say what it turns on. + expect(de.admin.section.backups.restoreInstanceSettingsLabel).toMatch( /instanzweite Einstellungen/i, ); - expect(en.admin.section.backups.restoreDescription).toMatch( - /instance-wide settings/i, + expect(de.admin.section.backups.restoreInstanceSettingsHint).toMatch( + /Installation/, + ); + expect(en.admin.section.backups.restoreInstanceSettingsLabel).toMatch( + /instance settings/i, + ); + expect(en.admin.section.backups.restoreInstanceSettingsHint).toMatch( + /installation/i, ); }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index c65ea34c5..4d048c902 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -124,13 +125,18 @@ function RestoreRowDialog({ }: { row: BackupRow; pending: boolean; - onConfirm: () => void; + onConfirm: (options: { restoreInstanceSettings: boolean }) => void; }) { const { t } = useTranslations(); const fmt = useFormatters(); const [open, setOpen] = useState(false); const [typed, setTyped] = useState(""); const matched = typed.trim() === "RESTORE"; + // Off unless the operator says otherwise. A disaster-recovery snapshot + // carries the host's own settings alongside the account's data, and putting + // one account back is not a reason to reconfigure the installation for + // everybody on it — so the second effect is a second decision. + const [withInstanceSettings, setWithInstanceSettings] = useState(false); // 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 @@ -170,7 +176,10 @@ function RestoreRowDialog({ open={open} onOpenChange={(next) => { setOpen(next); - if (!next) setTyped(""); + if (!next) { + setTyped(""); + setWithInstanceSettings(false); + } }} > @@ -255,6 +264,26 @@ function RestoreRowDialog({ )} + {/* The second decision, before the typed gate rather than after it: + the account's data comes back either way, the host's settings only + if this is ticked. */} +
@@ -1139,9 +1177,12 @@ export function BackupsSection() { + restore.mutate({ row, ...options }) } - onConfirm={() => restore.mutate(row)} /> diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index 7eabf894c..d28b3ed81 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -86,7 +86,11 @@ function makeRequest(id: string) { return new Request(`http://localhost/api/admin/backups/${id}/restore`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ confirm: "RESTORE" }), + // The disaster-recovery case, so the instance-wide half of the payload is + // asked for explicitly. Without the flag the restore rebuilds the account + // and leaves the host's own settings alone; that arm is covered in + // `admin-backups-restore.test.ts`. + body: JSON.stringify({ confirm: "RESTORE", restoreInstanceSettings: true }), }); } diff --git a/tests/integration/admin-backups-restore.test.ts b/tests/integration/admin-backups-restore.test.ts index 6e0ee8b29..3eecc7a2a 100644 --- a/tests/integration/admin-backups-restore.test.ts +++ b/tests/integration/admin-backups-restore.test.ts @@ -13,6 +13,9 @@ * Plus negative paths: * - 422 when `confirm` is missing. * - 404 when the backup id is unknown. + * - the singleton `app_settings` row stays where it is unless the request + * asks for it: restoring one account is not a reason to reconfigure the + * installation for everybody on it. * - audit-log entries for start, success, denial. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -307,7 +310,7 @@ describe("POST /api/admin/backups/[id]/restore", () => { expect(res.status).toBe(422); const error = (await res.json()) as { error: string }; - expect(error.error).toMatch(/instance-wide settings/i); + expect(error.error).toMatch(/confirm: 'RESTORE'/); const denied = await prisma.auditLog.count({ where: { action: "admin.backups.restore.denied", @@ -317,6 +320,71 @@ describe("POST /api/admin/backups/[id]/restore", () => { expect(denied).toBe(1); }); + it("leaves the instance settings alone unless the restore asks for them", async () => { + const prisma = getPrismaClient(); + const admin = await seedAdminSession(); + + // What the host is configured to do today. + const live = await prisma.appSettings.create({ + data: { + id: "singleton", + registrationEnabled: false, + documentMaxFileBytes: 1_048_576, + }, + }); + + // What the file says, which is the other answer to both. + const backup = await prisma.dataBackup.create({ + data: { + userId: admin.id, + type: "MANUAL_UPLOAD_SETTINGS_TEST", + data: encrypt( + JSON.stringify({ + schemaVersion: "1", + exportedAt: "2026-05-09T00:00:00.000Z", + userId: admin.id, + measurements: [], + medications: [], + intakeEvents: [], + moodEntries: [], + appSettings: { + ...live, + documentQuotaBytes: String(live.documentQuotaBytes), + registrationEnabled: true, + documentMaxFileBytes: 4 * 1_048_576, + }, + }), + ), + }, + }); + + const { POST } = await import("@/app/api/admin/backups/[id]/restore/route"); + const restore = (body: Record) => + POST( + makeRequest(backup.id, body) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + + // The default answer: this account's data, and nobody else's settings. + expect((await restore({ confirm: "RESTORE" })).status).toBe(200); + const untouched = await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }); + expect(untouched.registrationEnabled).toBe(false); + expect(untouched.documentMaxFileBytes).toBe(1_048_576); + + // And the second decision, when the operator makes it. + expect( + (await restore({ confirm: "RESTORE", restoreInstanceSettings: true })) + .status, + ).toBe(200); + const rewritten = await prisma.appSettings.findUniqueOrThrow({ + where: { id: "singleton" }, + }); + expect(rewritten.registrationEnabled).toBe(true); + expect(rewritten.documentMaxFileBytes).toBe(4 * 1_048_576); + }); + it("returns 404 when the backup id is unknown", async () => { const admin = await seedAdminSession(); void admin;