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
|
/// 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 & restore</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<button class="btn" id="open-backup">Backup & 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;
|
||||||
|
|||||||
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 { 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user