feat(backup): admin UI with admin-chosen target directory
Build desktop / desktop (push) Successful in 4m42s
Build & push images / images (push) Successful in 2m53s
CI / check (push) Successful in 39s

The backup destination is now chosen by the on-site admin in the UI (Setup ->
Backup), not a server env var. An env-pinned target defeats the purpose: the admin
can't point backups at a freshly-plugged USB or a NAS mount without editing .env
and restarting. The encryption key stays a server secret.

Target storage:
- New site_config.backup_target_dir (migration 0016, nullable; null = not
  configured). BackupService reads it fresh each run, so a UI change takes effect
  with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it
  backs up.

Routes:
- PUT /api/backup/config  -- set/clear the target (backup:update; upserts id=1).
- POST /api/backup/test   -- probe a candidate path server-side (exists / is a
  directory / writable) so the admin gets feedback before relying on it.
- status() now exposes targetDir + keyPresent, so the UI distinguishes
  'no target set' from 'BACKUP_KEY missing'.

UI (apps/web/src/BackupSettings.tsx):
- A Setup -> Backup tab (gated backup:read): an editable target-path field with a
  Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status
  panel (config state, last-run size/pruned/error, a distinct amber missing-key
  warning), a Back up now button, and the restore-is-out-of-band note. Full i18n
  (sq + en); nav.backup.
- API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup.

Also includes a small in-progress copy trim to the setup-intro i18n strings.

Verified live with Playwright: typed a path -> Test reported writable -> Save
persisted it -> status reflected it and showed the key-missing warning. Whole
monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-29 12:21:26 +02:00
parent 0c218179c4
commit d5e41500a8
16 changed files with 630 additions and 74 deletions
+37 -1
View File
@@ -61,6 +61,7 @@ export const en: Catalog = {
reports: "Reports",
recycleBin: "Recycle bin",
logs: "Logs",
backup: "Backup",
profile: "Profile",
},
profile: {
@@ -307,7 +308,7 @@ export const en: Catalog = {
setup: {
title: "Setup",
intro:
"Add your barrier controllers first — set which relay is entry/exit and which terminal the entry button is wired to. Then add readers, cameras and printers and point each at the barrier it serves.",
"Add your barrier controllers first. Then add readers (QR/RF), cameras, printers",
catControllers: "Controllers (barriers + entry button)",
catReaders: "Readers (QR / RFID)",
catCameras: "Cameras (snapshot + plate)",
@@ -830,6 +831,41 @@ export const en: Catalog = {
path: "Path",
empty: "No logs.",
},
backup: {
title: "Backup",
intro:
"An encrypted copy of the database (the signed ledger) to an external disk. Runs automatically every day and from the button below.",
statusTitle: "Status",
configured: "Enabled",
notConfigured: "Not configured",
notConfiguredHint: "Set BACKUP_TARGET_DIR and BACKUP_KEY on the server to enable backups.",
running: "Running…",
idle: "Idle",
lastSuccess: "Last successful backup",
lastError: "Last error",
never: "Never",
lastFile: "File",
size: "Size",
pruned: "Pruned",
runNow: "Back up now",
runSuccess: "Backup complete.",
runFailed: "Backup failed.",
notConfiguredError: "Backup is not configured.",
restoreNote:
"Restore is not done here — it's an out-of-band step when provisioning a fresh appliance (needs the backup file + the keys you escrowed offsite).",
targetLabel: "Backup location",
targetPlaceholder: "e.g. /mnt/backup or /media/usb",
targetHint: "An absolute path to a mounted disk (USB/SATA) or a network share (SMB/NFS).",
save: "Save",
saved: "Saved.",
test: "Test target",
testOk: "The location is writable.",
testEmpty: "Enter a path.",
testMissing: "The location does not exist.",
testNotDir: "The path is not a directory.",
testNotWritable: "The directory is not writable.",
keyMissing: "The encryption key (BACKUP_KEY) is missing on the server — set it to enable backups.",
},
pay: {
ticket: "Ticket",
entry: "Entry",