From 66d61c87cad1ac1994d61e81c8c06739aa0cb8c5 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:45:16 +0200 Subject: [PATCH] feat(backup): .picpeak download + upload-restore UI in Backup Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a self-contained "Portable backup (.picpeak)" card to the Restore tab, completing the GUI-only roundtrip: - Download: optional "include original photos" toggle + a prominent plaintext-secrets warning, streams the file via a blob download. - Restore: file picker → destructive confirmation modal ("replaces ALL data except your current account, cannot be undone") → multipart upload to /admin/backup/picpeak/import → success summary. If the backup uses external media, shows a banner to reconfigure the mount, with a docs link. Kept separate from the legacy RestoreWizard (different format/flow). en+de strings added; dark-mode variants throughout. --- .../components/admin/PicpeakBackupCard.tsx | 210 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 22 +- frontend/src/i18n/locales/en.json | 22 +- frontend/src/pages/admin/BackupManagement.tsx | 6 +- 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/admin/PicpeakBackupCard.tsx diff --git a/frontend/src/components/admin/PicpeakBackupCard.tsx b/frontend/src/components/admin/PicpeakBackupCard.tsx new file mode 100644 index 00000000..de8072f3 --- /dev/null +++ b/frontend/src/components/admin/PicpeakBackupCard.tsx @@ -0,0 +1,210 @@ +import React, { useRef, useState } from 'react'; +import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +import { Button, Card } from '../common'; +import { api } from '../../config/api'; + +interface RestoreResult { + tables: number; + filesRestored: number; + usesExternalMedia: boolean; +} + +// Portable ".picpeak" roundtrip: download a self-contained backup here, upload +// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data +// replaced) that keeps only the current account — hence the explicit confirm. +export const PicpeakBackupCard: React.FC = () => { + const { t } = useTranslation(); + const fileRef = useRef(null); + const [includePhotos, setIncludePhotos] = useState(false); + const [downloading, setDownloading] = useState(false); + const [pendingFile, setPendingFile] = useState(null); + const [restoring, setRestoring] = useState(false); + const [result, setResult] = useState(null); + + const handleDownload = async () => { + setDownloading(true); + try { + const res = await api.get('/admin/backup/picpeak/export', { + params: { includePhotos }, + responseType: 'blob', + }); + const cd = (res.headers['content-disposition'] as string) || ''; + const match = cd.match(/filename="?([^"]+)"?/); + const filename = (match && match[1]) || 'picpeak-backup.picpeak'; + const url = window.URL.createObjectURL(res.data as Blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch (_) { + toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.')); + } finally { + setDownloading(false); + } + }; + + const onFilePick = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) setPendingFile(f); + e.target.value = ''; // let the user re-pick the same file after cancelling + }; + + const confirmRestore = async () => { + if (!pendingFile) return; + setRestoring(true); + try { + const fd = new FormData(); + fd.append('backup', pendingFile); + const res = await api.post('/admin/backup/picpeak/import', fd); + setResult(res.data); + setPendingFile(null); + toast.success(t('backup.picpeak.restoreDone', 'Backup restored.')); + } catch (e: any) { + const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.'); + toast.error(msg); + setPendingFile(null); + } finally { + setRestoring(false); + } + }; + + return ( + +

+ {t('backup.picpeak.title', 'Portable backup (.picpeak)')} +

+

+ {t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')} +

+ + {/* Download */} +
+ +
+ +

+ {t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')} +

+
+ +
+ +
+ + {/* Restore */} +
+

+ {t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')} +

+

+ {t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')} +

+ + + + {result && ( +
+
+ +
+

+ {t('backup.picpeak.restoreDone', 'Backup restored.')} +

+

+ {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { + tables: result.tables, + files: result.filesRestored, + })} +

+ {result.usesExternalMedia && ( +

+ + + {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '} + + {t('backup.picpeak.externalMediaLink', 'Setup guide')} + + + +

+ )} + +
+
+
+ )} +
+ + {/* Destructive confirmation */} + {pendingFile && ( +
+
+
+ +
+

+ {t('backup.picpeak.confirmTitle', 'Restore will delete all current data')} +

+

+ {t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')} +

+

{pendingFile.name}

+
+
+
+ + +
+
+
+ )} +
+ ); +}; + +PicpeakBackupCard.displayName = 'PicpeakBackupCard'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9842f66b..326f8ac1 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1779,7 +1779,27 @@ "title": "E-Mail-Einstellungen" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portables Backup (.picpeak)", + "intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.", + "includePhotos": "Original-Galeriefotos einschließen (größere Datei)", + "secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.", + "download": ".picpeak herunterladen", + "downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.", + "restoreTitle": "Aus einer .picpeak wiederherstellen", + "restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.", + "chooseFile": ".picpeak-Datei auswählen…", + "restoreDone": "Backup wiederhergestellt.", + "restoreFailed": "Wiederherstellung fehlgeschlagen.", + "restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.", + "externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.", + "externalMediaLink": "Einrichtungsanleitung", + "reload": "App neu laden", + "confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten", + "confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.", + "confirmRestore": "Löschen & wiederherstellen" + } }, "branding": { "title": "Branding" diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 403b4f9d..84ce2a13 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1335,7 +1335,27 @@ "title": "Email Settings" }, "backup": { - "title": "Backup" + "title": "Backup", + "picpeak": { + "title": "Portable backup (.picpeak)", + "intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.", + "includePhotos": "Include original gallery photos (larger file)", + "secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.", + "download": "Download .picpeak", + "downloadFailed": "Could not create the backup file.", + "restoreTitle": "Restore from a .picpeak", + "restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.", + "chooseFile": "Choose .picpeak file…", + "restoreDone": "Backup restored.", + "restoreFailed": "Restore failed.", + "restoreSummary": "{{tables}} tables and {{files}} files restored.", + "externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.", + "externalMediaLink": "Setup guide", + "reload": "Reload app", + "confirmTitle": "Restore will delete all current data", + "confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.", + "confirmRestore": "Delete & restore" + } }, "branding": { "title": "Branding" diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index 1e6532e8..d0d74586 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; +import { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; @@ -225,7 +226,10 @@ export const BackupManagement: React.FC = () => { )} {activeTab === 'restore' && ( - setActiveTab('integrity')} /> +
+ + setActiveTab('integrity')} /> +
)} {activeTab === 'integrity' && (