Files
r-zakarya 45ba47f772 feat(settings): keep backup pwd on disable, dedicated pwd button, tab icons
Auto-backup no longer wipes the stored password when disabled, so
re-enabling reuses it silently. A dedicated "Set/Change backup password"
button (mirrors sync) owns the password, with a warning status when
unset. Corrected the stale hint that claimed the backup pwd was derived
from the master password. Added icons to each settings tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:53:45 +01:00

273 lines
11 KiB
JavaScript

// ============================================================
// app.backup.js — AUTO-BACKUP module (extracted from app.js, §3.1)
// ============================================================
//
// Silent scheduled encrypted exports (config in DPAPI prefs, files via the
// folder/file bridge cmds). Pure declarations + two consts, no top-level
// side effects → loads BEFORE app.js. Uses encryptExportPayload (app.import.js),
// Bridge, api, state — all resolved via shared global scope at call time.
//
// ============================================================
// AUTO-BACKUP (silent encrypted exports on a schedule)
// ============================================================
// All config is device-local (folder paths and passwords don't sync
// meaningfully across machines) and persisted via Delphi DPAPI prefs
// so it survives the port-rotation localStorage wipe.
const ABK = {
enabled: 'autoBackupEnabled', // '1' | ''
dir: 'autoBackupDir', // absolute Windows path
interval: 'autoBackupInterval', // days, int as string
keep: 'autoBackupKeep', // count, int as string
last: 'autoBackupLast', // ISO timestamp of last successful run
pwd: 'autoBackupPwd', // user-chosen pwd, used silently
};
const AUTO_BACKUP_PREFIX = 'vault-autobackup-';
async function loadAutoBackupConfig() {
if (!Bridge.active) return null;
const [enabled, dir, interval, keep, last, pwd] = await Promise.all([
Bridge.getPref(ABK.enabled),
Bridge.getPref(ABK.dir),
Bridge.getPref(ABK.interval),
Bridge.getPref(ABK.keep),
Bridge.getPref(ABK.last),
Bridge.getPref(ABK.pwd),
]);
return {
enabled: enabled === '1',
dir: dir || '',
interval: Math.max(1, parseInt(interval, 10) || 7),
keep: Math.max(1, parseInt(keep, 10) || 10),
last: last || '',
hasPwd: !!pwd,
pwd,
};
}
function refreshAutoBackupUI(cfg) {
if (!cfg) {
$('#autoBackupField').style.display = 'none';
return;
}
$('#autoBackupField').style.display = '';
$('#settingAutoBackupEnabled').checked = cfg.enabled;
$('#autoBackupConfig').style.display = cfg.enabled ? '' : 'none';
$('#autoBackupDir').textContent = cfg.dir || '(not set)';
$('#settingAutoBackupInterval').value = cfg.interval;
$('#settingAutoBackupKeep').value = cfg.keep;
$('#autoBackupLast').textContent = cfg.last
? 'Last run: ' + cfg.last.replace('T', ' ').slice(0, 16)
: 'Never run yet';
$('#autoBackupSetPwdBtn').textContent = cfg.hasPwd
? 'Change backup password' : 'Set backup password';
const pwdStatus = $('#autoBackupPwdStatus');
if (cfg.hasPwd) {
pwdStatus.innerHTML = '';
pwdStatus.style.color = 'var(--text-faint)';
pwdStatus.textContent = 'Set.';
} else {
pwdStatus.style.color = 'var(--warning, #e0a800)';
pwdStatus.innerHTML = '<svg width="13" height="13"><use href="#i-alert"/></svg>' +
'<span>Not set — backups won\'t run</span>';
}
}
async function pickAutoBackupFolder() {
const path = await Bridge.pickFolder();
if (!path) return;
Bridge.setPref(ABK.dir, path);
$('#autoBackupDir').textContent = path;
toast('Backup folder set');
}
async function promptAndStoreBackupPwd() {
let lastError = '';
let attempts = 0;
const MAX_ATTEMPTS = 5;
for (;;) {
const pwd = await promptDialog({
title: 'Choose a backup password',
message: 'You will need this to restore the auto-backups. Save it somewhere safe — it is independent of your master password.',
placeholder: 'At least 6 characters',
password: true,
okText: 'Save',
error: lastError,
});
if (!pwd) return false; // cancelled (false / null / '' / undefined)
if (pwd.length >= 6) {
Bridge.setPref(ABK.pwd, pwd);
return true;
}
attempts++;
if (attempts >= MAX_ATTEMPTS) {
toast('Too many invalid attempts', 'error');
return false;
}
lastError = 'Password must be at least 6 characters (attempt ' +
attempts + ' / ' + MAX_ATTEMPTS + ').';
}
}
async function onToggleAutoBackup(ev) {
const enabled = ev.target.checked;
if (enabled) {
Bridge.setPref(ABK.enabled, '1');
$('#autoBackupConfig').style.display = '';
// No pwd prompt here — the dedicated "Set backup password" button
// owns it (like sync). Backups no-op until it's set.
const cfg = await loadAutoBackupConfig();
refreshAutoBackupUI(cfg);
toast(cfg.hasPwd ? 'Auto-backup enabled'
: 'Auto-backup enabled — set a backup password to start');
} else {
Bridge.setPref(ABK.enabled, '');
// Keep the stored pwd — re-enabling reuses it silently. Change it
// anytime via the dedicated "Change backup password" button.
$('#autoBackupConfig').style.display = 'none';
toast('Auto-backup disabled');
}
}
// Dedicated pwd button (mirrors sync's syncSetEncPwdFlow) so the user can
// change the backup password without the disable/re-enable dance.
async function changeBackupPwd() {
if (await promptAndStoreBackupPwd()) {
refreshAutoBackupUI(await loadAutoBackupConfig());
toast('Backup password saved');
}
}
async function runAutoBackupNow(silent) {
const cfg = await loadAutoBackupConfig();
if (!cfg) return silent || toast('Bridge not available', 'error');
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
if (!state.cryptoKey) return silent || toast('Vault is locked', 'warning');
// Manual "Backup now" shows a spinner (big vaults take ~30s). The
// scheduled on-unlock run stays silent (no overlay stealing focus).
if (!silent) {
showBusy('Reading vault…');
await new Promise(r => setTimeout(r, 0));
}
try {
const payload = {
version: 1,
exported_at: new Date().toISOString(),
username: state.username,
folders: (state.folders || [])
.filter(f => f && f.name && f.name !== 'All')
.map(f => ({
name: f.name,
color: f.color || '',
icon: f.icon || '',
})),
// Same as the manual export container — keep the avatar so a
// restore from an auto-backup brings the profile picture back.
avatar_b64: state.avatarDataUri || '',
entries: [],
};
let _bkDone = 0;
const _bkTotal = state.entries.length;
for (const e of state.entries) {
_bkDone++;
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try { plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv); }
catch (_) { plainCustom = []; }
}
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(
full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename,
mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments: attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at, updated_at: e.updated_at,
});
}
if (!silent) updateBusy('Encrypting backup…');
const container = await encryptExportPayload(payload, cfg.pwd);
const json = JSON.stringify(container, null, 2);
// Filename: yyyymmdd-HHMMSS for filesystem-sort-friendliness.
const ts = new Date().toISOString()
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
const fname = AUTO_BACKUP_PREFIX + ts + '.json';
const path = cfg.dir.replace(/[\\/]+$/, '') + '\\' + fname;
if (!silent) updateBusy('Writing file…');
const res = await Bridge.writeFile(path, json, pct => {
if (!silent) updateBusy('Writing file… ' + pct + '%');
});
if (!res.ok) {
if (!silent) toast('Backup failed: ' + (res.error || 'unknown'), 'error');
return;
}
const now = new Date().toISOString();
Bridge.setPref(ABK.last, now);
$('#autoBackupLast').textContent = 'Last run: ' + now.replace('T', ' ').slice(0, 16);
if (!silent) toast(payload.entries.length + ' entries backed up');
applyAutoBackupRetention(cfg.dir, cfg.keep);
} catch (err) {
if (!silent) toast('Backup failed: ' + (err && err.message ? err.message : err), 'error');
} finally {
if (!silent) hideBusy();
}
}
async function applyAutoBackupRetention(dir, keep) {
try {
const files = await Bridge.listFiles(dir, AUTO_BACKUP_PREFIX);
if (files.length <= keep) return;
// Sort by name desc (timestamps in filename → lexical = chronological)
files.sort((a, b) => (a.name < b.name ? 1 : -1));
const toDelete = files.slice(keep);
for (const f of toDelete) {
const path = dir.replace(/[\\/]+$/, '') + '\\' + f.name;
await Bridge.deleteFile(path);
}
} catch (e) {
// Retention is best-effort; user can clean up manually.
}
}
async function runAutoBackupIfDue() {
if (!Bridge.active) return;
const cfg = await loadAutoBackupConfig();
if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return;
if (!state.cryptoKey) return;
const intervalMs = cfg.interval * 24 * 3600 * 1000;
const last = cfg.last ? Date.parse(cfg.last) : 0;
if (last && (Date.now() - last) < intervalMs) return;
await runAutoBackupNow(true); // silent — no spinner on the scheduled run
}