openSlideOver defers a focus+select on the title (setTimeout 80ms), which
landed after openEntryForFix's own focus and yanked the caret out of the
password field a moment after Fix was clicked.
Pass the existing { noFocus: true } option — added for the post-save re-open,
exactly the same need: the caller owns the focus.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
G1 (cancelled switch must not act on the still-open entry), G2 (Fix on an
entry deleted meanwhile), G3 (dashboard reflects import/sync/delete without
a manual Recompute).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
healthCache was only dropped on lock, on save and by the Recompute button.
Import, sync, delete, batch ops, duplicate, restore and the master-password
rotation all left it in place, so Vault health kept showing a pre-mutation
score and listed entries that no longer exist.
Invalidate inside loadEntries() — the choke point every mutation path already
goes through — instead of one opt-in per caller. Recompute stays lazy: it only
runs when the dashboard is next rendered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vault health → Fix on entry B while the editor is open and dirty on entry A:
the discard prompt appears, Cancel keeps A open — but openEntryForFix carried
on regardless. It unmasked A's password to cleartext, selected it, and pulsed
the Generate button (the blinking). Wrong entry, and it revealed a password
the user never asked to see.
openSlideOver was void, so callers couldn't tell a cancelled switch from a
successful one. It now returns false when it bails (discard cancelled, entry
not found) and true once the panel actually shows the requested entry;
openEntryForFix checks it before touching the password field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
B14 (native dialogs must not stack a discard prompt) and a new section F
covering the export/import round-trip: favorite + pinned restored, foreign
CSV defaults, sync staying device-local, custom-icon provenance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Import with an open, dirty editor showed "Discard unsaved changes?" stacked
on top of the native file dialog. doImport appends its hidden file input to
<body> and calls .click(); that synthetic click bubbles to the document
handler, which sees a target outside .slideover and fires
requestCloseSlideOver.
Same latent bug on the attachment download web-fallback (an <a download>
appended to <body>, then .click()ed).
Gate the handler on e.isTrusted: only a real user click can mean "clicked
away". Fixes both and any future programmatic click, at the shared choke
point. File inputs living inside the slideover (icon upload, attachment
picker) were already safe via closest('.slideover').
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A pinned entry came back unpinned after export→import. Wider than pinned:
`favorite` was WRITTEN into the export/backup/sync containers but nothing
ever read it back — not parseEntriesFromJSON, not encryptImportEntry, not
the server INSERTs. So it was a write-only field, and a restore silently
dropped both flags. `pinned` wasn't in any container at all.
Wired the full round-trip:
- containers: export + auto-backup now carry `pinned` (favorite was there).
- parseEntriesFromJSON reads both back (absent → 0, e.g. foreign CSV).
- encryptImportEntry passes them through to the body.
- server create + bulk-import INSERTs write favorite/pinned
(GetValue<Boolean>(..., False) → 0 default). PUT still ignores them, so a
sync update can't clobber local flags.
Sync stays device-local as decided 2026-07-11: buildSyncSnapshot now OMITS
favorite explicitly (it was dead weight before; with the passthrough live,
leaving it in would have started propagating it). Commented so the intent
isn't re-broken.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 18fe13a: the custom-icon flag now survives round-trips instead
of resetting to fetched on a restore.
- Containers write icon_custom: manual export (doExport), auto-backup, sync
snapshot; parseEntriesFromJSON carries it back (default 0 when absent, e.g.
foreign CSV).
- encryptImportEntry ships it in the body (shared by bulk-import + sync
create/update).
- Server create + bulk-import INSERTs read icon_custom
(GetValue<Boolean>('icon_custom', False) → 0 default). An empty icon always
forces icon_custom=0.
Now a custom icon stays custom after export→import and across sync — it won't
be silently reclassified as cache and clobbered by Re-fetch-all on the other
device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Auto-fetched favicons and user uploads shared one icon_b64 column, so
"Clear cache" wiped custom uploads and "Re-fetch all" overwrote them — data
loss the user can't undo (a custom icon exists precisely because DDG can't
fetch that site).
New icon_custom flag (0=fetched cache, 1=user upload). Invariant: an
automatic action never touches a custom icon; only the user changes/removes
it per entry.
- Schema: icon_custom INTEGER on vault_entries.
- POST /entries/{id}/icon takes {custom}; GET /entries echoes icon_custom;
DELETE /entries/icons/all filters COALESCE(icon_custom,0)=0.
- saveEntryIcon(id, uri, isCustom): upload→true, fetch/remove→false.
- backfillFavicons skips icon_custom even on force (Re-fetch all).
- clearAllFavicons clears only non-custom locally + refreshes the open editor
(the icon preview didn't update on clear before).
- duplicate re-stamps a copied custom icon; import-overwrite carries the flag.
Not propagated across bulk-import / sync (the bulk INSERT doesn't read it) —
a restored icon counts as fetched. Documented; custom icons are typically
self-hosted sites DDG won't re-fetch anyway.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Section E (E1-E7) covers the fixes that had none: reauth inline retry,
recovery setup + redeem on argon2id-v2, recovery commit-after-save, the
sessionStorage key removal, and a slideover regression guard after deleting
the dead entry modal. Dropped the now-done "legacy entry modal" item from the
remaining list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
openEntryModal had zero callers — the slideover editor replaced it — but the
whole cluster still shipped: the #entryModal markup, saveEntry (wired to the
form submit), closeEntryModal, populateFolderSelect, updateEntryStrength, and
the entire tag-chip subsystem (editingTags, renderChips, addTag, the suggest
dropdown) plus their init wirings and the Esc-chain branch.
saveEntry was a latent hazard: still bound to #entryForm submit, it did a
PARTIAL PUT (no kind / totp_secret / custom_fields / template) — the exact
silent-data-loss bug moveEntryToFolder documents. Unreachable today, but one
stray call away from wiping fields.
Removed JS + HTML + the orphaned .chip-suggestion CSS. The generator is shared
with the slideover — kept; its dead 'entry' target branch collapsed to
slideover-only. 322 lines gone, tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generating a recovery code POSTed /recovery-key/setup — which deletes any
existing recovery row and inserts the new one — BEFORE showing the code. So
cancelling the "save this code" modal still erased the old code and activated
a new one the user never saw: a silent path to lockout.
Generate + wrap locally, show the code, and only POST setup after "I saved
it". Cancel now writes nothing — the account is left exactly as it was, old
code still valid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The redeem client already reads `state.argon2Params = r.argon2 || null`, but
the server never sent `argon2`, so it always fell back to null → the default
params. Correct today (every account uses ARGON2_DEFAULT_PARAMS), but a latent
wrong-key derivation the day per-account params diverge: same class as the
setup verifier bug — an Argon2 account derived under the wrong params.
Echo m/t/p from the users row like /login/challenge does; omitted for
non-Argon2 accounts (m=0). No client change — it already consumes the field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/recovery-key/setup verified the master password with a local copy of the
verifier check that only knew pbkdf2 (legacy) and pbkdf2-sha256. Any account
on a decoupled scheme — pbkdf2-sha256-v2 or argon2id-v2, the current default
after a master-pw rotation — fell through both branches and was rejected as
"invalid password" even when correct, so recovery-key setup was impossible.
Match the canonical VerifierToStoredHash (PM.Handler.Auth): legacy stores the
verifier raw, every other algo is SHA256-wrapped identically. One branch, plus
a keep-in-sync note so the copy doesn't drift again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Setting a PIN, enabling Quick Unlock, and generating a recovery key all
closed the reauth modal and showed a toast on the first wrong master
password — no retry. Export already looped inline; the others didn't.
Extracted that loop into reauthLoop(message): re-prompts with an inline
error + attempt count (5 tries), honours the 429 lockout, returns the
verified password or null. All four sensitive-action flows now share it, so
a mistyped master password keeps the modal up to try again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A memory dump of the WebView2 renderer (vault unlocked) showed the AES vault
key sitting in sessionStorage as a clean base64 string — a plaintext copy of
the master secret, trivially grep-able, present the whole session.
persistCryptoKey/restoreCryptoKey existed only to survive a page reload, but
the shell has no reload path (F5/Ctrl+R blocked, every navigation is a cmd://
bridge call), so the branch that consumed it was unreachable. Removed both
functions, their 7 call sites, and collapsed the init restore branch into the
plain cold-start path (Quick Unlock → PIN → master-pw, all already there).
Also scrub the derived key bytes from the heap (keyBytes.fill(0)) once the
CryptoKey and verifier are built — the CryptoKey keeps its own copy.
Not fixable here, documented: the key stays extractable:true (Quick Unlock,
PIN, recovery and master-pw change all exportKey it), and decrypted password
strings still linger in the V8/Blink heap after lock — immutable strings the
GC never zeroes, inherent to a JS/WebView2 password manager.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor re-ships the payload it loaded on open, so a sync or import that
rewrote the same entry meanwhile was silently clobbered by Save — the last
hole editorAbsorbChange doesn't cover, since those paths rewrite every field
including the ciphertext.
Save now compares the live entry against the baseline soState already keeps
and asks before overwriting. Ciphertext comparison, so no decryption; changes
the editor absorbed read as equal (editorAbsorbChange updates `original`
too), so local tag/folder actions never trip it; the post-save reload
re-baselines, so a second save is clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A10b (editor icon preview), B11 (Esc closes the top modal, not the editor,
across all 11 modals), B12 (a static modal survives its Esc), B13 (toast /
busy-overlay clicks).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A10: the editor's icon preview is painted by a closure nothing outside can
reach, so a favicon fetched from Settings (or an import overwrite) updated
the cards and left the open editor showing the old initials. soIconField
publishes its painter; saveEntryIcon — the choke point for every icon write
— routes through editorAbsorbChange like tag/folder already do.
Also whitelists .toast and #busyOverlay in the editor's click-outside: both
can sit under the cursor during a long operation, and clicking one counted
as clicking away.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three static modals had no entry in the fallback Esc handler, so Esc over
them fell through and closed the editor behind (or did nothing when no
editor was open — reauth and the autofill picker could only be dismissed by
mouse).
Also scopes the stray-modal catch added in a95becd to [data-dynamic]. As
written it matched ANY open .modal, so an Esc over reauth / quick-search /
the autofill picker would have removed those static nodes from the DOM for
good, breaking the feature until restart. Only the runtime-built folder
modal carries the marker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The folder modal is built dynamically with no id in the fallback Esc
handler's modal list, so Esc fell straight through to the slideover close
and shut the editor behind it. The capture handler already bails on any
open .modal; mirror that in the fallback by closing a stray .modal before
reaching the slideover branch. Covers every dynamically-built modal, not
just this one.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting an entry inserted the batch bar as a block above the grid, pushing
every row down ~60px — the list jumped under the cursor mid-click. The bar
now renders inside .content-header, to the right of "All items", and the
header reserves its height (min-height) so appearing and disappearing shifts
nothing at all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers the four classes found this session (stale editor payload, editor
dismissed without the discard gate, missing withEncryptedMeta, view-blind
UI) with the surrounding cases, not just the ones reported.
Also fixes one more instance found while auditing: deleting a folder
reassigns its entries to All server-side, but an editor open on one of them
kept pointing at the deleted folder and re-created it on Save.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tagging or moving an entry from the batch bar / kebab / drag while that same
entry sat open in the editor was lost on the next Save: the editor still held
the payload it loaded before the change and re-shipped it wholesale.
editorAbsorbChange(id, {tag|folder}) mirrors the change into soState AND
soState.original (so the dirty check doesn't fire on an edit the user didn't
type), called from all five write paths: batchAddTag, batchMoveToFolder,
addTagToEntry, moveEntryToFolder, moveEntriesToFolder.
Two bugs found next door:
- moveEntriesToFolder (multi-drag) PUT without withEncryptedMeta. The
cleartext columns are gone server-side, so it shipped fields the server
can't store and blanked title/site/username/tags at rest.
- The kebab menu offered Pin/Edit/Duplicate/Move to trash on TRASHED rows —
the table view reaches it in trash, where only Restore / Delete forever
make sense.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The click-outside handler whitelists chrome (topbar, sidebar, pagination…)
but not .batch-bar, so Delete / Clear / Move to folder / Add tag counted as
an outside click and ran requestCloseSlideOver before their own handler.
Whitelist it, plus .content-header which carries buttons of its own
(Empty trash). Those actions now decide for themselves what happens to the
editor via closeEditorForDeleted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch Delete had the bug the single ✕ just lost: it never touched the open
editor, so an entry could be trashed while its editor stayed open on it,
unsaved edits and all. Same hole in batch purge, permanent delete and empty
trash.
closeEditorForDeleted(ids, alreadyConfirmed) now owns the rule for all five
call sites: close the editor only when it holds one of the deleted ids, and
show the discard prompt only when no delete modal was shown (one modal per
action; cancelling it deletes nothing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With askBeforeDelete off, clicking ✕ on the entry being edited trashed it
and closed the editor with no prompt whatsoever — a misclick cost the edits
silently. That path now runs the discard gate (no-op unless dirty). When
askBeforeDelete is on, its own confirm still stands alone: one modal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking the ✕ on any row called closeSlideOver() unconditionally, so an
open editor with unsaved edits on a DIFFERENT entry vanished without the
discard prompt. Close it only when it holds the entry being trashed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The note textarea showed its plaintext to anyone glancing at the screen,
unlike every other secret in the editor. An eye button on the NOTE label
masks it with -webkit-text-security (a textarea has no type="password").
The button IS the setting: it flips state.maskNotes, which is synced via
settings_json, so the choice sticks across entries and devices — no extra
Settings row for a one-bit preference.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.entry-meta and the first order-6 action both had margin-left:auto, so the
free space was SPLIT between them — the chips landed at a different x on
every row depending on how wide the rest of the row was. .entry-meta is
always present (empty when the entry has no chips), so its auto alone puts
the actions at the far edge and right-aligns the chips against them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The note placeholder was still flex:1, so note rows' copy button sat at the
far edge while password rows' sat at 110px. Same fixed track for both, full
label on hover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The list-view rule set `width: 110px`, but the base .entry-pw is `flex: 1`
(basis 0), so width was ignored and the pill stretched the full row width —
dragging the copy/open buttons to the far edge. `flex: 0 0 110px` pins it,
and the actions land right after it.
Names that ellipsise in the fixed-width title column (list) and the name
cell (table) now carry a title attr, so the full name shows on hover.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A sticky box is held so its *margin* box respects `top`, so the -24px
margin-top used to cancel .content's padding pushed the header's border box
24px down from the scrollport edge — rows scrolled through the strip above
it. Move the top padding off .content and onto .content-header instead: same
spacing at rest, opaque box flush with the scrollport when stuck.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"All items · N items" scrolled away like the pager did. It now sticks at
the scrollport top (padding-top/negative margin cancel .content's padding
so nothing shows through the gap), and the three things below it offset
by --hdr-h: batch bar, pagination, and the table header (--hdr-h + --pag-h).
Both vars are measured on .content in renderEntries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The page buttons sat at the top of the grid and scrolled away, so paging
through a long list meant scrolling back up. Sticky at the scrollport top
with an opaque background; the table header now sticks below it via
--pag-h (measured after append, so a wrapped pager still lines up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The gear hugged the left edge of the (width:100%) actions column because the
header th, unlike the body cells, has no col-actions class / text-align:right.
Set textAlign right on the actions header cell so the picker sits in the
top-right corner.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The show/hide-columns gear only makes sense in table view but sat in the
global toolbar (needing applyColsWrapVisibility to hide it elsewhere). Build
it inside the actions-column header cell in renderTable instead, with inline
handlers (recreated each render, so no dependence on once-wired listeners on
a static node). Removed #colsWrap from the toolbar + the now-obsolete
applyColsWrapVisibility and its callers; kept the menu id so
renderTableColsMenu still populates it, and a live-queried outside-click close.
Added .cols-wrap CSS (was an inline position:relative on the old node).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Part A — Ctrl+Shift+A (new entry from window title) is now editable like the
other 3 global hotkeys, following the same pattern end to end: state.newEntryHotkey
(synced), a capture button in Settings > Autofill, dup cross-check, reset-to-
defaults, autofillPushHotkeys, and Bridge.setAutofillHotkeys carrying ne_mods/
ne_vk. Delphi gets SetNewEntryHotkey + stores the live combo (FNewEntryHotkeyMods/
Vk) so ExecuteAutofill's post-fill re-register uses the user's combo instead of
resetting to Ctrl+Shift+A. UMainForm parses ne_* unconditionally (works with
autofill off).
Part B — new-entry from the tray pre-filled the title with "Password Manager":
GetForegroundWindow resolved to our own hidden window. Only capture the title
when the foreground window belongs to another process; otherwise pass '' (blank).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ctrl+Shift+P (password autofill) with Ctrl+Shift physically held triggered
new-entry: the clear-field step injects a real Ctrl+A, and the held modifiers
(re-pressed by keyboard auto-repeat faster than ForceReleaseModifiers clears
them) make the system see Ctrl+Shift+A = our new-entry global hotkey. Injected
keys are only Ctrl+A + Tab, and only 'A' matches a hotkey, so unregister
NEW_ENTRY around the SendInput (try/finally re-register) — no WM_HOTKEY is
generated. Password/username go as VK_PACKET unicode, can't chord.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
viewMode is a synced setting. On launch localStorage is wiped (ephemeral port
= new origin) so state.viewMode defaults to 'cards' and applyViewMode()
highlights the cards button. loadServerSettings then set state.viewMode='table'
and re-rendered the content (table) but never re-synced the .view-btn
highlight or the column-picker — so table content showed under a cards-active
toolbar. Re-apply both in the visual-settings block, alongside theme/sidebar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6c-1 removed the client caller, but the server handler stayed armed: a stray
authed+CSRF'd call could still re-encrypt only 1 blob of 8 (data loss) or
rewrite a -v2 account's hash_algo (permanent lockout, 6c-4). Gutted the ~165
destructive lines and replaced the body with an auth+CSRF gate then HTTP 410.
Both risks are now closed server-side, not merely unreachable by convention.
The vestigial kdfMigration login signal is left (client ignores it, endpoint
410s) — removing it would cascade through a login-path param for no benefit.
Fixed the now-false comments pointing at migrate-kdf.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SQLite defaults foreign_keys OFF per connection, and the schema relies on
ON DELETE CASCADE (entry_attachments, entries_password_history, recovery_keys).
FireDAC is said to default it On but that's undocumented here — set
ForeignKeys=On so a permanent delete never orphans encrypted blobs. Correct
in both cases (no-op if already On). Base check before fix: 0 orphans.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RequireCSRF looked up csrf_token by user_id ORDER BY created_at DESC LIMIT 1
— the newest session, not the one whose Bearer token was presented. Two
consequences: created_at's 1-second resolution made two same-second sessions
a coin-flip (phantom 403s), and conceptually it accepted "any valid CSRF for
this user" rather than the one bound to this session. Now queried by
token_hash = SHA256(bearer) AND user_id. Authenticate already located this
row + checked expiry, so the expiry clause is dropped. Signature unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CreateSession wrote FormatDateTime(IncHour(Now,24)) = local, but RequireCSRF
and CleanupExpired compare against SQLite datetime('now') = UTC. On negative
UTC offsets the CSRF window fell short of the 24h auth window: between the two,
GETs authed fine but every write got 403 "Invalid CSRF token". Now
IncHour(NowUTC,24) on write + LExpires < NowUTC in Authenticate, so all three
readers are UTC-consistent. Last FormatDateTime(...,Now) in the backend —
closes §2.2 for real. NowUTC + DateUtils already in uses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/migrate-kdf changed the AES key (100k->600k) but only re-encrypted
encrypted_password, leaving totp_secret/custom_fields/*_enc + attachments
under the old key = permanent data loss. Both live accounts are already at
600k so it never fired, but the path was armed and auto-triggered by the
server's login signal.
Removed the two triggers (doLogin + unlock) and the now-dead runKdfMigration.
Legacy <600k accounts upgrade safely via Change Master Password instead
(re-encrypts everything + moves to Argon2id). The Delphi endpoint is left
inert: without the client orchestrating the key swap it can't corrupt
anything, so no untested server change. Closes 6c-4 too (unreachable now).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
List view faked a table with flex+order, but the title used flex:1 so it
absorbed all free space — and since trailing content (tags/folder/actions)
varies per row, the fixed-width pw pill landed at a different x on each row.
Give the title a fixed 240px track so pw starts at the same x everywhere;
move the free-space absorption to margin-left:auto on the trailing group
(entry-meta + order-6 actions) so they still float to the far right. Copy +
globe stay in .entry-pw-row (order 4), pinned next to the pw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Swept every user-facing surface (index.html, all js strings/toasts/dialogs,
Delphi tray/balloon/dialog text) — no French in the UI. The remaining French
is project docs (CLAUDE.md/CODE_AUDIT.md), which is the doc language, not a
mix to fix. i18n stays a future feature, not a bug.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>