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:
+99
-9
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user