feat(ext): vault-tab Backup & Restore panel

Two cards — Export (passphrase + include-image checkbox → download)
and Restore (file picker + passphrase + new-remote form). Deep-linked
from settings-vault > 'Backup & restore →'.
This commit is contained in:
adlee-was-taken
2026-04-28 22:11:51 -04:00
parent 06913a0aed
commit 419408bbad
3 changed files with 168 additions and 2 deletions

View File

@@ -2,7 +2,7 @@
/// generator defaults (preview + "configure" → opens popover), and /// generator defaults (preview + "configure" → opens popover), and
/// autofill origin-ack revocation. /// autofill origin-ack revocation.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state';
import type { import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types'; } from '../../shared/types';
@@ -158,6 +158,13 @@ export function renderVaultSettings(app: HTMLElement): void {
</div> </div>
</div> </div>
<div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore →</button>
</div>
</div>
<div class="settings-footer"> <div class="settings-footer">
<button class="btn" id="discard-btn">discard</button> <button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button> <button class="btn btn-primary" id="save-btn" disabled>save changes</button>
@@ -187,6 +194,7 @@ export function renderVaultSettings(app: HTMLElement): void {
function wireHandlers(): void { function wireHandlers(): void {
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list')); document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
document.getElementById('trash-retention')?.addEventListener('change', (e) => { document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return; if (!pendingSettings) return;

View File

@@ -0,0 +1,152 @@
import { sendMessage } from '../../shared/state';
type ViewMode = 'idle' | 'exporting' | 'restoring';
let mode: ViewMode = 'idle';
export function renderBackupPanel(app: HTMLElement): void {
app.innerHTML = `
<div class="panel">
<h1>Backup &amp; restore</h1>
<section class="card" id="export-card">
<h2>Export</h2>
<p>Pack this vault into a single encrypted <code>.relbak</code> file.
The backup passphrase is independent of your vault passphrase.</p>
<label><input type="checkbox" id="include-image"> Include reference image (single-file recovery)</label>
<p class="muted">Git history is not bundled from the extension. Use the CLI if you want a full audit-log backup.</p>
<button id="export-btn">Export backup…</button>
<pre id="export-status" class="status hidden"></pre>
</section>
<section class="card" id="restore-card">
<h2>Restore</h2>
<p>Decrypt a <code>.relbak</code> file and push it to a fresh
remote. The remote must be empty.</p>
<input type="file" id="restore-file" accept=".relbak">
<fieldset id="restore-remote" class="hidden">
<legend>Target remote</legend>
<label>Host type
<select id="rt-host-type">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Host URL <input id="rt-host-url" type="url" placeholder="https://git.example.com"></label>
<label>Repo path <input id="rt-repo" type="text" placeholder="user/relicario-vault"></label>
<label>API token <input id="rt-token" type="password"></label>
<button id="restore-btn">Restore…</button>
</fieldset>
<pre id="restore-status" class="status hidden"></pre>
</section>
</div>
`;
wireExport(app);
wireRestore(app);
}
function wireExport(scope: HTMLElement): void {
const btn = scope.querySelector('#export-btn') as HTMLButtonElement;
const includeImage = scope.querySelector('#include-image') as HTMLInputElement;
const status = scope.querySelector('#export-status') as HTMLElement;
btn.addEventListener('click', async () => {
if (mode !== 'idle') return;
const passphrase = window.prompt('Backup passphrase (≥ zxcvbn score 3):');
if (!passphrase) return;
mode = 'exporting';
btn.disabled = true;
showStatus(status, 'Exporting…');
try {
const resp = await sendMessage({
type: 'export_backup',
passphrase,
includeImage: includeImage.checked,
});
if (!resp.ok) throw new Error(resp.error);
const data = (resp as { ok: true; data: { bytes: ArrayBuffer } }).data;
const blob = new Blob([data.bytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `relicario-${ts}.relbak`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
showStatus(status, `Exported (${(data.bytes.byteLength / 1024 / 1024).toFixed(2)} MiB).`);
} catch (e) {
showStatus(status, `Failed: ${(e as Error).message}`);
} finally {
mode = 'idle';
btn.disabled = false;
}
});
}
function wireRestore(scope: HTMLElement): void {
const file = scope.querySelector('#restore-file') as HTMLInputElement;
const remoteFs = scope.querySelector('#restore-remote') as HTMLElement;
const btn = scope.querySelector('#restore-btn') as HTMLButtonElement;
const status = scope.querySelector('#restore-status') as HTMLElement;
file.addEventListener('change', () => {
remoteFs.classList.toggle('hidden', file.files == null || file.files.length === 0);
});
btn.addEventListener('click', async () => {
if (mode !== 'idle') return;
const f = file.files?.[0];
if (!f) return;
const passphrase = window.prompt('Backup passphrase:');
if (!passphrase) return;
const hostType = (scope.querySelector('#rt-host-type') as HTMLSelectElement).value as 'gitea' | 'github';
const hostUrl = (scope.querySelector('#rt-host-url') as HTMLInputElement).value.trim();
const repoPath = (scope.querySelector('#rt-repo') as HTMLInputElement).value.trim();
const apiToken = (scope.querySelector('#rt-token') as HTMLInputElement).value.trim();
if (!hostUrl || !repoPath || !apiToken) {
showStatus(status, 'fill in host URL, repo path, and API token');
return;
}
mode = 'restoring';
btn.disabled = true;
showStatus(status, 'Restoring…');
try {
const bytes = await f.arrayBuffer();
const resp = await sendMessage({
type: 'restore_backup',
bytes,
passphrase,
newRemote: { hostType, hostUrl, repoPath, apiToken },
});
if (!resp.ok) throw new Error(resp.error);
const data = (resp as { ok: true; data: { summary: { itemCount: number; attachmentCount: number; hasImage: boolean } } }).data;
const summary = data.summary;
showStatus(status, `Restored: ${summary.itemCount} items, ${summary.attachmentCount} attachments${summary.hasImage ? ', image included' : ''}. Reload the extension and unlock.`);
} catch (e) {
showStatus(status, `Failed: ${(e as Error).message}`);
} finally {
mode = 'idle';
btn.disabled = false;
}
});
}
function showStatus(el: HTMLElement, text: string): void {
el.textContent = text;
el.classList.remove('hidden');
}
export function teardown(): void {
// No timers / object URLs to clean up — Blob URL revocation is per-export.
mode = 'idle';
}

View File

@@ -16,6 +16,7 @@ import { renderDevices, teardown as teardownDevices } from '../popup/components/
import { renderSettings } from '../popup/components/settings'; import { renderSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history'; import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -66,7 +67,7 @@ function typeLabel(t: ItemType): string {
// Hash routing // Hash routing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history'; type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup';
interface HashRoute { interface HashRoute {
view: VaultView; view: VaultView;
@@ -92,6 +93,7 @@ function parseHash(): HashRoute {
case 'settings': case 'settings':
case 'settings-vault': case 'settings-vault':
case 'field-history': case 'field-history':
case 'backup':
return { view }; return { view };
default: default:
return { view: 'list' }; return { view: 'list' };
@@ -418,6 +420,7 @@ function teardownPaneComponents(): void {
teardownTrash(); teardownTrash();
teardownDevices(); teardownDevices();
teardownFieldHistory(); teardownFieldHistory();
teardownBackup();
} }
function renderPane(): void { function renderPane(): void {
@@ -464,6 +467,9 @@ function renderPane(): void {
case 'field-history': case 'field-history':
renderFieldHistory(pane); renderFieldHistory(pane);
break; break;
case 'backup':
renderBackupPanel(pane);
break;
default: default:
pane.className = 'vault-pane vault-pane--empty'; pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item'; pane.innerHTML = 'select an item';