feat: secure notes + password history + custom fields + quick-win bundle
Big feature trio
- Secure notes (kind='login'|'note') reusing the encrypted_password+iv
pipeline for the body. New sidebar entry, slideover variant (title +
multiline body), distinct card / table-view rendering, badge in name
column, copy-content button replacing the password copy on note rows.
- Password history: entries_password_history table keeps up to 20 prior
ciphertexts per entry. HandleUpdateEntry pushes the pre-update
encrypted_password into history ONLY when it actually differs from
the incoming one (JS reuses originalEncrypted bit-for-bit when the
plaintext is unchanged — avoids spamming history on title/folder edits).
GET /entries/{id}/history endpoint. Slideover modal lists versions
with mask/reveal/copy/revert. Master-pw rotation wipes history (old
ciphertext can't be decrypted with the new key).
- Custom fields: per-entry encrypted JSON array of {label, value,
is_secret}. Same crypto pipeline as the password. Slideover row UI
with label/value inputs, secret toggle (eye), copy, delete. Re-
encryption flows through bulk-import, change-master-password, and
duplicate.
Quick wins
- Cheatsheet overlay (press '?' or topbar button or Ctrl+K). Lists all
hotkeys + global / tray / card actions. SVG icons inline so the
cheatsheet matches the actual app glyphs (no emoji mismatch).
- Open URL button on entry cards: ShellExecute via cmd://app/open-url,
http(s) only, validates entry.site looks like a real hostname.
- Trash auto-purge: setting "Empty trash after N days" (never/7/30/90).
DELETE /entries/trash/old?days=N called at every unlock.
Favicon strategy
- Subdomains (chat.deepseek.com, app.X.com…) now try the SLD first
(deepseek.com.ico) before the full host. DDG often returns a generic
placeholder for subdomains that passes the byte threshold; the SLD-first
switch surfaces the real brand icon.
- Cap bumped 64 KB → 256 KB on all three sides (Delphi fetch, server
endpoint, JS upload). DDG sometimes serves the full-res asset.
UX polish
- Click-outside-slideover: stopPropagation everywhere it bites. Custom
fields buttons (add / delete / secret toggle / copy / eye) all stop
the click bubble so the document-level "close on outside click" handler
doesn't fire when rerender() detaches the target from the DOM.
- Native search-cancel button restyled: cyan accent X via mask-image,
cursor: pointer, breathing room before the Ctrl+K kbd chip.
- Password history modal: scrollable body, multiline wrapped passwords,
hover border highlight.
- Cheatsheet panel widened (560 → 720 px) so the descriptions no longer
ellipsis-clip.
- "+ New" topbar splits into a small dropdown: New login / New note.
- Notes show a "note" badge in table-view name column, italic
"Encrypted note" placeholder in the username column.
Internals
- duplicateEntry copies kind + custom_fields too (one-line forgotten
earlier).
- entries_password_history dropped on master-pw rotation — the old
ciphertexts are unrecoverable with the new key.
- bulk-import re-encryption path includes custom_fields.
CLAUDE.md
- "Entry payload — call sites à toucher ensemble" lists the 6 spots
to update when adding a new (en)crypted field. Notes the historical
miss of kind in duplicateEntry and custom_fields in the rotation +
duplicate.
Repo hygiene
- .gitattributes forces CRLF on Delphi sources (RAD Studio refuses LF).
text=auto for web frontend / docs, binary for .res / .exe / images.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+288
-1
@@ -564,7 +564,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
}
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 8px 60px 8px 34px;
|
||||
padding: 8px 70px 8px 34px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -576,6 +576,28 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
/* Replace the native (blue/grey) search-cancel button with a custom one
|
||||
tinted via background-color so it picks up the accent on hover and
|
||||
matches the other X buttons in the app. mask-image keeps it crisp. */
|
||||
.search input::-webkit-search-cancel-button {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
background-color: var(--accent);
|
||||
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>");
|
||||
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>");
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
opacity: 0.85;
|
||||
transition: opacity var(--t-fast);
|
||||
}
|
||||
.search input::-webkit-search-cancel-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.search kbd { position: absolute; right: 8px; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
|
||||
@@ -700,6 +722,26 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
|
||||
.entry-grid.is-list .entry-head { display: contents; }
|
||||
.entry-grid.is-list .entry-pw-row { display: contents; }
|
||||
.entry-grid.is-list .entry-note-row { display: contents; }
|
||||
.entry-grid.is-list .entry-note-placeholder {
|
||||
order: 3;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
.entry-grid.is-list .entry-note-row .icon-btn {
|
||||
order: 4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-grid.is-list .entry-avatar {
|
||||
order: 1;
|
||||
@@ -1258,6 +1300,251 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
|
||||
/* ---- Password history modal --------------------- */
|
||||
.so-history-wrap {
|
||||
margin-top: -8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.so-history-wrap .btn { padding: 4px 8px; font-size: 11px; }
|
||||
|
||||
/* Make the history body actually scrollable. The .modal-body's default
|
||||
doesn't fix the height, so the panel grew to fit all rows → no scroll. */
|
||||
#historyBody {
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.history-loading, .history-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.history-row {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.history-meta {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.history-date {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.history-preview {
|
||||
display: flex; align-items: flex-start; gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.history-value {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
/* Long revealed passwords wrap to multiple lines (no ellipsis) so
|
||||
the user sees the full value. break-all is needed because real
|
||||
passwords have no whitespace for the browser to break on. */
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.history-row { min-width: 0; }
|
||||
.history-actions {
|
||||
display: flex; justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- Cheatsheet overlay --------------------------- */
|
||||
.cheatsheet-panel { max-width: 720px; width: 100%; }
|
||||
.cheatsheet-body {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
.cheatsheet-group { margin-bottom: 18px; }
|
||||
.cheatsheet-group h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.cheatsheet-list {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.cheatsheet-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cheatsheet-desc {
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cheatsheet-keys {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cheatsheet-keys kbd {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
padding: 2px 7px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 var(--border-soft);
|
||||
}
|
||||
.cheatsheet-plus {
|
||||
color: var(--text-faint);
|
||||
font-size: 10px;
|
||||
}
|
||||
.cheatsheet-icon-chip {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 22px;
|
||||
padding: 2px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 0 var(--border-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
.cheatsheet-icon-chip svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ---- Slideover custom-fields editor ------------------ */
|
||||
.so-custom-list {
|
||||
display: flex; flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.so-custom-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr auto auto auto auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.so-custom-row .so-input {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.so-custom-secret-toggle {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
width: 28px; height: 28px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.so-custom-secret-toggle.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.so-custom-add {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.so-custom-add .btn { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* ---- Note cards (kind=note) -------------------------- */
|
||||
.entry-note-row {
|
||||
display: flex; align-items: center;
|
||||
padding: 4px 0 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
.entry-note-placeholder {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Note marker in table-view "Name" cell + faint placeholder in user cell */
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.col-user-note {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---- Slideover note textarea ------------------------- */
|
||||
.so-note-body {
|
||||
min-height: 220px;
|
||||
resize: vertical;
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ---- + New dropdown ---------------------------------- */
|
||||
.new-entry-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
.new-entry-caret {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
.new-entry-wrap > #newEntryBtn {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
}
|
||||
.new-entry-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;
|
||||
}
|
||||
.new-entry-menu .dropdown-item {
|
||||
width: 100%;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: none; border: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.new-entry-menu .dropdown-item:hover { background: var(--accent-soft); }
|
||||
.new-entry-menu .dropdown-item svg { width: 14px; height: 14px; }
|
||||
|
||||
/* ---- Quick search modal (tray menu) ------------------ */
|
||||
.quick-search-panel {
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user