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:
2026-06-14 20:17:19 +01:00
parent 39406d712e
commit 63fac5b3b7
11 changed files with 1487 additions and 113 deletions
+33
View File
@@ -0,0 +1,33 @@
# Delphi / RAD Studio source files require CRLF line endings — the IDE
# warns ("Line endings are LF, but RAD Studio requires CRLF") and some
# packagers refuse LF outright. Force CRLF on both checkout and check-in
# so the working tree always has the right line endings regardless of
# core.autocrlf setting on the dev machine.
*.pas text eol=crlf
*.dpr text eol=crlf
*.dpk text eol=crlf
*.dproj text eol=crlf
*.fmx text eol=crlf
*.dfm text eol=crlf
*.inc text eol=crlf
*.rc text eol=crlf
# Resource files generated from web assets — keep them binary so Git
# doesn't try to normalise the embedded bytes.
*.res binary
# Web frontend & docs — let Git normalise to native (LF on Linux/macOS,
# CRLF on Windows via autocrlf). Explicit so .pas's eol=crlf doesn't
# cascade by accident.
*.html text
*.js text
*.css text
*.json text
*.md text
# Build outputs / archives — never normalise
*.exe binary
*.dll binary
*.ico binary
*.png binary
*.rar binary
+31
View File
@@ -353,6 +353,37 @@ viewMode change, pageSize change. Render via `renderPagination(total, totalPages
Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?"
— scroll + pulse trouvent la nouvelle entry dans la grille triée. — scroll + pulse trouvent la nouvelle entry dans la grille triée.
## Entry payload — call sites à toucher ensemble
Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** :
`encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`,
plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title,
username, folder, tags, kind`).
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
sur certaines actions :
1. **DB schema** : `PM.Database.CreateSchema` `AddColumnIfMissing(...)`
2. **GET /entries** : `PM.Handler.Entries.HandleGetEntries` — ajouter au
`LObj.AddPair(...)` (NULL → `TJSONNull`)
3. **POST + PUT /entries** : `HandleCreateEntry` + `HandleUpdateEntry`
lire du body, binder le param, gérer `Clear` pour NULL
4. **Master pw rotation** : `PM.Handler.Auth.HandleChangeMasterPassword`
UPDATE doit inclure la colonne, sinon la rotation l'écrase à NULL
5. **Master pw rotation JS** : `doChangeMasterPassword` → la boucle
`for (const e of state.entries)` re-chiffre chaque blob et push dans
`encrypted[]`. Manquer un champ chiffré = donnée perdue.
6. **`duplicateEntry`** (js/app.js) — copier le blob chiffré tel quel
(même vault key, pas besoin de re-chiffrer)
Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau
champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`.
Historiquement on a oublié `kind` dans `duplicateEntry` (bug "Site required"
sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette
liste évite de répéter ces erreurs.
## Settings sync ## Settings sync
Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
+288 -1
View File
@@ -564,7 +564,7 @@ input[type="range"]::-webkit-slider-thumb {
} }
.search input { .search input {
width: 100%; width: 100%;
padding: 8px 60px 8px 34px; padding: 8px 70px 8px 34px;
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -576,6 +576,28 @@ input[type="range"]::-webkit-slider-thumb {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); 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; } .search kbd { position: absolute; right: 8px; }
.topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } .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-head { display: contents; }
.entry-grid.is-list .entry-pw-row { 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 { .entry-grid.is-list .entry-avatar {
order: 1; order: 1;
@@ -1258,6 +1300,251 @@ input[type="range"]::-webkit-slider-thumb {
display: flex; flex-direction: column; gap: 6px; 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 modal (tray menu) ------------------ */
.quick-search-panel { .quick-search-panel {
padding: 0; padding: 0;
+24 -8
View File
@@ -918,6 +918,7 @@ begin
'UPDATE vault_entries SET ' + 'UPDATE vault_entries SET ' +
' encrypted_password = :ep, iv = :iv, ' + ' encrypted_password = :ep, iv = :iv, ' +
' totp_secret = :ts, totp_iv = :tiv, ' + ' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' updated_at = CURRENT_TIMESTAMP ' + ' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid'; 'WHERE id = :id AND user_id = :uid';
@@ -929,6 +930,8 @@ begin
LIv := LEntry.GetValue<string>('iv', ''); LIv := LEntry.GetValue<string>('iv', '');
LTotpSec := LEntry.GetValue<string>('totp_secret', ''); LTotpSec := LEntry.GetValue<string>('totp_secret', '');
LTotpIv := LEntry.GetValue<string>('totp_iv', ''); LTotpIv := LEntry.GetValue<string>('totp_iv', '');
var LCf := LEntry.GetValue<string>('custom_fields', '');
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
@@ -936,18 +939,31 @@ begin
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ep').AsString := LEncPwd; LQ.ParamByName('ep').AsString := LEncPwd;
LQ.ParamByName('iv').AsString := LIv; LQ.ParamByName('iv').AsString := LIv;
// TOTP fields are optional per entry — clear when empty so // TOTP / custom_fields are optional per entry — clear when
// existing-NULL rows don't get stomped with empty strings. // empty so existing-NULL rows don't get stomped with empty strings.
LQ.ParamByName('ts').DataType := ftString; LQ.ParamByName('ts').DataType := ftString;
LQ.ParamByName('tiv').DataType := ftString; LQ.ParamByName('tiv').DataType := ftString;
if LTotpSec.IsEmpty then LQ.ParamByName('cf').DataType := ftString;
LQ.ParamByName('ts').Clear LQ.ParamByName('cfiv').DataType := ftString;
else if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear
LQ.ParamByName('ts').AsString := LTotpSec; else LQ.ParamByName('ts').AsString := LTotpSec;
if LTotpIv = '' then LQ.ParamByName('tiv').Clear if LTotpIv = '' then LQ.ParamByName('tiv').Clear
else LQ.ParamByName('tiv').AsString := LTotpIv; else LQ.ParamByName('tiv').AsString := LTotpIv;
if LCf = '' then LQ.ParamByName('cf').Clear
else LQ.ParamByName('cf').AsString := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear
else LQ.ParamByName('cfiv').AsString := LCfIv;
LQ.ExecSQL; LQ.ExecSQL;
end; end;
// Password history is encrypted with the OLD vault key — we
// don't ship the plaintext server-side to re-encrypt it under
// the new key. Drop the history rows so a future "Show history"
// doesn't surface undecryptable garbage. The user accepts this
// as a consequence of rotating their master password.
LQ.SQL.Text :=
'DELETE FROM entries_password_history WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally finally
LQ.Free; LQ.Free;
end; end;
+204 -10
View File
@@ -122,6 +122,21 @@ begin
LObj.AddPair('icon_b64', TJSONNull.Create) LObj.AddPair('icon_b64', TJSONNull.Create)
else else
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString); LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
// Entry kind. Legacy / unset → 'login'.
var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login';
LObj.AddPair('kind', LKindVal);
// Custom fields: opaque ciphertext + IV, treated identically to
// password / totp_secret. NULL → JSON null so the client can
// distinguish "never set" from "empty array stored".
if LQ.FieldByName('custom_fields').IsNull then
LObj.AddPair('custom_fields', TJSONNull.Create)
else
LObj.AddPair('custom_fields', LQ.FieldByName('custom_fields').AsString);
if LQ.FieldByName('custom_fields_iv').IsNull then
LObj.AddPair('custom_fields_iv', TJSONNull.Create)
else
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
LArr.Add(LObj); LArr.Add(LObj);
@@ -143,7 +158,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LNewId: Integer; LUserId, LNewId: Integer;
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -165,13 +181,24 @@ begin
// TOTP secret + IV — optional. Empty string = no TOTP configured. // TOTP secret + IV — optional. Empty string = no TOTP configured.
LTotpSec := LBody.GetValue<string>('totp_secret', ''); LTotpSec := LBody.GetValue<string>('totp_secret', '');
LTotpIv := LBody.GetValue<string>('totp_iv', ''); LTotpIv := LBody.GetValue<string>('totp_iv', '');
LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
finally finally
LBody.Free; LBody.Free;
end; end;
if (LSite = '') or (LEnc = '') then if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
// 'login' entries require a site; 'note' only needs encrypted body.
if LEnc = '' then
begin begin
TJSONHelper.SendError(AResponse, 400, 'Site & password required'); TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit;
end;
if (LKind = 'login') and (LSite = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Site required');
Exit; Exit;
end; end;
@@ -185,8 +212,10 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'INSERT INTO vault_entries ' + 'INSERT INTO vault_entries ' +
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; ' created_at, updated_at) ' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
' :cf, :cfiv, :c, :c2)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
@@ -211,6 +240,11 @@ begin
LQ.ParamByName('tiv').Clear LQ.ParamByName('tiv').Clear
else else
LQ.ParamByName('tiv').AsString := LTotpIv; LQ.ParamByName('tiv').AsString := LTotpIv;
LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString;
LQ.ParamByName('cfiv').DataType := ftString;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
@@ -230,6 +264,7 @@ begin
LObj.AddPair('username', LUser); LObj.AddPair('username', LUser);
LObj.AddPair('folder', LFolder); LObj.AddPair('folder', LFolder);
LObj.AddPair('tags', LTags); LObj.AddPair('tags', LTags);
LObj.AddPair('kind', LKind);
TJSONHelper.SendJSON(AResponse, LObj); TJSONHelper.SendJSON(AResponse, LObj);
end; end;
@@ -240,7 +275,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
var var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -268,13 +304,23 @@ begin
LTags := Trim(LBody.GetValue<string>('tags', '')); LTags := Trim(LBody.GetValue<string>('tags', ''));
LTotpSec := LBody.GetValue<string>('totp_secret', ''); LTotpSec := LBody.GetValue<string>('totp_secret', '');
LTotpIv := LBody.GetValue<string>('totp_iv', ''); LTotpIv := LBody.GetValue<string>('totp_iv', '');
LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
finally finally
LBody.Free; LBody.Free;
end; end;
if (LSite = '') or (LEnc = '') then if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
if LEnc = '' then
begin begin
TJSONHelper.SendError(AResponse, 400, 'Site & password required'); TJSONHelper.SendError(AResponse, 400, 'Content required');
Exit;
end;
if (LKind = 'login') and (LSite = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Site required');
Exit; Exit;
end; end;
@@ -284,10 +330,36 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
// Insert pre-update ciphertext into history ONLY when it actually
// changed (JS reuses originalEncrypted bit-for-bit otherwise).
LQ.SQL.Text :=
'INSERT INTO entries_password_history ' +
' (entry_id, user_id, encrypted_password, iv, kind, changed_at) ' +
'SELECT id, user_id, encrypted_password, iv, ' +
' COALESCE(NULLIF(kind, ''''), ''login''), :now ' +
'FROM vault_entries ' +
'WHERE id = :id AND user_id = :uid ' +
' AND encrypted_password <> :newenc';
LQ.ParamByName('now').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('newenc').AsString := LEnc;
LQ.ExecSQL;
// Cap to last 20 versions.
LQ.SQL.Text :=
'DELETE FROM entries_password_history WHERE id IN (' +
' SELECT id FROM entries_password_history ' +
' WHERE entry_id = :id ' +
' ORDER BY changed_at DESC ' +
' LIMIT -1 OFFSET 20)';
LQ.ParamByName('id').AsInteger := LId;
LQ.ExecSQL;
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + 'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' +
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
' updated_at=:c ' + ' updated_at=:c ' +
'WHERE id=:id AND user_id=:uid'; 'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
@@ -311,6 +383,11 @@ begin
LQ.ParamByName('tiv').Clear LQ.ParamByName('tiv').Clear
else else
LQ.ParamByName('tiv').AsString := LTotpIv; LQ.ParamByName('tiv').AsString := LTotpIv;
LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString;
LQ.ParamByName('cfiv').DataType := ftString;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
@@ -505,7 +582,10 @@ begin
// Soft cap to prevent a misbehaving fetcher from ballooning the DB. // Soft cap to prevent a misbehaving fetcher from ballooning the DB.
// 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64. // 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64.
if Length(LIcon) > 65536 then // Soft cap. Most favicons are < 10 KB; bumped to 256 KB because DDG
// occasionally serves the brand's full-resolution PNG (deepseek.com
// came back at 200+ KB) and we want those to be cacheable too.
if Length(LIcon) > 262144 then
begin begin
TJSONHelper.SendError(AResponse, 413, 'Icon too large'); TJSONHelper.SendError(AResponse, 413, 'Icon too large');
Exit; Exit;
@@ -571,6 +651,118 @@ begin
TJSONHelper.SendOK(AResponse, 'Icons cleared'); TJSONHelper.SendOK(AResponse, 'Icons cleared');
end; end;
// ===== GET /entries/{id}/history =============================================
// Returns up to 20 prior versions of one entry's encrypted_password+iv.
// The client decrypts with the current vault key (rotation re-encrypts the
// whole history table, see HandleChangeMasterPassword in PM.Handler.Auth).
procedure HandleGetEntryHistory(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LId := StrToIntDef(AParams[0], 0);
if LId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid id');
Exit;
end;
LArr := TJSONArray.Create;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT id, encrypted_password, iv, kind, changed_at ' +
'FROM entries_password_history ' +
'WHERE entry_id = :id AND user_id = :uid ' +
'ORDER BY changed_at DESC';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
LObj.AddPair('kind', LQ.FieldByName('kind').AsString);
LObj.AddPair('changed_at', ISODateTimeField(LQ.FieldByName('changed_at')));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== DELETE /entries/trash/old?days=N ======================================
// Permanently deletes trashed entries whose deleted_at is older than N days.
// Driven by the user's "Auto-purge trash" setting; called from JS at login.
procedure HandleAutoPurgeTrash(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LDays, LPurged: Integer;
LQ: TFDQuery;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LDays := StrToIntDef(GetQueryParam(ARequest, 'days', '0'), 0);
if (LDays <= 0) or (LDays > 3650) then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid days');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'DELETE FROM vault_entries ' +
'WHERE user_id = :uid AND deleted = 1 ' +
' AND deleted_at IS NOT NULL ' +
' AND (julianday(''now'') - julianday(deleted_at)) >= :d';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('d').AsInteger := LDays;
LQ.ExecSQL;
LPurged := LQ.RowsAffected;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
if LPurged > 0 then
LogAudit(LUserId, Format('auto_purge_trash %d entries (> %d days)',
[LPurged, LDays]), GetClientIP(ARequest));
LObj := TJSONObject.Create;
LObj.AddPair('purged', TJSONNumber.Create(LPurged));
TJSONHelper.SendJSON(AResponse, LObj);
end;
// ===== DELETE /entries/trash/empty =========================================== // ===== DELETE /entries/trash/empty ===========================================
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
@@ -771,11 +963,13 @@ initialization
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match. // /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}. // Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons); Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/bulk-import', HandleBulkImport);
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon);
Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory);
Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries/count', HandleEntriesCount);
Router.Register('GET', '/entries', HandleGetEntries); Router.Register('GET', '/entries', HandleGetEntries);
Router.Register('POST', '/entries', HandleCreateEntry); Router.Register('POST', '/entries', HandleCreateEntry);
+30
View File
@@ -189,6 +189,25 @@ begin
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')'); ')');
// Password history — keeps the last N versions of each entry's
// encrypted_password + iv. Populated by HandleUpdateEntry before each
// PUT overwrites the row; pruned to 20 entries per row after each insert.
// kind mirrors vault_entries.kind so notes can be restored too.
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS entries_password_history (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' entry_id INTEGER NOT NULL,' +
' user_id INTEGER NOT NULL,' +
' encrypted_password TEXT NOT NULL,' +
' iv TEXT NOT NULL,' +
' kind TEXT NOT NULL DEFAULT ''login'',' +
' changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_history_entry ' +
' ON entries_password_history(entry_id, changed_at DESC)');
end; end;
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
@@ -241,6 +260,17 @@ begin
// sees the plaintext secret. NULL = no TOTP configured for this entry. // sees the plaintext secret. NULL = no TOTP configured for this entry.
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT'); AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT'); AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
// Entry kind: 'login' (default — site/user/encrypted_password/iv/totp)
// or 'note' (free-text secure note — body stored in encrypted_password
// + iv, site/username/totp_* unused). Legacy rows default to 'login'.
AddColumnIfMissing('vault_entries', 'kind', 'TEXT DEFAULT ''login''');
// Custom fields: opaque encrypted JSON array of
// [{label, value, is_secret}, ...]
// Same crypto pipeline as encrypted_password (AES-GCM with the vault
// key). NULL = no custom fields configured. The server treats both
// columns as opaque ciphertext + IV.
AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT');
AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT');
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,..."). // Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
// Fetched on demand by the Delphi favicon proxy when the user opts in. // Fetched on demand by the Delphi favicon proxy when the user opts in.
// NULL = no icon cached → JS falls back to the first-letter avatar. // NULL = no icon cached → JS falls back to the first-letter avatar.
+27 -23
View File
@@ -39,7 +39,8 @@ uses
const const
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico'; ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit) MAX_ICON_BYTES = 262144; // 256 KB cap (DDG sometimes serves full-res
// assets; matches handler + JS upload limits)
HTTP_TIMEOUT_MS = 5000; HTTP_TIMEOUT_MS = 5000;
// DDG returns a generic placeholder for unknown domains. Bigger threshold // DDG returns a generic placeholder for unknown domains. Bigger threshold
// than 100 to avoid treating its blank globe glyph as a real icon. // than 100 to avoid treating its blank globe glyph as a real icon.
@@ -153,48 +154,51 @@ begin
Exit; Exit;
end; end;
// Strategy: prefer DDG (privacy-centralising) but fall back to the // Strategy: prefer the SLD (brand domain) when the host has a subdomain,
// site's own /favicon.ico for domains DDG doesn't index (self-hosted // because DDG often returns a generic placeholder for chat.X.com / app.X.com
// tools, niche services, fresh subdomains, etc.). The user already // / etc. (passes our byte threshold but looks wrong) while having the real
// opted into "fetch icons" so the DNS leak to one extra host they // brand icon under X.com. For bare 2-label hosts we go straight to step 2.
// already visit is an acceptable trade-off for actually getting an icon.
LSld := ExtractSLD(LHost); LSld := ExtractSLD(LHost);
LOk := False; LOk := False;
// 1) DDG full host. // 1) DDG SLD first when host has a subdomain (e.g. chat.deepseek.com →
LUrl := Format(ICON_URL_TEMPLATE, [LHost]); // try deepseek.com.ico first). Skipped for bare hosts.
if FetchOneIcon(LUrl, LBytes) then if LSld <> '' then
begin begin
if Length(LBytes) >= MIN_REAL_ICON_BYTES then LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
if FetchOneIcon(LUrl, LBytes) then
begin begin
LOk := True; if Length(LBytes) >= MIN_REAL_ICON_BYTES then
Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)])); begin
LOk := True;
Trace(Format('OK step1 DDG sld: %s (%d bytes)', [LUrl, Length(LBytes)]));
end
else
Trace(Format('skip step1 DDG sld: %s only %d bytes', [LUrl, Length(LBytes)]));
end end
else else
Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)', Trace('fail step1 DDG sld: ' + LUrl);
[LUrl, Length(LBytes), MIN_REAL_ICON_BYTES])); end;
end
else
Trace('fail step1 DDG host: ' + LUrl);
// 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s). // 2) DDG full host as fallback (covers brands whose subdomain has its own
if (not LOk) and (LSld <> '') then // distinct icon, OR plain hosts like github.com that have no SLD step).
if not LOk then
begin begin
var LTry: TBytes; var LTry: TBytes;
LUrl := Format(ICON_URL_TEMPLATE, [LSld]); LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
if FetchOneIcon(LUrl, LTry) then if FetchOneIcon(LUrl, LTry) then
begin begin
if Length(LTry) >= MIN_REAL_ICON_BYTES then if Length(LTry) >= MIN_REAL_ICON_BYTES then
begin begin
LBytes := LTry; LOk := True; LBytes := LTry; LOk := True;
Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)])); Trace(Format('OK step2 DDG host: %s (%d bytes)', [LUrl, Length(LTry)]));
end end
else else
Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)])); Trace(Format('skip step2 DDG host: %s only %d bytes', [LUrl, Length(LTry)]));
end end
else else
Trace('fail step2 DDG sld: ' + LUrl); Trace('fail step2 DDG host: ' + LUrl);
end; end;
if (not LOk) or (Length(LBytes) = 0) then if (not LOk) or (Length(LBytes) = 0) then
+17 -1
View File
@@ -16,7 +16,7 @@ interface
uses uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.StrUtils, System.Generics.Collections,
Winapi.Windows, Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs, FMX.DialogService, FMX.Dialogs, FMX.DialogService,
@@ -742,6 +742,22 @@ begin
else if ACmd = 'app/theme' then else if ACmd = 'app/theme' then
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark') FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
// Open the entry's site in the user's default browser. We restrict the
// scheme to http(s) so JS can't smuggle a file:// or other handler that
// would invoke arbitrary Windows applications.
else if ACmd = 'app/open-url' then
begin
var LUrl := GetParam('url');
if (LUrl <> '') and
(LUrl.ToLower.StartsWith('http://') or LUrl.ToLower.StartsWith('https://')) then
begin
ShellExecute(0, 'open', PChar(LUrl), nil, nil, 1); // SW_SHOWNORMAL = 1
LogLine('Opened URL: ' + LUrl);
end
else
LogLine('Refused to open non-http(s) URL: ' + LUrl);
end
// ---- Device-bound prefs (DPAPI key/value) ---------------------------- // ---- Device-bound prefs (DPAPI key/value) ----------------------------
// Used for prefs that must survive the ephemeral-port reset of the // Used for prefs that must survive the ephemeral-port reset of the
// WebView2 localStorage (rememberedUsername, etc.). // WebView2 localStorage (rememberedUsername, etc.).
Binary file not shown.
+80 -4
View File
@@ -168,6 +168,11 @@
<span>Favorites</span> <span>Favorites</span>
<span class="nav-count" id="countFav">0</span> <span class="nav-count" id="countFav">0</span>
</button> </button>
<button class="nav-item" data-view="notes">
<svg><use href="#i-edit"/></svg>
<span>Notes</span>
<span class="nav-count" id="countNotes">0</span>
</button>
</nav> </nav>
<div class="sidebar-section" data-section="folders"> <div class="sidebar-section" data-section="folders">
@@ -263,14 +268,33 @@
<svg><use href="#i-table"/></svg> <svg><use href="#i-table"/></svg>
</button> </button>
</div> </div>
<button class="icon-btn" id="cheatsheetBtn" title="Keyboard shortcuts (?)">
<span style="font-weight:700;font-size:14px">?</span>
</button>
<button class="icon-btn" id="themeBtn" title="Toggle theme"> <button class="icon-btn" id="themeBtn" title="Toggle theme">
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg> <svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
<svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg> <svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg>
</button> </button>
<button class="btn btn-primary btn-sm" id="newEntryBtn"> <div class="new-entry-wrap">
<svg><use href="#i-plus"/></svg> <button class="btn btn-primary btn-sm" id="newEntryBtn">
New <svg><use href="#i-plus"/></svg>
</button> New
</button>
<button class="btn btn-primary btn-sm new-entry-caret" id="newEntryCaretBtn"
title="Choose entry type">
<svg><use href="#i-chevron-down"/></svg>
</button>
<div class="new-entry-menu is-hidden" id="newEntryMenu">
<button class="dropdown-item" data-new-kind="login">
<svg><use href="#i-key"/></svg>
<span>New login</span>
</button>
<button class="dropdown-item" data-new-kind="note">
<svg><use href="#i-edit"/></svg>
<span>New note</span>
</button>
</div>
</div>
<div class="user-menu"> <div class="user-menu">
<button class="user-chip" id="userChip"> <button class="user-chip" id="userChip">
<span id="userName">user</span> <span id="userName">user</span>
@@ -416,6 +440,22 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="setting-row">
<span>
Auto-purge trash after
<small class="setting-hint">
Permanently delete entries that have been in
the trash for longer than this. Runs at every
unlock.
</small>
</span>
<select id="settingTrashPurge">
<option value="0">Never</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="setting-row"> <div class="setting-row">
<span> <span>
Check passwords against breach database (HIBP) Check passwords against breach database (HIBP)
@@ -723,6 +763,42 @@
</div> </div>
</div> </div>
<!-- ============================================================ -->
<!-- MODAL: Password history — list previous versions + revert -->
<!-- ============================================================ -->
<div id="historyModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel modal-panel-sm">
<header class="modal-header">
<h3 id="historyTitle">Previous versions</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body" id="historyBody"></div>
<footer class="modal-footer">
<span style="font-size:11px;color:var(--text-faint);line-height:1.4">
Up to 20 versions kept. Master-password change clears the history.
</span>
</footer>
</div>
</div>
<!-- ============================================================ -->
<!-- CHEATSHEET overlay — press '?' to discover hotkeys -->
<!-- ============================================================ -->
<div id="cheatsheetModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel cheatsheet-panel">
<header class="modal-header">
<h3>Keyboard shortcuts</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<div class="modal-body cheatsheet-body" id="cheatsheetBody"></div>
<footer class="modal-footer">
<span style="font-size:11px;color:var(--text-faint)">Press <kbd>?</kbd> anytime to reopen.</span>
</footer>
</div>
</div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- MODAL: Quick search (tray menu → fast password copy) --> <!-- MODAL: Quick search (tray menu → fast password copy) -->
<!-- ============================================================ --> <!-- ============================================================ -->
+753 -66
View File
File diff suppressed because it is too large Load Diff