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:
@@ -2,7 +2,7 @@
|
||||
/// generator defaults (preview + "configure" → opens popover), and
|
||||
/// 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 {
|
||||
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
|
||||
} from '../../shared/types';
|
||||
@@ -158,6 +158,13 @@ export function renderVaultSettings(app: HTMLElement): void {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-section__title">backup & restore</div>
|
||||
<div class="settings-row">
|
||||
<button class="btn" id="open-backup">Backup & restore →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-footer">
|
||||
<button class="btn" id="discard-btn">discard</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 {
|
||||
document.getElementById('back-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) => {
|
||||
if (!pendingSettings) return;
|
||||
|
||||
152
extension/src/vault/components/backup-panel.ts
Normal file
152
extension/src/vault/components/backup-panel.ts
Normal 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 & 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';
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { renderDevices, teardown as teardownDevices } from '../popup/components/
|
||||
import { renderSettings } from '../popup/components/settings';
|
||||
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
|
||||
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
|
||||
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -66,7 +67,7 @@ function typeLabel(t: ItemType): string {
|
||||
// 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 {
|
||||
view: VaultView;
|
||||
@@ -92,6 +93,7 @@ function parseHash(): HashRoute {
|
||||
case 'settings':
|
||||
case 'settings-vault':
|
||||
case 'field-history':
|
||||
case 'backup':
|
||||
return { view };
|
||||
default:
|
||||
return { view: 'list' };
|
||||
@@ -418,6 +420,7 @@ function teardownPaneComponents(): void {
|
||||
teardownTrash();
|
||||
teardownDevices();
|
||||
teardownFieldHistory();
|
||||
teardownBackup();
|
||||
}
|
||||
|
||||
function renderPane(): void {
|
||||
@@ -464,6 +467,9 @@ function renderPane(): void {
|
||||
case 'field-history':
|
||||
renderFieldHistory(pane);
|
||||
break;
|
||||
case 'backup':
|
||||
renderBackupPanel(pane);
|
||||
break;
|
||||
default:
|
||||
pane.className = 'vault-pane vault-pane--empty';
|
||||
pane.innerHTML = 'select an item';
|
||||
|
||||
Reference in New Issue
Block a user