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:
@@ -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