feat: MFA tools, single-instance, tray polish, prefs persistence

Session highlights:

- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
  rememberedUsername being lost across reboots due to the random
  ephemeral HTTP port changing the localStorage origin every launch.
  Bridge cmd://prefs/{get,set} round-trips through Delphi.

- feat(tray): icon visible from startup (NIM_ADD at constructor, not
  at first minimize). Tray context menu themed via uxtheme!135
  SetPreferredAppMode so it follows the app's dark/light setting.

- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
  Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
  running bridge restores the window from tray. Mutex lives in Local\
  namespace so distinct Windows users can still each run one.

- feat(mfa): Authenticator sidebar view (live TOTP codes for every
  entry with a secret) + standalone TOTP generator modal (paste
  base32 / otpauth:// URI, or generate a random 20-byte secret).

- feat(sidebar): Folders / Tags / Tools sections collapsible with
  chevron toggle. Badge counts stay visible when collapsed. State
  persisted in settings_json (synced across devices).

- feat(autofill): hotkey when vault is locked now restores the app
  and focuses the master password input instead of no-op'ing
  silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
  vault was locked" path.

- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
  sleep. Rationale: the DPAPI blob already gates access via the
  Windows account, so re-locking on top of the OS lock is redundant.
  Idle auto-lock still fires (separate opt-in).

- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
  source-of-truth at boot, instead of trusting (now-volatile)
  localStorage.

- docs: CLAUDE.md updated with all new modules, bridge commands,
  and the port-ephemeral pitfall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:31:39 +01:00
parent 664db65437
commit 40b3154a34
38 changed files with 8165 additions and 548 deletions
+178 -13
View File
@@ -38,7 +38,11 @@
<symbol id="i-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
<symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.4" fill="currentColor"/><circle cx="12" cy="12" r="1.4" fill="currentColor"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></symbol>
<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-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
@@ -99,7 +103,16 @@
</label>
<label class="field">
<span>Master password</span>
<input id="loginPassword" type="password" required>
<div class="input-with-action">
<input id="loginPassword" type="password" required>
<button type="button" class="icon-btn" id="loginPwToggle" title="Show/hide password">
<svg><use href="#i-eye"/></svg>
</button>
</div>
</label>
<label class="remember-row">
<input type="checkbox" id="loginRememberUser">
<span>Remember username on this device</span>
</label>
<button id="loginBtn" type="submit" class="btn btn-primary btn-block">
<svg><use href="#i-unlock"/></svg>
@@ -157,28 +170,51 @@
</button>
</nav>
<div class="sidebar-section">
<div class="sidebar-section" data-section="folders">
<div class="sidebar-section-header">
<span>Folders</span>
<button class="section-toggle" data-section-toggle="folders">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Folders</span>
<span class="section-count" id="countFolders">0</span>
</button>
<button class="icon-btn icon-btn-sm" id="addFolderBtn" title="New folder">
<svg><use href="#i-plus"/></svg>
</button>
</div>
<nav class="sidebar-nav" id="foldersList"></nav>
<nav class="sidebar-nav section-body" id="foldersList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tags</span></div>
<nav class="sidebar-nav" id="tagsList"></nav>
<div class="sidebar-section" data-section="tags">
<div class="sidebar-section-header">
<button class="section-toggle" data-section-toggle="tags">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Tags</span>
<span class="section-count" id="countTags">0</span>
</button>
</div>
<nav class="sidebar-nav section-body" id="tagsList"></nav>
</div>
<div class="sidebar-section">
<div class="sidebar-section-header"><span>Tools</span></div>
<nav class="sidebar-nav">
<div class="sidebar-section" data-section="tools">
<div class="sidebar-section-header">
<button class="section-toggle" data-section-toggle="tools">
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
<span>Tools</span>
</button>
</div>
<nav class="sidebar-nav section-body">
<button class="nav-item" id="sidebarGenBtn">
<svg><use href="#i-dice"/></svg>
<span>Generator</span>
</button>
<button class="nav-item" id="sidebarAuthenticatorBtn">
<svg><use href="#i-shield"/></svg>
<span>Authenticator</span>
</button>
<button class="nav-item" id="sidebarTotpToolBtn">
<svg><use href="#i-key"/></svg>
<span>TOTP generator</span>
</button>
<button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg>
<span>Import vault</span>
@@ -219,6 +255,9 @@
<button class="view-btn" id="viewListBtn" data-view="list" title="List view">
<svg><use href="#i-list"/></svg>
</button>
<button class="view-btn" id="viewTableBtn" data-view="table" title="Table view">
<svg><use href="#i-table"/></svg>
</button>
</div>
<button class="icon-btn" id="themeBtn" title="Toggle theme">
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
@@ -233,6 +272,9 @@
<span id="userName">user</span>
</button>
<div class="user-dropdown is-hidden" id="userDropdown">
<button class="dropdown-item" id="dropdownSettingsBtn">
<svg><use href="#i-settings"/></svg> Settings
</button>
<button class="dropdown-item" id="lockBtn">
<svg><use href="#i-lock"/></svg> Lock vault
</button>
@@ -287,6 +329,17 @@
<option value="light">Light</option>
</select>
</div>
<div class="setting-row">
<span>Sort entries by</span>
<select id="settingSort">
<option value="name:asc">Name (A → Z)</option>
<option value="name:desc">Name (Z → A)</option>
<option value="updated:desc">Most recent first</option>
<option value="updated:asc">Oldest update first</option>
<option value="created:desc">Recently created</option>
<option value="created:asc">Oldest created</option>
</select>
</div>
<div class="setting-row">
<span>Compact action menu (⋯)</span>
<label class="toggle">
@@ -301,6 +354,16 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span>
Show site / URL
<small class="setting-hint">On cards: shown under the display name when the entry has a custom title. In table view: adds a Site column.</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingShowSite">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="slideover-field">
@@ -333,6 +396,39 @@
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutofillRow">
<span>
Autofill with global hotkey
<small class="setting-hint">
Press your hotkey in any browser to fill the matching
login. The vault must be unlocked.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutofill">
<span class="toggle-slider"></span>
</label>
</div>
<div id="settingAutofillHotkeysRow" style="margin-top:8px">
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
Full fill (username + Tab + password)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillFullCombo">Ctrl+Shift+L</button>
</div>
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
Password only (for step-2 forms / unlock screens)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
</p>
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
Reset to defaults
</button>
</div>
</div>
<div class="slideover-field">
@@ -451,7 +547,7 @@
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>Change master password</h3>
<h3 id="changeMasterTitle">Change master password</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="changeMasterForm" class="modal-body" autocomplete="off">
@@ -497,8 +593,12 @@
<form id="entryForm" class="modal-body" autocomplete="off">
<input type="hidden" id="entryId">
<label class="field">
<span>Website / App</span>
<input id="entrySite" type="text" required placeholder="example.com">
<span>Display name <small style="color:var(--text-faint);font-weight:400">— shown on cards, also used for autofill matching</small></span>
<input id="entryTitle" type="text" placeholder="GitHub (work account)">
</label>
<label class="field">
<span>Website / App <small style="color:var(--text-faint);font-weight:400">— hostname or brand, used for autofill matching</small></span>
<input id="entrySite" type="text" required placeholder="github.com">
</label>
<label class="field">
<span>Username / Email</span>
@@ -565,6 +665,49 @@
</div>
</div>
<!-- ============================================================ -->
<!-- MODAL: Standalone TOTP generator (paste secret → live code) -->
<!-- ============================================================ -->
<div id="totpToolModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3>TOTP generator</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body">
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
Paste a base32 secret or an <code>otpauth://</code> URI to
generate a code on the fly. Nothing is saved.
</p>
<label class="field">
<span>Secret</span>
<input id="totpToolSecret" type="text" autocomplete="off"
placeholder="JBSWY3DPEHPK3PXP or otpauth://totp/...">
<div style="display:flex;gap:6px;margin-top:6px">
<button type="button" class="btn btn-ghost btn-sm" id="totpToolGen" style="flex:1">
<svg><use href="#i-dice"/></svg> Generate
</button>
<button type="button" class="btn btn-ghost btn-sm" id="totpToolCopySecret" style="flex:1">
<svg><use href="#i-copy"/></svg> Copy secret
</button>
</div>
</label>
<div class="totp-panel" style="margin-top:12px">
<div class="totp-code" id="totpToolCode">— — — — — —</div>
<button class="icon-btn icon-btn-sm" id="totpToolCopy" title="Copy code">
<svg><use href="#i-copy"/></svg>
</button>
</div>
<div class="totp-bar-wrap"><div class="totp-bar" id="totpToolBar"></div></div>
<p id="totpToolError" style="margin:8px 0 0;color:#dc2626;font-size:12px;display:none"></p>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Close</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- CONFIRM / PROMPT MODAL (replaces native confirm/prompt) -->
<!-- ============================================================ -->
@@ -592,6 +735,28 @@
</div>
</div>
<!-- ============================================================ -->
<!-- AUTOFILL PICKER — shown when multiple entries match hotkey -->
<!-- ============================================================ -->
<div id="autofillPickerModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3 id="autofillPickerTitle">Choose an entry to autofill</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body" style="padding:8px 0">
<p style="margin:0 0 8px 16px;font-size:12px;color:var(--text-dim)">
Click an entry to type its credentials into the active window.
</p>
<div id="autofillPickerList"></div>
</div>
<footer class="modal-footer">
<button class="btn btn-ghost" data-close>Cancel</button>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- COMMAND PALETTE (Cmd+K) — phase 3, shell only for now -->
<!-- ============================================================ -->