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;