Commit Graph

73 Commits

Author SHA1 Message Date
r-zakarya 3d217e82d4 fix(quick-search): Esc closes only the modal; cancel returns focus to target
- The qsInput Esc handler didn't stopPropagation, so the SAME keystroke fell
  through to the document-level Esc handlers which, seeing the modal now
  closed, also closed (or discard-prompted) the dirty slideover behind it.
- autofill/cancel now hands the foreground back to the saved target HWND:
  Esc after a fill-mode hotkey returns the user to the window they came
  from instead of leaving our app focused. Locked-vault path still ends
  focused on us (cancelAutofill runs before focusApp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:20:24 +01:00
r-zakarya 770504ea03 fix(quick-search): only tray-hidden windows go back to the tray on close
wasHidden counted IsIconic too, so a window merely minimised to the taskbar
("visible" to the user) was treated as tray-origin: Esc/pick sent it to the
TRAY, vanishing from the taskbar it came from. wasHidden is now strictly
"came from the tray" (Visible=false); an iconic window gets restored by the
hotkey and simply stays open when the modal closes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:12:41 +01:00
r-zakarya 3822c21194 fix(clipboard): auto-clear must not wipe content copied since our SetText
Copy a password (30s auto-clear armed), then copy something else from
another app before the timer fires: the tick emptied the clipboard anyway,
destroying the user's newer content. Guard with the Win32 clipboard sequence
number: SetText snapshots GetClipboardSequenceNumber, ClearIfOurs only
empties when it hasn't moved. Applied to the auto-clear timer AND the
clear-on-minimize path (same bug class); the explicit JS clipboard/clear
command stays unconditional (user-initiated).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:20:49 +01:00
r-zakarya 72dcc3dd82 fix(autofill): kill residual hotkey chord; dedicated fail-balloon setting
Residual new-entry trigger (1 in 6): password chars go out as
KEYEVENTF_UNICODE (VK_PACKET, can't match a hotkey) — the real chord risk is
the Ctrl+A clear-field, which sends a real VK_A. If the user re-presses
Ctrl+Shift mid-sequence, that VK_A becomes physical Ctrl+Shift+A = our own
new-entry hotkey. ForceReleaseModifiers now runs inside
SendSelectAllAndDelete, at the risky instant, not just once up front.

Balloon: was gated by "Show tray notifications" (OFF for this user) — now
gated by its own synced setting "Tray alert when autofill is blocked"
(autofillFailBalloon, Settings > Autofill, default ON), carried as notify=0
on cmd://autofill/execute. ShowBalloon no longer gates internally; each
caller applies its own setting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:23:02 +01:00
r-zakarya 9a72fc0424 fix(build): ShowBalloon must be public (called from UMainForm)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:12:23 +01:00
r-zakarya aab0b14be4 fix(autofill): force-release stuck modifiers; balloon when blocked from tray
Root cause of "Ctrl+Shift+P opened the new-entry modal": if the user still
holds Ctrl+Shift when WaitForModifierRelease times out (1s), every password
letter is typed as a Ctrl+Shift+<letter> chord — garbage in the field AND it
fires our own global hotkeys (a password containing 'a' triggers Ctrl+Shift+A
= new entry). ForceReleaseModifiers now injects KEYUP for any still-held
modifier before typing.

Also: when the fill is blocked (elevated target) while the window is hidden
in the tray, the in-app toast is invisible — show a tray balloon instead.
ShowFirstTimeBalloon generalized into ShowBalloon(title, text, warning),
gated by the existing "Show tray notifications" setting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 05:08:50 +01:00
r-zakarya b869effee5 feat(autofill): "Clear the field before typing" setting (default ON)
The Ctrl+A + Del sent before each field misbehaves on targets where Ctrl+A
isn't select-all (terminals, some remote desktops). New synced setting
(autofillClearField, Settings > Autofill) gates it: JS appends clear=0 to
cmd://autofill/execute when off; ExecuteAutofill wraps the three
SendSelectAllAndDelete calls behind AClearFirst. Absent param = ON, so
existing behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:52:35 +01:00
r-zakarya da1284cfc3 fix(build): declare PROCESS_QUERY_LIMITED_INFORMATION locally
Winapi.Windows doesn't expose the WinNT.h constant — dcc32 E2003.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 04:41:38 +01:00
r-zakarya 4a47caad55 fix(autofill): honest result reporting + restore maximized from tray
Bug 1: a maximized window trayed via the quick-search fill flow came back
"normal" on the next restore. ExecuteAutofill minimizes the window BEFORE
MinimizeToTray snapshots the placement, so the snapshot said SHOWMINIMIZED
and the never-restore-minimized guard forced SHOWNORMAL. Now honours
WPF_RESTORETOMAXIMIZED (Windows keeps the pre-minimize state in flags).

Bug 2: filling into an elevated app (admin Notepad) showed "password sent"
while UIPI silently discarded the keystrokes (SendInput even reports
success). ExecuteAutofill is now a function: it checks the target process
elevation up front (can't-open counts as elevated) and returns False without
typing. UMainForm feeds the result to JS via Bridge.onAutofillResult; the
quick-search success toast is deferred until Delphi confirms, and a failure
shows "Autofill blocked - the target window runs as administrator".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:19:34 +01:00
r-zakarya 7103fbf703 fix(quick-search): keep the open window visible during the fill (no minimize)
The minimize-then-SW_SHOWNOACTIVATE approach flickered and sometimes lost the
restore race (window stayed minimized). Root fix: when the window was open
before the hotkey (ARestoreAfter), skip the minimize entirely — being the
foreground process is exactly what allows handing focus to the target, so the
window simply stays in place beside it. Tray-origin flow keeps the old
minimize (the window is a temporary overlay, trayed after the fill anyway).

Safety: if the target refuses the foreground (elevated / UIPI) and our window
is still foreground, bail before typing — otherwise the password would be
typed into the vault's own visible UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:01:05 +01:00
r-zakarya 3b32a621e8 fix(quick-search): stop teleporting + hiding an already-open window
Two bugs when Ctrl+Shift+Q fires while the app window is open beside the
target app:
- RestoreFromTray re-applied FSavedPlacement (captured at the LAST
  MinimizeToTray) to an already-visible window -> it jumped to a stale
  position. Now: visible and not iconic -> just SetForegroundWindow.
- ExecuteAutofill minimizes our window when it is foreground (the user just
  clicked the entry) and never brought it back when hide_after was false.
  New ARestoreAfter param (= not HideAfter): restore with SW_SHOWNOACTIVATE
  after the fill, so the window returns to its position without stealing
  focus from the freshly-filled target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:52:26 +01:00
r-zakarya 01426a48b1 refactor(db): drop cleartext template refs, fix ghost columns + fresh-DB schema
Migration verified (0 unmigrated cleartext templates across both accounts)
before contracting:
- GET/POST/PUT/bulk no longer read or write the cleartext template column
  (only template_enc/iv); the PUT partial-update gate stays keyed on the
  'template' JSON key presence.
- Removed AddColumnIfMissing for template AND tags/title — those two had
  silently re-added the dropped columns as empty ghosts at every start.
- CREATE TABLE: removed site/username NOT NULL cleartext columns — a FRESH
  database rejected the very first INSERT (which no longer ships them).

User can now DROP COLUMN template (and re-drop the ghost tags/title).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:52:26 +01:00
r-zakarya d583a3f7d3 refactor(js): extract attachments, autofill, unlock modules from app.js (3.1)
app.js 9363 -> 7962 lines. Three new classic-script modules:
- app.attachments.js (250): blob crypto + upload/download UI, pure
  declarations, loads before app.js
- app.autofill.js (276): Win32 combos, title->entry matching, picker,
  pure declarations, loads before app.js
- app.unlock.js (907): Quick Unlock + PIN + recovery code grouped (same
  "enter without master pw" theme); assigns Bridge.onPinResult /
  onQuickUnlockResult at top level so it loads AFTER app.js, like app.sync.js

Audit viewer stays in app.js (only 65 lines, not worth a file). Clipboard
bridge helpers stay too (were interleaved in the quick-unlock section but
unrelated). Registered in BuildAssets whitelist + index.html + APP_PARTS.
Verified in-app: quick unlock cold-start, attachment upload/download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:28:19 +01:00
r-zakarya 982b7c082c chore: stop tracking generated embedded-asset artifacts
assets.res/.rc/.inc are regenerated by BuildAssets before every Delphi build
and linked into the exe — tracking them just churned a binary each session.
Untrack + gitignore (matches the intent already stated in CLAUDE.md). A fresh
clone runs BuildAssets first anyway (documented build step 1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:35:28 +01:00
r-zakarya a42e4b205d feat(entries): encrypt template at rest, guided tour, import fixes, cleanup
Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:37 +01:00
r-zakarya b9eee0e15e perf(build): skip redundant per-file node --check when the test gate runs
The unit suite concatenates + parses every embedded app.*.js (plus argon2)
in a vm, so a syntax error already fails it. Run the ~10 cold `node --check`
spawns (~3-4s) only when tests are bypassed (PM_SKIP_TESTS) or absent.
Cuts the pre-compile freeze from ~7s to ~4s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:15 +01:00
r-zakarya 0925afb689 perf(autofill): keep WebView2 renderer responsive while in tray
Add --disable-renderer-backgrounding / -backgrounding-occluded-windows /
-background-timer-throttling to the WebView2 args. Without them Chromium
freezes a hidden renderer, so the first autofill hotkey after a tray-only
start (Start with Windows) waited 3-5s for onAutofillRequest to run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:14 +01:00
r-zakarya 45ba47f772 feat(settings): keep backup pwd on disable, dedicated pwd button, tab icons
Auto-backup no longer wipes the stored password when disabled, so
re-enabling reuses it silently. A dedicated "Set/Change backup password"
button (mirrors sync) owns the password, with a warning status when
unset. Corrected the stale hint that claimed the backup pwd was derived
from the master password. Added icons to each settings tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:53:45 +01:00
r-zakarya d333ce00ec build: regenerate embedded assets (settings tabs + sync/settings fixes) 2026-07-10 04:51:14 +01:00
r-zakarya b023eab1f4 fix(http): return 401 (not 500) on expired/rejected session
Authenticate/RequireCSRF write a 401 then raise ESessionRejected; when it
reached the dispatcher catch-all, the generic `on E: Exception` overwrote it
with a 500. Added `on ESessionRejected do Exit` before the generic clause in
both dispatchers (GET + Other) — one place, covers every handler whether or
not it wraps Authenticate. Root cause, not per-handler patch.

ponytail: runtime check only (expired token → 401) — no Delphi unit harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:42:08 +01:00
r-zakarya a980af1e94 build: regenerate embedded assets
Rebuilt assets.res embedding the current JS (metadata-at-rest encryption,
dropped-column code, slideover focus fix). Runtime-validated: create/edit/
import/reload all OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:38:23 +01:00
r-zakarya 94ebc96d73 refactor(db): drop cleartext title/tags column refs (columns removed)
User dropped the title + tags cleartext columns too — all four searchable
metadata columns are now gone. Removed their refs from GET emission and the
POST/PUT/bulk INSERT/UPDATE (columns + params + binds). Only *_enc columns
remain; the client reads everything via decryptEntryMeta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:29:06 +01:00
r-zakarya bcdd2f44bc refactor(db): drop cleartext site/username column refs (columns removed)
User dropped the now-empty cleartext `site` + `username` columns after the
§1.3 migration completed. Removed every reference so the code matches the
schema: GET emission, POST/PUT/bulk INSERT/UPDATE (columns + params + binds).
title/tags cleartext columns still exist and are untouched.

decryptEntryMeta defaults e[f]='' for rows without *_enc (notes w/o site),
since GET no longer returns the dropped columns.

ponytail: contract phase of expand→migrate→contract; only safe because the
migration is proven complete (0 cleartext, both accounts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:25:35 +01:00
r-zakarya 6556ce8dea feat(crypto): encrypt site/title/tags at rest too (CODE_AUDIT §1.3)
Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.

Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
  cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
  cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.

Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
  AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
  rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
  client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
  frontend never sends ?search=).

Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.

username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:25:13 +01:00
r-zakarya 69fb2b10dd feat(crypto): encrypt username at rest (CODE_AUDIT §1.3)
username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.

Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
  rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
  searches client-side anyway).

Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
  cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
  username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
  body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
  batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
  still carry cleartext username so the DB gets scrubbed (bumps updated_at
  once; plaintext unchanged so devices converge).

site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.

NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:04:48 +01:00
r-zakarya 9e424efaf4 refactor(js): extract quick-search overlay cluster from app.js (§3.1)
Eighth slice. Quick search wasn't contiguous — its functions sat on both
sides of the cheatsheet and password-history modals (lines 725-1104). Rather
than a fiddly non-contiguous cut, the whole overlay cluster is extracted as
one byte-identical block: js/app.overlays.js (quick search + cheatsheet +
password history). Pure declarations, no top-level side effects → loads
before app.js; all state/api/Bridge/render/decryptPwd refs resolve via shared
global scope at call time.

- Byte-for-byte identical; syntax OK on all nine app parts; 62/62 tests green.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.

app.js: 11936 → 9170 lines (8 modules extracted, ~2770 lines). Load order:
argon2 → crypto → totp → favicon → import → backup → health → overlays →
app → sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:43:55 +01:00
r-zakarya b44b05118e refactor(js): extract vault-health module from app.js monofile (§3.1)
Seventh slice of the app.js split. Moves the vault-health dashboard
(computeHealthCache, healthScoreBand, renderHealthDashboard/Section,
openEntryForFix, entryAgeDays + scoring consts) to js/app.health.js. Pure
declarations, no top-level side effects → loads before app.js. Uses
computeStrength/decryptPwd/state/api via shared global scope at call time.

- Byte-for-byte identical extraction; syntax OK on all eight app parts.
- auditCache/auditFilter sit in this var block but drive the separate
  Audit-log viewer in app.js — they ride along and resolve cross-file via
  shared scope (documented).
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- 62/62 tests green.

app.js: 11936 → 9545 lines (7 modules extracted).

NOTE: quick search is NOT contiguous (interleaved with cheatsheet +
history-modal code, lines 886-1063), so a clean byte-identical extraction
isn't trivial — deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:07:42 +01:00
r-zakarya a7ad81c708 refactor(js): extract favicon module + add faviconHost tests (§3.1)
Sixth slice of the app.js split. Moves the favicon fetch/cache section
(faviconHost, saveEntryIcon, ensureEntryFavicon, backfillFavicons,
clearAllFavicons) to js/app.favicon.js. Pure declarations, no top-level
side effects → loads before app.js.

- Code moved byte-for-byte; no duplicate const; syntax OK on all 7 app parts.
- NEW: js/tests/favicon.test.js — 7 tests for faviconHost, the pure
  URL→validated-hostname function that decides which domain is sent to the
  DuckDuckGo proxy (a bug there leaks the wrong host). Covers scheme/www/
  path/port stripping, non-hostname rejection, malformed dotting, unsafe
  chars, and the 253-char DNS cap.
- Fixed an inaccurate source comment surfaced by the tests: it claimed raw
  IPs "stay valid", but the TLD rule /\.[a-z]{2,}$/ rejects a numeric final
  label, so IPs get no favicon lookup (fine). Test pins the real behaviour.
- Suite: 55 → 62 tests, all green. Assets regenerated (8 ordered JS files).

app.js: 11936 → 9790 lines (6 modules extracted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:58:16 +01:00
r-zakarya 4fd768d4cf refactor(js): extract auto-backup module from app.js monofile (§3.1)
Fifth slice of the app.js split. Moves the scheduled encrypted-backup
feature (config, retention, runAutoBackupNow/runAutoBackupIfDue) to
js/app.backup.js. Pure declarations + two consts, no top-level side effects
→ loads before app.js; uses encryptExportPayload (app.import.js), Bridge,
api, state via shared global scope at call time.

- Byte-for-byte identical extraction; no duplicate const; no top-level
  backup reference left in app.js; syntax OK on all six app parts.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
  regenerated (manifest embeds all 7 ordered JS files).
- 55/55 tests green.

app.js: 11936 → 9900 lines — now under 10k. Five modules extracted
(~2000 lines): argon2 → crypto → totp → import → backup → app → sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:46:01 +01:00
r-zakarya 5fc07aed7a refactor(js): extract TOTP module + add RFC 6238 tests (§3.1)
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP,
parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to
js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js
(uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js
via shared global scope.

- Byte-for-byte identical extraction; no duplicate const; syntax OK on all
  five app parts.
- NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B
  reference vectors (generateTOTP reads Date.now(), so each case stubs the
  sandbox clock to the vector's fixed time), base32 decode edge cases, and
  parseOtpAuthUri. Extraction AND new coverage in one slice.
- Suite: 42 → 55 tests, all green.
- Assets regenerated (manifest now embeds all 6 ordered JS files:
  argon2 → crypto → totp → import → app → sync); also fixes the previous
  import commit's not-yet-rebuilt manifest.
- Delphi build artifacts (*.vrc, *.$manifest) gitignored.

app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:35:29 +01:00
r-zakarya 97a19836a0 refactor(js): extract import/export module from app.js monofile (§3.1)
Third slice of the app.js split. Moves the encrypted export container,
CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV,
parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to
js/app.import.js. encryptImportEntry moves here too (also called by
app.sync.js — resolved via shared global scope at call time).

- Byte-for-byte identical to the extracted block; no duplicate const;
  no top-level import ref left in app.js.
- Load order: BEFORE app.js (pure declarations, no top-level side effects),
  alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from
  the extracted file and stay green (42/42).

app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700
lines moved into 3 modules).

NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run
BuildAssets before the next Delphi build to embed js/app.import.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:24:11 +01:00
r-zakarya 2d309f8988 refactor(js): extract sync module from app.js monofile (§3.1)
Second slice of the app.js split (after crypto). Moves the WebDAV sync
section to js/app.sync.js: transport (_webdavCall), buildSyncSnapshot,
applyRemoteSnapshot (merge + tombstone arbitration), runSyncNow, and the
sync settings UI.

- Byte-for-byte identical to the extracted block (verified before removal);
  no duplicate const; no top-level sync reference left in app.js.
- Load order: AFTER app.js (unlike crypto, which loads before) because this
  module has a top-level side effect — `Bridge.onWebdavResult = …` — that
  needs Bridge/state/api already declared. Rule documented in CLAUDE.md.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
  rebuilt to embed the new file.
- Safety net: the existing merge tests exercise applyRemoteSnapshot /
  buildSyncSnapshot from the extracted file and stay green (42/42).

app.js: 11936 → 11256 lines (crypto + sync now separate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:09:02 +01:00
r-zakarya 4ffbd63893 fix(sync): bump updated_at on set-icon + folder-delete reassignment
Both UPDATEs mutated a synced column without touching updated_at, so the
change rode in the sync snapshot but other devices skipped it (last-write-
wins saw "not newer"). Now both SET updated_at = datetime('now') (UTC).

- POST /entries/{id}/icon (PM.Handler.Entries)
- folder delete → entries reassigned to 'All' (PM.Handler.Folders)

accessed_at stays exempt (read timestamp, not synced); bulk "clear all
icons" stays exempt (device-local favicon cache purge). Invariant documented
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:55:23 +01:00
r-zakarya ca8081987d refactor(js): extract crypto module from app.js monofile (§3.1 start)
First slice of the app.js split. Approach: ordered classic-script files
loaded via separate <script> tags (argon2.js → app.crypto.js → app.js),
NOT ES modules / a bundler. Classic scripts share one global lexical
environment, so consts/functions cross-reference across files exactly as
in the monofile — zero call-site rewrites, near-zero risk. Chosen over the
audit's esbuild/ES-module suggestion because the code is written entirely
in global scope (functions call each other by bare name everywhere).

- js/app.crypto.js: KDF (PBKDF2 + Argon2id), verifier, AES-GCM encrypt/
  decrypt, key persist/restore. Verified byte-for-byte identical to the
  original block before removal; no duplicate const across the two scripts.
- index.html + BuildAssets whitelist + test harness updated for the load
  order. Harness CONCATENATES app.crypto.js + app.js (node:vm doesn't share
  top-level const across separate runInContext calls the way browsers share
  it across <script> tags); argon2.js stays a separate IIFE.
- Runtime-validated: rebuilt exe unlocks via quick-unlock and loads/decrypts
  entries — the extracted crypto (restoreCryptoKey, verifierFromKeyHex,
  decryptPwd) works from the separate file. 42/42 tests green.
- Docs: CLAUDE.md "Découpage frontend" (pattern + rules), file map, tests
  README, CODE_AUDIT §3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:44:06 +01:00
r-zakarya 5e88ad33d1 feat(crypto): adopt Argon2id (argon2id-v2) on register + master-pw change
Phase 2 of CODE_AUDIT §1.2 — live adoption of the Argon2id foundation.
Verified at runtime: a rotated account shows hash_algo=argon2id-v2 with
argon2_m=19456,t=2,p=1 in vault.db.

Server (never runs Argon2 — zero-knowledge, only stores/echoes params):
- DB: users.argon2_m/t/p columns (default 0 = PBKDF2).
- PM.Handler.Auth: HASH_ALGO_ARGON2 + param bounds, ReadArgon2Params /
  AppendArgon2Params helpers. /register and /change-master-password accept
  hashAlgo='argon2id-v2' + argon2:{m,t,p} and persist them; /login/challenge
  echoes them. Verify path (VerifierToStoredHash/CheckVerifier) is
  KDF-agnostic — the 64-hex verifier is SHA256-wrapped as for any -v2 scheme.

Client (app.js):
- state.argon2Params, cached from the challenge and persisted to
  sessionStorage + the quick-unlock / PIN cold-start blobs (so a cold-started
  session can still derive-from-password for reauth/rotation).
- Register + master-pw rotation derive with argon2id-v2 + ARGON2_DEFAULT_PARAMS
  (OWASP m=19MiB,t=2,p=1) and send the params. Rotation re-encrypts the whole
  vault under the new Argon2 key (natural migration point). Existing accounts
  stay PBKDF2 until they rotate.
- Params threaded through every derive-from-password site (login, reauth,
  recovery setup, change-pw current verifier). Cold-start verifier-from-raw-key
  paths need no params (isDecoupledVerifierAlgo handles the -v2 wrap).

Tests: +2 param-contract tests (register<->login determinism, param
sensitivity). 42/42. Assets rebuilt to embed js/argon2.js.

Docs: CLAUDE.md auth-hash section rewritten (4 markers); CODE_AUDIT §1.2 +
table + plan marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:52:11 +01:00
r-zakarya 2bd0fcfbf8 feat(crypto): Argon2id KDF foundation (vendored, not yet adopted)
Phase 1 of CODE_AUDIT §1.2 — additive, no live account uses Argon2id yet.

- Vendor @noble/hashes@2.2.0 argon2id as js/argon2.js (esbuild IIFE exposing
  globalThis.NobleArgon2). Pure-JS, not WASM: CSP is script-src 'self' with no
  wasm-unsafe-eval, so WASM would require weakening it. Verified against the
  RFC 9106 §5.3 test vector. Server needs zero Argon2 (zero-knowledge: it only
  ever SHA256-wraps the client verifier).
- app.js: deriveKeyBytes(pwd, salt, algo, iters, argonParams) branches Argon2id
  vs PBKDF2; deriveKeyAndVerifier refactored around it. New markers
  HASH_ALGO_ARGON2='argon2id-v2' + ARGON2_DEFAULT_PARAMS (OWASP m=19MiB,t=2,p=1,
  ~0.65s/unlock). isDecoupledVerifierAlgo() generalises the decoupled-verifier
  rule to any '-v2' scheme so argon2id-v2 inherits it. AES key is still ALWAYS
  the raw KDF output → entries decryptable, legacy accounts untouched.
- index.html loads js/argon2.js before app.js; added to BuildAssets whitelist;
  test harness loads it into the sandbox first.
- Tests: +5 (RFC 9106 vector via vendored bundle, argon2 branch derives Argon2
  key not PBKDF2, decoupled verifier, AES round-trip under Argon2 key). 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:13:36 +01:00
r-zakarya d9397881dc test: add frontend unit suite + fix mixed local/UTC timestamps
Two CODE_AUDIT items in one session.

§3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps):
- harness.js loads app.js (monofile, no exports) into a node:vm with browser
  globals stubbed, surfacing internals via an export epilogue.
- crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node
  pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip,
  IV uniqueness, AEAD tamper/wrong-key.
- csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping.
- merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete,
  resurrection arbitration (both NaN branches), local-tombstone veto,
  additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry
  run for real.
- Wired as a build gate in BuildAssets.ps1 (after node --check, bypass
  PM_SKIP_TESTS=1).

§2.2 — Unify timestamps on UTC:
- Entry created_at/updated_at were written via Delphi FormatDateTime(Now)
  = LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC.
  The tombstone-resurrection arbitration compared the two zones, skewing by
  the machine's UTC offset even single-device.
- Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment
  write site (Entries create/update/bulk, Attachments POST echo).
- No JS change needed: arbitration now compares same-zone values.
- Existing rows self-heal on next edit (no destructive migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:17:33 +01:00
r-zakarya ac909f4f09 feat: sync ETag concurrency + fix chunk-transfer hang + sync overlay + auto-VACUUM
Sync optimistic concurrency (ETag/If-Match)
- webdav GET captures the response ETag; PUT sends it back as If-Match so
  the server rejects (412) our write when another device changed the file
  between our pull and push. A 412 re-runs the whole pull→merge→push
  (bounded to 3) so the other device's changes are folded in instead of
  clobbered. Servers without ETags → empty etag → no If-Match → falls
  back to last-write-wins (no regression). onWebdavResult gained a 4th
  etag arg.

Chunked webdav PUT (big vaults no longer black-screen on sync)
- The whole encrypted snapshot base64'd into a single cmd://webdav/put URL
  blew past WebView2's cap → black screen once the vault grew (20MB of
  attachments). PUT bodies now stream through the file/chunk transport and
  commit via a new webdav/put-commit (reads the accumulated buffer).

Chunk-transfer hang fix (root cause of the stuck "Preparing…" sync)
- All chunked transfers (saveFile/writeFile/webdav PUT) share one
  reqId-keyed resolver. A resolved chunk's stale 30s timeout would later
  delete the CURRENT chunk's resolver and fire the wrong res(), leaving
  that chunk's await pending forever. Extracted a single _streamChunks()
  helper whose ack CLEARS the pending timeout, so resolvers stay strictly
  one-at-a-time. Also fixed _webdavCall referencing the Bridge-local cmd()
  from module scope (latent ReferenceError).

Sync busy overlay
- syncStatus() now drives the global busy overlay too, so a running sync
  blocks stray clicks (e.g. the auto-backup "Choose…" picker) and reads
  like the manual backup. The account-mismatch confirm hideBusy()s first
  so it's visible above the overlay.

Auto-VACUUM (reclaim space after deleting large attachments)
- SQLite never shrinks the file on DELETE, so deleting big attachments
  left vault.db bloated (35MB for 11 tiny entries). DB.CompactIfBloated
  VACUUMs when >20% of pages are free AND >~2MB is reclaimable — called on
  startup and after each attachment delete. A healthy small vault pays
  nothing. (Verified: 35MB → 695KB after the deletes.)

Rebuild: BuildAssets + F9 (UMainForm + PM.Database + PM.Handler.Attachments).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-04 00:02:25 +01:00
r-zakarya b367d031b5 style: define missing --bg-elev-3 / --accent-fg CSS variables
--bg-elev-3 was referenced (spinner ring, search-clear hover) but never
defined in either theme, so var(--bg-elev-3) with no fallback produced an
invalid declaration — that's why the busy spinner ring was invisible
until it was switched to --border. Define it as a real elevation step
above --bg-elev-2 in dark (#34343f) and light (#e3e3dd).

Also define --accent-fg (#fff) explicitly instead of relying on the
inline var(--accent-fg, #fff) fallback.

Audited used-vs-defined custom properties: the only remaining "undefined"
one is --strength, which is set at runtime by JS on the password-strength
bar (has a 0% fallback) — intentional, not a bug.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 18:01:57 +01:00
r-zakarya 48bb06c029 feat: rotation progress spinner + quick-unlock re-wrap on master-pw change
- doChangeMasterPassword shows the busy overlay while it re-encrypts the
  vault: "Re-encrypting vault…" → "Re-encrypting entries… N/total" →
  "Re-encrypting attachments… N/total", cleared in finally. A rotation on
  a big vault took tens of seconds with no feedback before.
- Quick-unlock now SURVIVES a master-pw change instead of being wiped.
  The blob stores the raw key (DPAPI-wrapped, no user secret), so it's
  re-wrapped in place with the new key/salt/iters/algo (state already
  holds the new values at that point). Cold-start then re-logs in with a
  verifier derived from the new key. Falls back to clearing if the
  re-wrap throws, so a stale old-key blob is never left behind.
- PIN blob still cleared (wrapped by PBKDF2(pin) — can't re-wrap without
  the PIN). A setTimeout(0) separates the quickunlock/store and pin/clear
  navigations so the back-to-back window.location.href assignments don't
  coalesce and drop the re-wrap.
- Fixed a `failed` counter declaration accidentally dropped from the
  attachment re-encryption loop while adding progress (ReferenceError at
  runtime; node --check wouldn't catch it).
- CLAUDE.md updated for the re-wrap vs clear distinction.

Rebuild: BuildAssets + F9 (JS only this commit; F9 to re-embed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 17:54:51 +01:00
r-zakarya 22bec64348 fix: chunk auto-backup writes + spinner on manual "Backup now"
Bridge.writeFile had the same URL-length trap saveFile did: a large
auto-backup base64'd into a single cmd://file/write URL blew past
WebView2's ~2MB navigation cap, so backups of big vaults failed
silently (or blanked the page) — for BOTH the manual "Backup now"
button and the silent scheduled run.

- writeFile now streams payloads over ~1MB in chunks (reusing the same
  file/chunk transport as saveFile), committed via a new
  file/write-commit. Delphi shares the decode+write logic through a new
  WriteDecodedFile helper and the existing FFileSaveChunks buffer.
- runAutoBackupNow(silent): the manual run shows the busy overlay
  ("Reading vault… N/total" → "Encrypting backup…" → "Writing file… N%")
  since a 20MB backup takes ~30s; the scheduled on-unlock run passes
  silent=true (no overlay, but still chunked so it no longer fails on
  large vaults).
- Fixed the "Backup now" click handler passing the click Event as the
  silent arg (truthy → would have suppressed the spinner and swallowed
  errors); wrapped in () => runAutoBackupNow().
- Pre-sync backup benefits from the chunked writeFile automatically.

Rebuild: BuildAssets + F9 (UMainForm.pas changed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 17:38:10 +01:00
r-zakarya c5fe26ce94 feat: chunked file save + export progress spinner + password-reveal + avatar size
Large file save (fixes black screen on big attachment download / export)
- Bridge.saveFile streams anything over ~1MB base64 in chunks through the
  cmd:// channel instead of stuffing the whole payload in one URL — a
  multi-MB base64 URL blew past WebView2's ~2MB navigation cap and blanked
  the document (black screen). Small payloads keep the single-shot path.
- Chunks are sent sequentially (each acked via Bridge.onFileChunkAck
  before the next) so repeated location.href assignments don't coalesce.
- Delphi accumulates chunks per reqId in a TStringBuilder
  (FFileSaveChunks), commits on file/save-commit, and shares the
  decode+dialog+write logic with the single-shot path via SaveDecodedFile.
- Chunk size 1MB → far fewer round-trips (a 20MB export dropped from ~67
  to ~27 hops).

Busy overlay + progress
- Global spinner overlay (showBusy/updateBusy/hideBusy). doExport shows it
  immediately on click — BEFORE the entry-decrypt + attachment-fetch loop
  that is the real cost — with a 0ms yield so it paints before the thread
  blocks (was appearing 3-5s late). Phases: "Reading vault… N/total" →
  "Encrypting export…" → "Preparing file… N%" (real chunk progress).
  Attachment download shows the same for files > 512KB.
- Spinner ring used an undefined --bg-elev-3 (invalid border → invisible);
  switched to --border. Fixed a second stale --bg-elev-3 use on the
  settings-search clear button hover.

Password reveal
- promptDialog gets an eye toggle in password mode, so every encrypted
  prompt (export, import, backup password, recovery code, sync password)
  can show/hide the typed value.

Avatar
- Top-right chip avatar enlarged 22px → 30px with the chip padding
  rebalanced.

Rebuild: BuildAssets + F9 (UMainForm.pas changed for the chunk handlers).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 16:56:47 +01:00
r-zakarya 3f8ecde571 feat(security): decouple the login verifier from the AES vault key
The zero-knowledge verifier sent to /login used to be the raw PBKDF2
output in hex — i.e. the exact bytes of the AES key that encrypts every
entry. Intercepting a /login body (loopback, but still) handed over the
vault key. This introduces a decoupled scheme where the transmitted
verifier is a one-way function of the key.

New auth-hash scheme
- users.hash_algo 'pbkdf2-sha256-v2': the client sends
  verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of
  keyHex. Stored form is still SHA256(verifier) (identical server wrap
  to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the
  client which verifier formula to use. Verification needs no new server
  branch (VerifierToStoredHash already SHA256-wraps any non-legacy
  verifier).
- The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries
  remain decryptable and switching schemes never re-encrypts data.

Adoption: new-registration + master-pw-change only
- Register and change-master-password write v2. Existing accounts keep
  their algo until they rotate — the login/reauth migration signal now
  fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so
  sha256/v2 accounts are never force-migrated (which would have
  downgraded v2 → sha256 via migrate-kdf).

Client (js/app.js): algo-aware everywhere
- verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier
  / computeVerifier take an algo arg. state.hashAlgo caches the account
  scheme, set from /login/challenge, register, change-master, the
  quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem
  response. All ~12 verifier sites updated (login, register, reauth ×4,
  change-master current+new, migrate-kdf, quick-unlock + PIN cold-start,
  recovery-mode current verifier).

Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to
the old behaviour, so every pre-decoupling account (and every existing
quick-unlock / PIN blob without the new field) keeps working unchanged.
Verified: existing account + pre-change quick-unlock still unlocks; a
master-pw change now writes 'pbkdf2-sha256-v2' in vault.db.

Server: recovery redeem returns hashAlgo; register + change-master store
the decoupled algo; login + reauth migration signal narrowed to legacy.

Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax
gate can't block on stdin in the Delphi pre-build environment.

Addresses CODE_AUDIT.md section 1.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 12:38:20 +01:00
r-zakarya 3076fec710 feat: quick-search fill modes + editable custom-field combobox + JS build gate
Quick search (Ctrl+Shift+Q fill mode)
- Enter / left-click → full autofill (username + Tab + password), like
  Ctrl+Shift+L.
- Shift+Enter / right-click → username only (new Delphi username-only
  SendInput path via field=user; ExecuteAutofill AUsernameOnly param).
- Ctrl+Enter / Ctrl+click → password only.
- Copy mode (tray / palette) unchanged: Enter/left = password,
  Shift+Enter/right = username.
- Clipboard fix: copy-then-minimise no longer wipes the just-copied
  password — MinimizeToTray takes an AClearClipboard flag (False on the
  quick-search copy path, driven by app/minimize?keepclip=1). The 30s
  auto-clear still guards it.
- Right-click on a result row suppresses the native/custom context menu
  (preventDefault + stopPropagation).

Editable custom-field combobox
- Option-backed custom fields (card brand, expiry year/month, etc.) now
  render a custom editable combobox instead of a locked <select>: an
  arrow drops a menu of ALL options (a native <datalist> filtered to the
  typed text, which confused users), while the input stays freely
  typeable for values not in the list. Storage shape unchanged.
- Outside-click closes the menu via the existing slideover mousedown
  handler; item mousedown + preventDefault so blur doesn't race the pick.

Build safety
- BuildAssets.ps1 runs `node --check` on every embedded .js before
  generating assets.res. A syntax error now aborts the asset build
  (exit 1, file + line logged) instead of shipping a dead bundle that
  only surfaces after a full Delphi rebuild. Node is optional: absent →
  warn and continue.

Docs
- CODE_AUDIT.md: full static-analysis report (security, latent bugs,
  maintainability, future features, prioritized action plan).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 08:13:25 +01:00
r-zakarya 7440d07793 feat: profile avatar + tombstone-restore fix + WebView2 nav race + sync summary
Profile picture / avatar
- users.avatar_b64 column (nullable, cosmetic, not encrypted) + GET/POST
  /avatar endpoints mirroring the settings handler pattern.
- Top-right chip + Settings→Account show a round avatar: custom picture
  if set, otherwise the username's initial on a deterministic
  hash-picked colour (stable across renders).
- Upload downscales + center-crops to a 128px JPEG via FileReader →
  data: URI (NOT blob:, which the CSP's `img-src 'self' data:` blocks)
  before POSTing. Remove button clears it.
- Carried in the encrypted JSON export; restored on import only when the
  current account has no picture (never clobbers a local one).

Tombstone restore-then-sync fix
- POST /entries and POST /entries/bulk-import now DELETE any tombstone
  matching an inserted uuid (same transaction) so a restored backup
  isn't re-killed on the next sync by its own stale tombstone.
- applyRemoteSnapshot arbitrates remote tombstones by timestamp: a
  tombstone is skipped when the local entry with that uuid is newer than
  deleted_at (resurrection wins). Ties / unparseable timestamps favour
  KEEP. loadEntries() up front so updated_at reflects the live rows.

WebView2 navigation race
- Black-window-on-cold-start fix: the 1.5s nav timer no longer consumes
  FPendingURL when WebView2 isn't initialised yet (it re-arms, bounded
  to ~10 retries). FBrowserInitialized flag set in OnInitialized; after
  the retry budget we Navigate best-effort rather than loop forever.

Sync UX
- Bidirectional toast: "pulled X new · Y updated · Z deleted · pushed N
  entries" so a 0/0/0 pull still shows the vault was uploaded.
- FolderPOST/PUT: pre-declare ftString on color/icon params (fixes the
  earlier [SQLite]-335 on NULL bind, already in play for CSV import).

Docs
- CLAUDE.md sync section documents tombstone purge-on-insert +
  resurrection arbitration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-02 23:36:36 +01:00
r-zakarya 0a3372c151 feat: sync guards + import restore mode + inline progress + uncategorised polish
Sync engine
- Cross-account guard: refuse to merge a remote snapshot whose
  username differs from the currently-signed-in one (confirm dialog,
  Cancel by default) so a shared WebDAV URL / same sync password
  between accounts stops silently mixing vaults.
- Local tombstones veto: skip any remote entry whose uuid is already
  in the local entry_tombstones table — otherwise a perm-delete on
  this device was getting undone on the next pull.
- "deleted" counter fixed: report only tombstones that actually
  removed a live local entry this round, not the accumulated history
  the toast used to inflate ("73 deleted" for 48 real deletes).
- Push failure surfaces syncStatus reset + toast so state doesn't
  get stuck on a stale phase label.

Sync progress feedback
- Inline #syncStatus label next to the Sync button reports each
  phase: Local backup… → Pulling… → Merging… → Preparing… N/total
  (per-entry counter during the slow buildSyncSnapshot decrypt loop)
  → Encrypting… → Pushing…, then clears.
- Sync now / Test connection buttons are disabled while any run is
  in flight so double-clicks can't kick off a concurrent sync.

Import (JSON only — CSV out of scope)
- Uuid-aware dedup: split parsed rows into fresh (new uuid) vs
  overlaps (uuid already present locally).
- Overlaps prompt: confirm dialog offers Overwrite (restore/roll
  back) or Skip. Overwrite PUTs the file's payload over each match;
  Skip drops them and only imports fresh. Prevents the "re-import
  doubles everything" regression while still allowing restore.
- Bulk-import call is skipped entirely when there's nothing fresh to
  send (avoids a POST with an empty entries array).
- Template notes with empty body but populated custom_fields
  (credit-card, ssh-key, etc.) no longer skipped as "note body
  required" — kept as long as at least one custom field has a value.

Sidebar / uncategorised view
- "(no folder)" pseudo-entry stays visible whenever the vault has
  any real folder, so it always works as a drag target for
  uncategorising — and doesn't vanish mid-action when the user is
  currently viewing it.
- Header title reads "(no folder)" for state.view === 'folder:All'
  instead of the ambiguous "All".
- Dedicated empty-state copy for the uncategorised view.

Rebuild assets required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 20:24:35 +01:00
r-zakarya fbfc24a4d5 feat: sticky slideover Save + uncategorised-view polish + regression plan
- Slideover Save action row is now sticky at the bottom of the scrolling
  body (background + top border) so it stays reachable on entries with
  many custom fields or attachments — was previously buried below the
  fold.
- Uncategorised view (state.view = 'folder:All'):
  - Header title reads "(no folder)" instead of the ambiguous "All".
  - Sidebar pseudo-entry stays visible whenever the vault has at least
    one real folder, so it can be used as a drag drop-target to move
    entries out of a folder even when its own count is 0.
  - Empty state gets its own copy ("No uncategorised entries" + hint
    to drop entries here to uncategorise) instead of the generic
    "Folder is empty".
- TEST_REGRESSION.md checked in — 16-section list scoped to what the
  recent Esc / drag / sync / import work touched, so post-commit
  regressions can be walked through methodically instead of poking
  the app ad-hoc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 13:04:45 +01:00
r-zakarya 4cb45ec63a fix: Esc priority across slideover / settings / modal handlers
Pressing Esc after editing a card silently failed: requestCloseSlideOver
opened the discard-confirm modal, then the global Esc-fallback handler
fired on the same keystroke and either closed the just-opened modal
(when Esc came on a clean slideover via the modal path) or, when
Settings was layered on top of a dirty slideover, opened the discard
prompt in the background while Settings closed.

Both panel-level Esc handlers now run in capture phase and
stopPropagation:
- Slideover handler stops only when it actually acts (slideover open,
  no modal up, Settings not on top) so a single Esc opens the discard
  confirm without the global fallback racing to close it.
- Settings handler stops when Settings is the active panel so the
  global fallback's requestCloseSlideOver branch can't fire underneath
  and pop a discard confirm on the slideover the user left dirty.

Priority order is now: open modal > Settings > slideover. First Esc on
"card dirty + Settings open" closes Settings; second Esc shows the
discard confirm. Clean slideover + Esc still closes directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 23:45:17 +01:00
r-zakarya bd14831449 feat: Bitwarden import bundle + settings search + quick-search hotkey + UX fixes
- Bitwarden CSV import: folders auto-created server-side; notes column on
  login rows surfaces as a "Notes" custom field instead of polluting tags;
  type=card / type=identity rows now mapped to kind=note with the
  credit-card / identity template + card_* / identity_* columns
  pulled into custom_fields; `fields` column parsed (Bitwarden's
  "label: value\nlabel: value" lines + our own JSON shape).
- Settings panel search: live filter at top of the panel, matches each
  .setting-row individually, hides whole section when no row matches,
  shows a "No matches" banner. Esc clears query (without closing
  Settings); Esc with empty query closes the panel.
- Quick-search hotkey customizable: SetQuickSearchHotkey added to
  PM.Bridge; cmd://autofill/hotkeys extended with qs_mods/qs_vk
  (independent of the autofill enabled flag — quick-search stays
  armed even when autofill is off); state.quickSearchHotkey synced
  via settings_json; new "Quick search picker" row in Settings.
- FireDAC SQLite folder POST/PUT: pre-declare ftString on color/icon
  params so .Clear (NULL) doesn't trip "[FireDAC][Phys][SQLite]-335
  type unknown" at Prepare — was crashing the CSV-import folder
  auto-creation path.
- Edge form-data autocomplete suppressed on slideover inputs (title,
  site, username, password, TOTP, note body, custom fields):
  autocomplete=off (new-password on secrets) + spellcheck=false. Fixes
  the "Informations enregistrées" dropdown popping over data after a
  field was edited.
- closeSlideOver blurs any focused descendant before removing .is-open
  so an invisible focused field can't react to arrow-down / backspace
  after dismissal.
- Slideover Esc handler upgraded to capture phase so it fires before
  the input's own keydown or browser-level Esc swallow on the active
  autocomplete popup.
- Settings panel Esc closes the panel when search input is empty;
  search keeps the keystroke when it has a query to clear.
- Discard-fantome on note open: customFields working copy and
  originalCustomJson now share the SAME normalized array — comparing
  raw plainCustom against the .map()'d working copy made notes look
  dirty on open.
- Delete / Backspace global shortcut: batch-trash on normal views,
  batch perm-delete on trash view, gated on selection + no input
  focused + no modal up.
- Toggle thumb vertical centering via top:50% + translateY(-50%);
  state checked uses translate(16px, -50%) to keep the centring.
- Batch bar disappears after per-card restore/perm-delete/trash:
  state.checked.delete(id) before render for the relevant flows;
  state.checked.clear() before render in emptyTrash and the new
  moveEntriesToFolder helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 23:25:50 +01:00
r-zakarya 6869b7c692 feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle
- Sync (WebDAV, auto-merge): UUID + tombstones foundations (server +
  JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with
  pull/merge/push flow, Settings UI, pre-sync backup option. Test
  connection now treats 404 as OK (snapshot not yet created) and 401/
  403 as auth failure with dedicated toast.
- Batch drag-drop: cards + table rows carry checked-set ids (CSV) when
  dragged from an active selection; folder + trash drop handlers parse
  and apply in batch via new moveEntriesToFolder helper that preserves
  TOTP / custom_fields / kind in the full PUT payload.
- Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the
  bridge message-only window; FormCloseQuery bypasses the tray-minimize
  intercept on system shutdown / restart / logoff so FireDAC closes the
  SQLite WAL cleanly instead of leaving -shm / -wal residue after a
  force-kill.
- Center-mode modal: blur+dim backdrop via body::before pseudo-element
  in editor-position=center, swallows clicks below the panel so the
  existing outside-click handlers reliably dismiss the slideover /
  settings panel.
- Batch bar state fixes: state.checked cleared before render in
  moveEntriesToFolder, emptyTrash, and per-card restoreEntry /
  permanentDelete / deleteEntry so the action bar disappears once the
  selection is fully processed.
- Save-then-discard duplicate fix: soState reset to null before
  openSlideOver re-opens the freshly saved entry, otherwise the dirty
  check fired on the soState.id=null → newId switch and a Cancel left
  the form in new-entry mode (second Save → POST duplicate).
- TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync
  with 2 real instances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-30 00:32:12 +01:00