feat(backup): admin UI with admin-chosen target directory
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:
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiError,
|
||||
fetchBackupStatus,
|
||||
runBackup,
|
||||
setBackupTarget,
|
||||
testBackupTarget,
|
||||
type BackupStatus,
|
||||
type TargetCheck,
|
||||
} from "./api.js";
|
||||
import { formatRelativeDateTime } from "./lib/format.js";
|
||||
|
||||
// Admin screen for the on-site encrypted DB backup. The admin picks the TARGET DIRECTORY here
|
||||
// (stored in site_config; a mounted USB/SATA/SMB/NFS path) — the encryption key stays a server
|
||||
// secret. Shows status + last-run outcome, a "Test target" probe, and a manual "Back up now".
|
||||
// Gated by backup:read (config/test by backup:update, run by backup:create). RESTORE is absent
|
||||
// by design — out-of-band on a fresh appliance. See wiki/concepts/backup-recovery.md.
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const mb = n / 1048576;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
/** Map a target-check result to a localized message. */
|
||||
function checkMessage(c: TargetCheck, t: (k: string) => string): string {
|
||||
if (c.ok) return t("backup.testOk");
|
||||
switch (c.reason) {
|
||||
case "empty":
|
||||
return t("backup.testEmpty");
|
||||
case "not_a_dir":
|
||||
return t("backup.testNotDir");
|
||||
case "not_writable":
|
||||
return t("backup.testNotWritable");
|
||||
default:
|
||||
return t("backup.testMissing");
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BackupStatus }) {
|
||||
const { t } = useTranslation();
|
||||
if (!status.configured) {
|
||||
return <span className="text-[0.75rem] font-semibold text-term-muted">{t("backup.notConfigured")}</span>;
|
||||
}
|
||||
if (status.running) {
|
||||
return <span className="text-[0.75rem] font-semibold text-term-amber">{t("backup.running")}</span>;
|
||||
}
|
||||
return <span className="text-[0.75rem] font-semibold text-term-green">{t("backup.configured")}</span>;
|
||||
}
|
||||
|
||||
export function BackupSettings() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [toast, setToast] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||
const [target, setTarget] = useState("");
|
||||
const [check, setCheck] = useState<{ kind: "ok" | "err"; msg: string } | null>(null);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ["backup-status"],
|
||||
queryFn: fetchBackupStatus,
|
||||
refetchInterval: (query) => (query.state.data?.running ? 2000 : false),
|
||||
});
|
||||
const status = q.data;
|
||||
|
||||
// Seed the editable field from the saved value once it loads (and when it changes server-side).
|
||||
useEffect(() => {
|
||||
if (status) setTarget(status.targetDir ?? "");
|
||||
}, [status?.targetDir]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => setBackupTarget(target.trim() || null),
|
||||
onSuccess: (next) => {
|
||||
setToast({ kind: "ok", msg: t("backup.saved") });
|
||||
setCheck(null);
|
||||
qc.setQueryData(["backup-status"], next);
|
||||
},
|
||||
onError: () => setToast({ kind: "err", msg: t("backup.runFailed") }),
|
||||
});
|
||||
|
||||
const test = useMutation({
|
||||
mutationFn: () => testBackupTarget(target.trim()),
|
||||
onSuccess: (res) => setCheck({ kind: res.ok ? "ok" : "err", msg: checkMessage(res, t) }),
|
||||
});
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: runBackup,
|
||||
onSuccess: () => {
|
||||
setToast({ kind: "ok", msg: t("backup.runSuccess") });
|
||||
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const code = err instanceof ApiError ? err.message : "";
|
||||
setToast({
|
||||
kind: "err",
|
||||
msg: code === "backup_not_configured" ? t("backup.notConfiguredError") : t("backup.runFailed"),
|
||||
});
|
||||
void qc.invalidateQueries({ queryKey: ["backup-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
const dirty = (status?.targetDir ?? "") !== target.trim();
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">{t("backup.title")}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!status?.configured || status?.running || run.isPending || dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
run.mutate();
|
||||
}}
|
||||
>
|
||||
{status?.running || run.isPending ? t("backup.running") : t("backup.runNow")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 max-w-2xl text-[0.75rem] text-term-muted">{t("backup.intro")}</p>
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
className={`mb-3 rounded-term border px-3 py-2 text-[0.75rem] ${
|
||||
toast.kind === "ok"
|
||||
? "border-term-green/40 bg-term-green/5 text-term-green"
|
||||
: "border-term-red/40 bg-term-red/5 text-term-red"
|
||||
}`}
|
||||
>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target directory — the admin-chosen destination. */}
|
||||
<div className="card mb-3 p-4">
|
||||
<div className="field">
|
||||
<span className="label">{t("backup.targetLabel")}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-96 max-w-full"
|
||||
value={target}
|
||||
placeholder={t("backup.targetPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setTarget(e.target.value);
|
||||
setCheck(null);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
disabled={test.isPending || !target.trim()}
|
||||
onClick={() => test.mutate()}
|
||||
>
|
||||
{t("backup.test")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={save.isPending || !dirty}
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
{t("backup.save")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="mt-1 text-[0.6875rem] text-term-muted">{t("backup.targetHint")}</span>
|
||||
{check && (
|
||||
<span className={`mt-1 text-[0.75rem] ${check.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
|
||||
{check.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
{q.isLoading || !status ? (
|
||||
<div className="text-[0.75rem] text-term-muted">{t("common.loading")}</div>
|
||||
) : (
|
||||
<dl className="grid grid-cols-[10rem_1fr] gap-x-4 gap-y-2 text-[0.8125rem]">
|
||||
<dt className="text-term-muted">{t("backup.statusTitle")}</dt>
|
||||
<dd>
|
||||
<StatusBadge status={status} />
|
||||
</dd>
|
||||
|
||||
{!status.keyPresent && (
|
||||
<>
|
||||
<dt className="text-term-muted" />
|
||||
<dd className="text-[0.75rem] text-term-amber">{t("backup.keyMissing")}</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
<dt className="text-term-muted">{t("backup.lastSuccess")}</dt>
|
||||
<dd className="text-term-text">
|
||||
{status.lastSuccessAt ? formatRelativeDateTime(status.lastSuccessAt, t) : t("backup.never")}
|
||||
</dd>
|
||||
|
||||
{status.lastResult && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.size")}</dt>
|
||||
<dd className="text-term-text tabular-nums">
|
||||
{formatBytes(status.lastResult.bytes)}
|
||||
{status.lastResult.prunedFiles > 0 && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({t("backup.pruned")}: {status.lastResult.prunedFiles})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status.lastError && (
|
||||
<>
|
||||
<dt className="text-term-muted">{t("backup.lastError")}</dt>
|
||||
<dd className="text-term-red">
|
||||
{status.lastError}
|
||||
{status.lastErrorAt && (
|
||||
<span className="ml-2 text-term-muted">
|
||||
({formatRelativeDateTime(status.lastErrorAt, t)})
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 max-w-2xl text-[0.6875rem] text-term-muted">{t("backup.restoreNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -227,6 +227,54 @@ export function fetchLogs(params: {
|
||||
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
// --- Backup ---------------------------------------------------------------
|
||||
// On-site encrypted DB backup. See wiki/concepts/backup-recovery.md.
|
||||
|
||||
export interface BackupStatus {
|
||||
configured: boolean;
|
||||
/** Admin-chosen target directory (null = not set). */
|
||||
targetDir: string | null;
|
||||
/** Whether the env encryption key is present (a missing key is flagged distinctly). */
|
||||
keyPresent: boolean;
|
||||
running: boolean;
|
||||
lastSuccessAt: string | null;
|
||||
lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
||||
lastErrorAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
/** Set (or clear, with "") the admin-chosen target directory. Returns the new status. */
|
||||
export async function setBackupTarget(targetDir: string | null): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/config", { method: "PUT", body: JSON.stringify({ targetDir }) });
|
||||
}
|
||||
|
||||
export interface TargetCheck {
|
||||
ok: boolean;
|
||||
/** "empty" | "missing" | "not_a_dir" | "not_writable" when !ok. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Probe a candidate target path server-side (exists / is a dir / is writable). */
|
||||
export async function testBackupTarget(targetDir: string): Promise<TargetCheck> {
|
||||
return apiFetch("/api/backup/test", { method: "POST", body: JSON.stringify({ targetDir }) });
|
||||
}
|
||||
|
||||
export interface BackupRunResult {
|
||||
ok: true;
|
||||
path: string;
|
||||
bytes: number;
|
||||
prunedFiles: number;
|
||||
}
|
||||
|
||||
/** Trigger a manual "back up now". Throws on 409 (not configured) / 500 (run failed). */
|
||||
export async function runBackup(): Promise<BackupRunResult> {
|
||||
return apiFetch("/api/backup/run", { method: "POST" });
|
||||
}
|
||||
|
||||
// --- Device setup ---------------------------------------------------------
|
||||
|
||||
export interface ConfigField {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const sq = {
|
||||
reports: "Raportet",
|
||||
recycleBin: "Koshi",
|
||||
logs: "Loget",
|
||||
backup: "Kopje rezervë",
|
||||
profile: "Profili",
|
||||
},
|
||||
profile: {
|
||||
@@ -310,7 +311,7 @@ export const sq = {
|
||||
setup: {
|
||||
title: "Konfigurimi",
|
||||
intro:
|
||||
"Shto fillimisht kontrollerat e barrierave — cakto cili rele është hyrje/dalje dhe në cilin terminal është lidhur butoni i hyrjes. Pastaj shto lexues, kamera dhe printera dhe drejto secilin te barriera që shërben.",
|
||||
"Shto fillimisht kontrollerat e barrierave — Pastaj shto lexues (QR/RF), kamera, printera.",
|
||||
// Category titles + the singular noun used in buttons/modal titles.
|
||||
catControllers: "Kontrollerat (barrierat + butoni i hyrjes)",
|
||||
catReaders: "Lexuesit (QR / RFID)",
|
||||
@@ -845,6 +846,42 @@ export const sq = {
|
||||
path: "Rruga",
|
||||
empty: "Asnjë regjistër.",
|
||||
},
|
||||
backup: {
|
||||
title: "Kopje rezervë",
|
||||
intro:
|
||||
"Kopje e enkriptuar e bazës së të dhënave (regjistri i nënshkruar) në një disk të jashtëm. Bëhet automatikisht çdo ditë dhe me butonin më poshtë.",
|
||||
statusTitle: "Gjendja",
|
||||
configured: "Aktive",
|
||||
notConfigured: "E pakonfiguruar",
|
||||
notConfiguredHint:
|
||||
"Cakto BACKUP_TARGET_DIR dhe BACKUP_KEY në server që të aktivizohet kopja rezervë.",
|
||||
running: "Duke u kryer…",
|
||||
idle: "Në pritje",
|
||||
lastSuccess: "Kopja e fundit e suksesshme",
|
||||
lastError: "Gabimi i fundit",
|
||||
never: "Asnjëherë",
|
||||
lastFile: "Skedari",
|
||||
size: "Madhësia",
|
||||
pruned: "Të hequra",
|
||||
runNow: "Bëj kopje tani",
|
||||
runSuccess: "Kopja rezervë u krye.",
|
||||
runFailed: "Kopja rezervë dështoi.",
|
||||
notConfiguredError: "Kopja rezervë nuk është e konfiguruar.",
|
||||
restoreNote:
|
||||
"Rikthimi nuk bëhet nga këtu — është veprim i jashtëm gjatë instalimit të një aparati të ri (kërkon skedarin e kopjes + çelësat e ruajtur jashtë).",
|
||||
targetLabel: "Vendndodhja e kopjes",
|
||||
targetPlaceholder: "p.sh. /mnt/backup ose /media/usb",
|
||||
targetHint: "Rrugë absolute drejt një disku të lidhur (USB/SATA) ose një ndarjeje rrjeti (SMB/NFS).",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
test: "Testo vendndodhjen",
|
||||
testOk: "Vendndodhja është e shkruajtshme.",
|
||||
testEmpty: "Shkruaj një rrugë.",
|
||||
testMissing: "Vendndodhja nuk ekziston.",
|
||||
testNotDir: "Rruga nuk është një dosje.",
|
||||
testNotWritable: "Dosja nuk është e shkruajtshme.",
|
||||
keyMissing: "Çelësi i enkriptimit (BACKUP_KEY) mungon në server — caktoje që kopja të aktivizohet.",
|
||||
},
|
||||
pay: {
|
||||
ticket: "Bileta",
|
||||
entry: "Hyrja",
|
||||
|
||||
@@ -42,6 +42,7 @@ import { UsersManager } from "./UsersManager.js";
|
||||
import { RolesManager } from "./RolesManager.js";
|
||||
import { ShiftsHistory } from "./ShiftsHistory.js";
|
||||
import { LogsViewer } from "./LogsViewer.js";
|
||||
import { BackupSettings } from "./BackupSettings.js";
|
||||
import { RecycleBin } from "./RecycleBin.js";
|
||||
import { Profile } from "./Profile.js";
|
||||
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
|
||||
@@ -106,6 +107,7 @@ function SetupLayout() {
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -444,6 +446,7 @@ function RootLayout() {
|
||||
show("user:read") ||
|
||||
show("role:read") ||
|
||||
show("recyclebin:read") ||
|
||||
show("backup:read") ||
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
@@ -694,6 +697,14 @@ const logsRoute = createRoute({
|
||||
component: LogsViewer,
|
||||
});
|
||||
|
||||
// Encrypted DB backup — status + manual run. Gated by backup:read (run by backup:create).
|
||||
const backupRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "backup",
|
||||
beforeLoad: ({ context }) => requirePerm("backup:read")(context),
|
||||
component: BackupSettings,
|
||||
});
|
||||
|
||||
// My profile — self-service for ANY signed-in user (no permission gate). Edits only
|
||||
// the caller's own name/email/password. See Profile.tsx and routes/auth.ts.
|
||||
const profileRoute = createRoute({
|
||||
@@ -726,6 +737,7 @@ const routeTree = rootRoute.addChildren([
|
||||
rolesRoute,
|
||||
recycleBinRoute,
|
||||
logsRoute,
|
||||
backupRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user