feat: PIN unlock + table column picker + edit-position chooser + UX

- PIN unlock: device-local 4-12 digit shortcut, DPAPI-wrapped vault
  key. Three modes (state.unlockMode): pw / pin / pw+pin. PIN
  derives a wrap key via PBKDF2(pin, salt, 100k) and unwraps the
  stored vault key (mirrors the Quick Unlock blob shape).
  Anti-brute-force: 5 wrong attempts wipes the blob. Setup gated by
  master-pw reauth so an unattended unlocked laptop can't be
  backdoored. Master pw rotation clears the PIN blob (key drift).
  loadServerSettings post-sync demotes pin/both -> pw when the local
  blob is missing, so a wiped device re-syncs the correct mode up.
  New unit PM.PinUnlock.pas + cmd://pin/{store,get,clear,status}.
- Table column picker: ⚙ in topbar (table view only), checkbox menu
  for Site/Username/Folder/Updated. Site also drives showSiteOnCards
  so the existing "Show site / URL" toggle in Settings stays in
  sync. NAME column auto-widths (180px min, content max, +32px
  right padding) so column hugs the next one without truncating.
- Editor position chooser (Appearance setting): Slide-over right /
  left / Centered modal. Scoped to #slideover + #settingsPanel so
  the click-outside / pointer-events logic doesn't accidentally
  trap the modal-style empty viewport.
- Confirm before discarding unsaved edits: state.confirmOnUnsaved
  setting (default ON), prompts on X / Esc / click-outside / switch-
  to-other-entry. Also gates Lock vault / Sign out actions when the
  editor is dirty; auto-lock and system-lock paths bypass to avoid
  blocking on an unattended machine.
- Open-in-browser button added to the actions cell of the table
  view (was card-only).
- Entry templates pass folder customization + template id through
  duplicate / export / import / auto-backup roundtrips.
- Folder color + icon now persisted across export/import: payload.
  folders carries name/color/icon; import creates missing folders
  additively (existing local customisation kept).
- Bulk move-to-folder, batch add-tag, single add-tag now re-ship
  the full entry payload so partial PUTs don't silently wipe
  TOTP / custom_fields / kind / template.
- FireDAC: switched ftString -> ftMemo for icon_b64 / custom_fields
  / TOTP / template params and replaced .AsString with .Value so a
  large (~200 KB) DeepSeek favicon no longer gets truncated at the
  default ANSI 4000-char cap.
- Unicode filenames: attachment INSERT now uses ftWideString +
  .AsWideString so non-ANSI filenames round-trip instead of being
  mangled to "?".
- HandleSetEntryIcon cap raised 256 KB -> 512 KB chars to accept
  base64 data URIs produced by max-raw favicon fetches.
- promptDialog + askReauth support inline `error` line + retry-
  with-count loops on doExport reauth and auto-backup password
  setup (5 attempts cap before bailing).
- Recently used moved from Tools to Vault section in the sidebar.
- Auth screen passkey button hidden (Delphi backend stubs WebAuthn).
- Sensitive cmd://favicon/refresh-style buttons in Settings now
  stopPropagation so the document-level "close panel" handler
  doesn't dismiss Settings mid-async during DOM reparenting.
