feat: native save + auto-backup + folder customization + attachments + UX bundle

- File: native Save As dialog via Bridge.saveFile (replaces WebView2
  browser download popup) for encrypted JSON + CSV exports.
- Auto-backup: silent periodic encrypted JSON to a chosen folder,
  user-set interval + retention, separate DPAPI-stored password, runs
  5s after unlock if due. New file/* bridge cmds (folder/pick,
  file/write, file/listMatch, file/delete).
- Folders: per-folder color + icon (8-swatch palette, 8 icon presets),
  drag-reorder via HTML5 DnD with insert-line indicators, edit pencil
  on hover. New POST /folders/reorder + PUT /folders/{name}. Folder
  chip on cards inherits custom icon + color.
- Recently used: vault_entries.accessed_at + POST /entries/{id}/touch
  (debounced 2s), sidebar Tools entry showing top-10 by accessed_at.
- Encrypted attachments: per-entry file storage (5MB cap), AES-GCM
  with vault key, native Save As download, paperclip upload in
  slideover. New entry_attachments table + PM.Handler.Attachments.
- Password expiry: vault_entries.password_changed_at (conditional bump
  via SQL CASE only when ciphertext differs), passwordExpiryDays
  setting, "Aged" badge on cards + matching Filters chip.
- Recovery: Print button on generated code modal (A4 printable sheet
  via @media print, code in 32px monospace + instructions).
- Audit log viewer (sidebar Tools, GET /audit with pagination cursor).
- Plaintext CSV export + Filters dropdown with 9 predicates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:12:06 +01:00
parent 63fac5b3b7
commit fa7ea191be
13 changed files with 3927 additions and 1378 deletions
+86 -3
View File
@@ -44,6 +44,9 @@
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-2 2 2 2-3 3-2-2"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
<symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
<rect x="40" y="50" width="40" height="35" rx="4" fill="var(--bg-elev)" stroke="var(--accent)" stroke-width="2"/>
@@ -220,10 +223,18 @@
<svg><use href="#i-key"/></svg>
<span>TOTP generator</span>
</button>
<button class="nav-item" id="sidebarRecentBtn">
<svg><use href="#i-rotate-ccw"/></svg>
<span>Recently used</span>
</button>
<button class="nav-item" id="sidebarHealthBtn">
<svg><use href="#i-alert"/></svg>
<span>Vault health</span>
</button>
<button class="nav-item" id="sidebarAuditBtn">
<svg><use href="#i-list"/></svg>
<span>Audit log</span>
</button>
<button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg>
<span>Import vault</span>
@@ -268,6 +279,13 @@
<svg><use href="#i-table"/></svg>
</button>
</div>
<div class="filters-wrap">
<button class="icon-btn" id="filtersBtn" title="Filters">
<svg><use href="#i-list"/></svg>
<span class="filters-count" id="filtersCount" style="display:none">0</span>
</button>
<div class="filters-menu is-hidden" id="filtersMenu"></div>
</div>
<button class="icon-btn" id="cheatsheetBtn" title="Keyboard shortcuts (?)">
<span style="font-weight:700;font-size:14px">?</span>
</button>
@@ -320,6 +338,9 @@
<span class="content-meta" id="contentMeta">0 items</span>
</div>
<!-- Active filter chips (populated by renderFilterChips) -->
<div id="filterChips" class="filter-chips is-hidden"></div>
<!-- Entry grid -->
<div id="entryGrid" class="entry-grid"></div>
@@ -456,6 +477,19 @@
<option value="90">90 days</option>
</select>
</div>
<div class="setting-row">
<span>
Flag aged passwords
<small class="setting-hint">Show an "Aged" badge on entries whose password hasn't changed in this many days.</small>
</span>
<select id="settingPasswordExpiry">
<option value="0">Off</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
<option value="730">2 years</option>
</select>
</div>
<div class="setting-row">
<span>
Check passwords against breach database (HIBP)
@@ -545,9 +579,58 @@
choose a password independent of your master password —
save it carefully, you need it to restore.
</p>
<button class="btn btn-ghost btn-sm" id="exportBtn">
<svg><use href="#i-log-out"/></svg> Export vault
</button>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" id="exportBtn">
<svg><use href="#i-log-out"/></svg> Encrypted JSON
</button>
<button class="btn btn-ghost btn-sm is-danger" id="exportCsvBtn"
title="Plaintext! Use only for migration">
<svg><use href="#i-alert"/></svg> Plaintext CSV
</button>
</div>
</div>
<div class="slideover-field" id="autoBackupField" style="display:none">
<div class="slideover-field-label">Auto-backup</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Encrypted JSON dropped silently into a folder of your
choice, on a schedule. Old backups beyond the retention
count are pruned automatically.
</p>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="checkbox" id="settingAutoBackupEnabled">
<span>Enable auto-backup</span>
</label>
<div id="autoBackupConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:8px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Folder</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<code id="autoBackupDir" style="font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--bg-2);padding:4px 6px;border-radius:4px">(not set)</code>
<button class="btn btn-ghost btn-sm" id="autoBackupPickDirBtn">Choose…</button>
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
<label style="display:flex;flex-direction:column;gap:2px">
<span style="font-size:11px;color:var(--text-faint)">Every (days)</span>
<input type="number" id="settingAutoBackupInterval" min="1" max="365" value="7" style="width:80px">
</label>
<label style="display:flex;flex-direction:column;gap:2px">
<span style="font-size:11px;color:var(--text-faint)">Keep last N</span>
<input type="number" id="settingAutoBackupKeep" min="1" max="100" value="10" style="width:80px">
</label>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
Backups are encrypted with a password derived from your
master password (so restore needs the master at the
time of backup). Filename pattern: <code>vault-autobackup-YYYYMMDD-HHmmss.json</code>.
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<button class="btn btn-ghost btn-sm" id="autoBackupNowBtn">
<svg><use href="#i-log-out"/></svg> Backup now
</button>
<span id="autoBackupLast" style="font-size:11px;color:var(--text-faint)"></span>
</div>
</div>
</div>
<div class="slideover-field">