- TEST_PLAN.md: +PIN unlock section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-29 04:41:39 +01:00
parent 1f56a03492
commit b00da43ab0
11 changed files with 1417 additions and 71 deletions
+99 -9
View File
@@ -1859,6 +1859,32 @@ input[type="range"]::-webkit-slider-thumb {
color: var(--text-faint);
}
/* Table column picker dropdown — only rendered in table view. */
.cols-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: 60;
min-width: 160px;
padding: 4px;
overflow: hidden;
}
.cols-menu.is-hidden { display: none; }
.cols-menu-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px;
font-size: 13px;
color: var(--text);
cursor: pointer;
border-radius: 4px;
}
.cols-menu-item:hover { background: var(--bg-elev-2); }
.cols-menu-item input { cursor: pointer; }
/* ---- Quick search modal (tray menu) ------------------ */
.quick-search-panel {
padding: 0;
@@ -1986,12 +2012,57 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 50;
}
.slideover.is-open { transform: translateX(0); }
/* Push the app shell to the left when the slideover is open, so the
table, sort buttons, pagination and search bar are no longer hidden
behind it. Width matches .slideover (420px) — bumped to 100% via the
responsive override below 720px. */
body:has(.slideover.is-open) #appShell { margin-right: 420px; }
#appShell { transition: margin-right var(--t-base); }
/* Suppress the slide animation when the panel is closed — otherwise
changing editor-position animates the off-screen position from right
to left and the panel briefly crosses the viewport. */
.slideover:not(.is-open) { transition: none; }
/* Push the app shell to the left when the entry slideover is open,
so the table, pagination etc. stay visible. Only applies to the
entry editor (#slideover) — NOT #settingsPanel which would visually
re-flow the whole app. Width matches .slideover (420px). */
body[data-editor-position="right"]:has(#slideover.is-open) #appShell { margin-right: 420px; }
#appShell { transition: margin-right var(--t-base), margin-left var(--t-base); }
/* ---- Editor position: LEFT (mirrored slideover) ---- */
body[data-editor-position="left"] #slideover,
body[data-editor-position="left"] #settingsPanel {
left: 0; right: auto;
border-left: none;
border-right: 1px solid var(--border);
transform: translateX(-100%);
}
body[data-editor-position="left"] #slideover.is-open,
body[data-editor-position="left"] #settingsPanel.is-open { transform: translateX(0); }
body[data-editor-position="left"]:has(#slideover.is-open) #appShell {
margin-left: 420px;
}
/* ---- Editor position: CENTER (modal-style) ---- */
body[data-editor-position="center"] #slideover,
body[data-editor-position="center"] #settingsPanel {
top: 50%; left: 50%; right: auto; bottom: auto;
width: 520px;
max-height: 90vh;
border-radius: var(--radius-lg);
border-left: 1px solid var(--border);
transform: translate(-50%, -50%) scale(0.96);
opacity: 0;
/* Hidden panels in center mode live INSIDE the viewport (opacity 0
instead of off-screen translate), so without this guard the
buttons would stay clickable through the invisible panel. */
pointer-events: none;
transition: transform var(--t-base), opacity var(--t-base);
}
body[data-editor-position="center"] #slideover.is-open,
body[data-editor-position="center"] #settingsPanel.is-open {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
pointer-events: auto;
}
/* No dim/blur in center mode — the panel doesn't actually block
interaction (cards, sidebar, topbar stay clickable without
dismissing it), so painting a modal-style backdrop would lie about
the behaviour. The panel just floats above the page. */
.slideover-header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px;
@@ -2289,7 +2360,7 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; }
.slideover { width: 100%; }
/* On a narrow viewport the slideover fills the screen — no point
pushing the shell, the table isn't visible anyway. */
body:has(.slideover.is-open) #appShell { margin-right: 0; }
body:has(.slideover.is-open) #appShell { margin-right: 0; margin-left: 0; }
}
/* ---- 17. AUTOFILL PICKER -------------------------------- */
@@ -2403,7 +2474,24 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; }
.entry-row.is-selected { background: var(--accent-soft); }
.entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); }
.col-check { width: 36px; }
.col-name { min-width: 180px; }
/* NAME stops growing past 360px so it doesn't push Username/Folder/
Updated all the way to the right. Long names ellipsise on a single
line so the avatar never falls under the title. */
.col-name {
min-width: 180px;
width: auto; /* shrinks to short names, grows to fit
long ones — no fixed cap, the column
matches its widest entry. */
white-space: nowrap;
padding-right: 32px; /* extra breathing room so the longest
title never butts up against Username. */
}
.col-name .cell-name-wrap {
display: inline-flex;
align-items: center;
gap: 6px;
vertical-align: middle;
}
.col-name .entry-avatar-sm {
width: 22px; height: 22px;
display: inline-grid; place-items: center;
@@ -2420,7 +2508,9 @@ body:has(.slideover.is-open) #appShell { margin-right: 420px; }
.entry-row:hover .col-user .icon-btn { opacity: 0.7; }
.col-folder { color: var(--text-dim); white-space: nowrap; }
.col-updated { color: var(--text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; }
.col-actions { width: 80px; text-align: right; white-space: nowrap; }
/* Soak the remaining horizontal space so NAME/Username/Folder stay
close to each other on the left rather than spreading out. */
.col-actions { width: 100%; min-width: 80px; text-align: right; white-space: nowrap; }
.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); }
.entry-row:hover .col-actions .icon-btn { opacity: 0.7; }
.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; }