diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..03c23b6 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e6cfd31..33ff0d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ?" — 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 Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT diff --git a/css/style.css b/css/style.css index c332c44..3ce49f9 100644 --- a/css/style.css +++ b/css/style.css @@ -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,"); + mask-image: url("data:image/svg+xml;utf8,"); + -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; diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index 449503a..bb792c0 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -918,6 +918,7 @@ begin 'UPDATE vault_entries SET ' + ' encrypted_password = :ep, iv = :iv, ' + ' totp_secret = :ts, totp_iv = :tiv, ' + + ' custom_fields = :cf, custom_fields_iv = :cfiv, ' + ' updated_at = CURRENT_TIMESTAMP ' + 'WHERE id = :id AND user_id = :uid'; @@ -929,6 +930,8 @@ begin LIv := LEntry.GetValue('iv', ''); LTotpSec := LEntry.GetValue('totp_secret', ''); LTotpIv := LEntry.GetValue('totp_iv', ''); + var LCf := LEntry.GetValue('custom_fields', ''); + var LCfIv := LEntry.GetValue('custom_fields_iv', ''); if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); @@ -936,18 +939,31 @@ begin LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('ep').AsString := LEncPwd; LQ.ParamByName('iv').AsString := LIv; - // TOTP fields are optional per entry — clear when empty so - // existing-NULL rows don't get stomped with empty strings. - LQ.ParamByName('ts').DataType := ftString; - LQ.ParamByName('tiv').DataType := ftString; - if LTotpSec.IsEmpty then - LQ.ParamByName('ts').Clear - else - LQ.ParamByName('ts').AsString := LTotpSec; + // TOTP / custom_fields are optional per entry — clear when + // empty so existing-NULL rows don't get stomped with empty strings. + LQ.ParamByName('ts').DataType := ftString; + LQ.ParamByName('tiv').DataType := ftString; + LQ.ParamByName('cf').DataType := ftString; + LQ.ParamByName('cfiv').DataType := ftString; + if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear + else LQ.ParamByName('ts').AsString := LTotpSec; if LTotpIv = '' then LQ.ParamByName('tiv').Clear 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; 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 LQ.Free; end; diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 09c5647..4e5d144 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -122,6 +122,21 @@ begin LObj.AddPair('icon_b64', TJSONNull.Create) else 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('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LArr.Add(LObj); @@ -143,7 +158,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LNewId: Integer; 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; begin try @@ -165,13 +181,24 @@ begin // TOTP secret + IV — optional. Empty string = no TOTP configured. LTotpSec := LBody.GetValue('totp_secret', ''); LTotpIv := LBody.GetValue('totp_iv', ''); + LKind := LBody.GetValue('kind', 'login'); + LCf := LBody.GetValue('custom_fields', ''); + LCfIv := LBody.GetValue('custom_fields_iv', ''); finally LBody.Free; 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 - 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; end; @@ -185,8 +212,10 @@ begin LQ.SQL.Text := 'INSERT INTO vault_entries ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + - ' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' + - 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + + ' 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('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -211,6 +240,11 @@ begin LQ.ParamByName('tiv').Clear else 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('c2').AsString := LNow; LQ.ExecSQL; @@ -230,6 +264,7 @@ begin LObj.AddPair('username', LUser); LObj.AddPair('folder', LFolder); LObj.AddPair('tags', LTags); + LObj.AddPair('kind', LKind); TJSONHelper.SendJSON(AResponse, LObj); end; @@ -240,7 +275,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LId: Integer; 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; begin try @@ -268,13 +304,23 @@ begin LTags := Trim(LBody.GetValue('tags', '')); LTotpSec := LBody.GetValue('totp_secret', ''); LTotpIv := LBody.GetValue('totp_iv', ''); + LKind := LBody.GetValue('kind', 'login'); + LCf := LBody.GetValue('custom_fields', ''); + LCfIv := LBody.GetValue('custom_fields_iv', ''); finally LBody.Free; end; - if (LSite = '') or (LEnc = '') then + if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login'; + + if LEnc = '' then 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; end; @@ -284,10 +330,36 @@ begin LQ := TFDQuery.Create(nil); try 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 := 'UPDATE vault_entries ' + '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 ' + 'WHERE id=:id AND user_id=:uid'; LQ.ParamByName('s').AsString := LSite; @@ -311,6 +383,11 @@ begin LQ.ParamByName('tiv').Clear else 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('id').AsInteger := LId; LQ.ParamByName('uid').AsInteger := LUserId; @@ -505,7 +582,10 @@ begin // 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. - 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 TJSONHelper.SendError(AResponse, 413, 'Icon too large'); Exit; @@ -571,6 +651,118 @@ begin TJSONHelper.SendOK(AResponse, 'Icons cleared'); 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); +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); +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 =========================================== procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; @@ -771,11 +963,13 @@ initialization // /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}. Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); + Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash); Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons); Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); + Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory); Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries', HandleGetEntries); Router.Register('POST', '/entries', HandleCreateEntry); diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index e7787f8..3ade9d7 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -189,6 +189,25 @@ begin ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + ' 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; function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; @@ -241,6 +260,17 @@ begin // sees the plaintext secret. NULL = no TOTP configured for this entry. AddColumnIfMissing('vault_entries', 'totp_secret', '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,..."). // 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. diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas index 06614e4..f1f3467 100644 --- a/delphi-backend/Source/PM.Favicon.pas +++ b/delphi-backend/Source/PM.Favicon.pas @@ -39,7 +39,8 @@ uses const 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; // DDG returns a generic placeholder for unknown domains. Bigger threshold // than 100 to avoid treating its blank globe glyph as a real icon. @@ -153,48 +154,51 @@ begin Exit; end; - // Strategy: prefer DDG (privacy-centralising) but fall back to the - // site's own /favicon.ico for domains DDG doesn't index (self-hosted - // tools, niche services, fresh subdomains, etc.). The user already - // opted into "fetch icons" so the DNS leak to one extra host they - // already visit is an acceptable trade-off for actually getting an icon. + // Strategy: prefer the SLD (brand domain) when the host has a subdomain, + // because DDG often returns a generic placeholder for chat.X.com / app.X.com + // / etc. (passes our byte threshold but looks wrong) while having the real + // brand icon under X.com. For bare 2-label hosts we go straight to step 2. LSld := ExtractSLD(LHost); LOk := False; - // 1) DDG full host. - LUrl := Format(ICON_URL_TEMPLATE, [LHost]); - if FetchOneIcon(LUrl, LBytes) then + // 1) DDG SLD first when host has a subdomain (e.g. chat.deepseek.com → + // try deepseek.com.ico first). Skipped for bare hosts. + if LSld <> '' then begin - if Length(LBytes) >= MIN_REAL_ICON_BYTES then + LUrl := Format(ICON_URL_TEMPLATE, [LSld]); + if FetchOneIcon(LUrl, LBytes) then begin - LOk := True; - Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)])); + if Length(LBytes) >= MIN_REAL_ICON_BYTES then + 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 else - Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)', - [LUrl, Length(LBytes), MIN_REAL_ICON_BYTES])); - end - else - Trace('fail step1 DDG host: ' + LUrl); + Trace('fail step1 DDG sld: ' + LUrl); + end; - // 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s). - if (not LOk) and (LSld <> '') then + // 2) DDG full host as fallback (covers brands whose subdomain has its own + // distinct icon, OR plain hosts like github.com that have no SLD step). + if not LOk then begin var LTry: TBytes; - LUrl := Format(ICON_URL_TEMPLATE, [LSld]); + LUrl := Format(ICON_URL_TEMPLATE, [LHost]); if FetchOneIcon(LUrl, LTry) then begin if Length(LTry) >= MIN_REAL_ICON_BYTES then begin 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 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 else - Trace('fail step2 DDG sld: ' + LUrl); + Trace('fail step2 DDG host: ' + LUrl); end; if (not LOk) or (Length(LBytes) = 0) then diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 5cbb906..84f4f19 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -16,7 +16,7 @@ interface uses System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.StrUtils, System.Generics.Collections, - Winapi.Windows, + Winapi.Windows, Winapi.ShellAPI, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, FMX.Dialogs, FMX.DialogService, @@ -742,6 +742,22 @@ begin else if ACmd = 'app/theme' then 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) ---------------------------- // Used for prefs that must survive the ephemeral-port reset of the // WebView2 localStorage (rememberedUsername, etc.). diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index caddabf..472f7e4 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/index.html b/index.html index cefef98..524429b 100644 --- a/index.html +++ b/index.html @@ -168,6 +168,11 @@ Favorites 0 + + - +
+ + + +
+
+ + Auto-purge trash after + + Permanently delete entries that have been in + the trash for longer than this. Runs at every + unlock. + + + +
Check passwords against breach database (HIBP) @@ -723,6 +763,42 @@
+ + + + + + + + + + diff --git a/js/app.js b/js/app.js index c1d6e38..831a262 100644 --- a/js/app.js +++ b/js/app.js @@ -281,6 +281,14 @@ const Bridge = (() => { if (!active) return; cmd('cmd://tray/notifications?enabled=' + (enabled ? '1' : '0')); }, + + // Open an http(s) URL in the user's default browser via ShellExecute. + // Delphi validates the scheme so a malformed entry can't smuggle a + // file:// or custom handler. + openUrl(url) { + if (!active) return; + cmd('cmd://app/open-url?url=' + encodeURIComponent(url)); + }, }; })(); @@ -350,6 +358,9 @@ const state = { // Show the "running in tray" balloon (and any future tray balloon). // Default ON — gates Shell_NotifyIcon NIF_INFO calls in PM.Bridge. trayNotificationsEnabled: localStorage.getItem('trayNotificationsEnabled') !== '0', + // Days after which trashed entries are permanently purged. 0 = never. + // Synced across devices because it's a user-level preference. + trashAutoPurgeDays: parseInt(localStorage.getItem('trashAutoPurgeDays') || '0') || 0, }; // ============================================================ @@ -576,6 +587,33 @@ async function decryptTotpSecret(encB64, ivB64) { return await decryptPwd(encB64, ivB64); } +// ---- Custom fields (per-entry encrypted JSON array) ---------------- +// +// Stored as: +// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON +// vault_entries.custom_fields_iv = base64 12-byte IV +// Plaintext shape: +// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...] +// +// Same crypto pipeline as encrypted_password (reuses encryptPwd / +// decryptPwd over the JSON string) so the master-pw rotation logic +// works without any special-casing — it just sees one more ciphertext +// blob per entry to re-encrypt. +async function encryptCustomFields(fieldsArray) { + if (!Array.isArray(fieldsArray) || fieldsArray.length === 0) + return { encrypted: '', iv: '' }; + return await encryptPwd(JSON.stringify(fieldsArray)); +} +async function decryptCustomFields(encB64, ivB64) { + if (!encB64 || !ivB64) return []; + const plain = await decryptPwd(encB64, ivB64); + if (plain === '[ERROR]' || !plain) return []; + try { + const arr = JSON.parse(plain); + return Array.isArray(arr) ? arr : []; + } catch (e) { return []; } +} + // ============================================================ // FAVICONS (opt-in, cached server-side as base64 data URI) // ============================================================ @@ -810,6 +848,191 @@ async function quickSearchPickEntry(entry, copyUsername) { closeQuickSearchModal(); } +// ============================================================ +// CHEATSHEET — press '?' anywhere to see all hotkeys +// ============================================================ +// +// Discovery aid. Built dynamically so adding a new hotkey only requires +// extending CHEATSHEET_GROUPS — the overlay picks it up automatically. + +const CHEATSHEET_GROUPS = [ + { + title: 'Inside the app', + items: [ + { keys: ['Ctrl', 'K'], desc: 'Command palette / quick search' }, + { keys: ['?'], desc: 'Show this cheatsheet' }, + { keys: ['Esc'], desc: 'Close modal / panel / cheatsheet' }, + { keys: ['Enter'], desc: 'Open / confirm / submit' }, + ], + }, + { + title: 'Global (Windows-only, works even when minimised)', + items: [ + { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' }, + { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' }, + { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' }, + { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' }, + ], + }, + { + title: 'Tray', + items: [ + { keys: ['Right-click tray'], desc: 'Open / Quick search… / Lock vault / Quit' }, + { keys: ['Click tray'], desc: 'Restore window' }, + ], + }, + { + title: 'On each card', + items: [ + { keys: [{ icon: 'i-globe' }], desc: 'Open the site in your default browser' }, + { keys: [{ icon: 'i-copy' }], desc: 'Copy password to the secure clipboard (auto-clears in 30s)' }, + { keys: ['Click card'], desc: 'Open the entry details / edit panel' }, + ], + }, +]; + +function renderCheatsheet() { + const body = document.getElementById('cheatsheetBody'); + body.innerHTML = ''; + CHEATSHEET_GROUPS.forEach(group => { + const section = el('section', { class: 'cheatsheet-group' }); + section.appendChild(el('h4', null, group.title)); + const list = el('div', { class: 'cheatsheet-list' }); + group.items.forEach(item => { + const row = el('div', { class: 'cheatsheet-row' }); + const kc = el('div', { class: 'cheatsheet-keys' }); + item.keys.forEach((k, i) => { + if (i > 0) kc.appendChild(el('span', { class: 'cheatsheet-plus' }, '+')); + if (k && typeof k === 'object' && k.icon) { + // SVG icon — wrap in kbd-shaped chip for visual consistency + // with the text key chips next to it. + const chip = el('span', { class: 'cheatsheet-icon-chip' }); + chip.appendChild(icon(k.icon)); + kc.appendChild(chip); + } else { + kc.appendChild(el('kbd', null, String(k))); + } + }); + row.appendChild(kc); + row.appendChild(el('div', { class: 'cheatsheet-desc' }, item.desc)); + list.appendChild(row); + }); + section.appendChild(list); + body.appendChild(section); + }); +} + +// ============================================================ +// PASSWORD HISTORY — open the modal, decrypt previous versions, +// optionally revert one into the current field. +// ============================================================ + +async function openHistoryModal(entryId) { + const modal = document.getElementById('historyModal'); + const body = document.getElementById('historyBody'); + body.innerHTML = ''; + body.appendChild(el('div', { class: 'history-loading' }, 'Loading…')); + modal.classList.remove('is-hidden'); + + let rows; + try { + rows = await fetch(API + '/entries/' + entryId + '/history', { + headers: authHeaders(), + }).then(r => r.ok ? r.json() : []); + } catch (e) { + rows = []; + } + body.innerHTML = ''; + if (!rows.length) { + body.appendChild(el('p', { class: 'history-empty' }, + 'No previous versions yet — they accumulate on each save.')); + return; + } + + // Decrypt each row's stored ciphertext with the CURRENT vault key + // (master-pw change wipes the history, so the key always works). + const list = el('ul', { class: 'history-list' }); + for (const row of rows) { + const li = el('li', { class: 'history-row' }); + const meta = el('div', { class: 'history-meta' }); + meta.appendChild(el('span', { class: 'history-date' }, + formatDateShort(row.changed_at) + ' · ' + row.changed_at.slice(11, 16))); + let plain = ''; + try { + plain = await decryptPwd(row.encrypted_password, row.iv); + } catch (_) { plain = '[ERROR]'; } + if (plain === '[ERROR]') plain = ''; + + const preview = el('div', { class: 'history-preview' }); + const isNote = (row.kind === 'note'); + const snippet = isNote + ? (plain.replace(/\s+/g, ' ').slice(0, 80) + + (plain.length > 80 ? '…' : '')) + : '•'.repeat(Math.max(plain.length, 8)); + const valueSpan = el('span', { class: 'history-value' }, snippet); + preview.appendChild(valueSpan); + let revealed = false; + if (!isNote) { + const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Show / hide' }); + eye.appendChild(icon('i-eye')); + eye.addEventListener('click', () => { + revealed = !revealed; + valueSpan.textContent = revealed ? plain + : '•'.repeat(Math.max(plain.length, 8)); + }); + preview.appendChild(eye); + } + const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Copy' }); + copy.appendChild(icon('i-copy')); + copy.addEventListener('click', () => { + if (Bridge.active) Bridge.copySecure(plain, 30000); + else { try { navigator.clipboard.writeText(plain); } catch (_) {} } + toast('Copied · clears in 30s'); + }); + preview.appendChild(copy); + + const revert = el('button', { class: 'btn btn-ghost btn-xs', type: 'button' }); + revert.appendChild(icon('i-rotate-ccw')); + revert.appendChild(document.createTextNode(' Revert')); + revert.addEventListener('click', () => { + const target = isNote + ? document.getElementById('soNoteBody') + : document.getElementById('soPassword'); + if (target) { + target.value = plain; + target.dispatchEvent(new Event('input', { bubbles: true })); + soDirtyCheck(); + toast('Restored — click Save to commit', 'warning'); + } + closeHistoryModal(); + }); + + const actions = el('div', { class: 'history-actions' }); + actions.appendChild(revert); + + li.appendChild(meta); + li.appendChild(preview); + li.appendChild(actions); + list.appendChild(li); + } + body.appendChild(list); +} + +function closeHistoryModal() { + document.getElementById('historyModal').classList.add('is-hidden'); +} + +function openCheatsheet() { + renderCheatsheet(); + document.getElementById('cheatsheetModal').classList.remove('is-hidden'); +} + +function closeCheatsheet() { + document.getElementById('cheatsheetModal').classList.add('is-hidden'); +} + function openQuickSearchModal(hideAfter, forFill) { const modal = document.getElementById('quickSearchModal'); const input = document.getElementById('quickSearchInput'); @@ -1513,6 +1736,7 @@ function filteredEntries() { } else { list = state.entries; if (state.view === 'favorites') list = list.filter(e => e.favorite); + else if (state.view === 'notes') list = list.filter(e => e.kind === 'note'); else if (state.view.startsWith('folder:')) { const f = state.view.slice(7); // 'folder:All' is now the "(no folder)" pseudo-entry → filter @@ -1555,6 +1779,7 @@ function allTags() { function viewTitle() { if (state.view === 'all') return 'All items'; if (state.view === 'favorites') return 'Favorites'; + if (state.view === 'notes') return 'Notes'; if (state.view === 'trash') return 'Trash'; if (state.view === 'authenticator') return 'Authenticator'; if (state.view === 'health') return 'Vault health'; @@ -1576,6 +1801,9 @@ function renderSidebar() { // counts $('#countAll').textContent = state.entries.length; $('#countFav').textContent = state.entries.filter(e => e.favorite).length; + const noteCount = state.entries.filter(e => e.kind === 'note').length; + const countNotes = document.getElementById('countNotes'); + if (countNotes) countNotes.textContent = noteCount || ''; // Prefer the server-side count (always up-to-date even if user never // navigated to Trash this session) ; fall back to local array length. const trashN = state.trashedCount || state.trashed.length || 0; @@ -1935,7 +2163,11 @@ function entryAgeDays(e) { async function computeHealthCache() { const weak = [], old = [], pwned = []; const byPwd = new Map(); // plaintext → [entries] + // Notes have no password to weigh — their encrypted_password is just + // the free-text body. Skipping them avoids polluting the "weak / reused" + // categories with note content. for (const e of state.entries) { + if ((e.kind || 'login') !== 'login') continue; const ageD = entryAgeDays(e); if (ageD > HEALTH_OLD_DAYS) old.push({ entry: e, ageDays: ageD }); @@ -2216,6 +2448,35 @@ function entryDisplayName(e) { return t || e.site || ''; } +// Decide if entry.site can be opened in a browser. Accepts: +// "https://github.com/login" → kept as-is +// "github.com" → prefixed with https:// +// "Gitea" / "my note" → returns '' (no dot or not a hostname) +// Returns the canonical URL to pass to ShellExecute, or '' if not openable. +function entryOpenUrl(site) { + if (!site) return ''; + let s = String(site).trim(); + // Already-scheme'd: only allow http(s). + if (/^https?:\/\//i.test(s)) return s; + if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return ''; // ftp://, file://, mailto:… + // Plain hostname or hostname/path. Require at least one dot and a + // letter TLD ≥ 2 chars to avoid opening "Gitea" or "Brand name". + const host = s.split('/')[0].split(':')[0].toLowerCase(); + if (!host.includes('.')) return ''; + if (!/\.[a-z]{2,}$/i.test(host)) return ''; + return 'https://' + s; +} + +function entryOpenInBrowser(url) { + if (!url) return; + if (Bridge.active && typeof Bridge.openUrl === 'function') { + Bridge.openUrl(url); + } else { + // PHP frontend / fallback: regular window.open. + try { window.open(url, '_blank', 'noopener'); } catch (e) {} + } +} + // Display label for a folder value. "All" is the default "uncategorized" // bucket; we relabel it so users don't see two "All" entries in folder // pickers (the top nav "All items" also says "All"). @@ -2266,14 +2527,20 @@ function buildKebabMenu(entry) { wrap.appendChild(btn); const menu = el('div', { class: 'entry-kebab-menu' }); + const isNote = entry.kind === 'note'; const items = [ { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, - { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, - { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, + { lbl: isNote ? 'Copy note content' : 'Copy password', + ic: 'i-copy', fn: () => copyPassword(entry) }, + ]; + // Username is login-only — hide the menu item for notes (no username field). + if (!isNote) + items.push({ lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }); + items.push( { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, { lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) }, { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, - ]; + ); items.forEach(it => { const mi = el('button', { class: 'kebab-item' + (it.danger ? ' is-danger' : ''), @@ -2407,17 +2674,47 @@ function renderCard(e) { } card.appendChild(head); - // password row (placeholder dots, click reveals via slide-over) - const pwRow = el('div', { class: 'entry-pw-row' }); - pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); - const copyBtn = el('button', { - class: 'icon-btn icon-btn-sm', - title: 'Copy password', - on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, - }); - copyBtn.appendChild(icon('i-copy')); - pwRow.appendChild(copyBtn); - card.appendChild(pwRow); + // Body row: password placeholder for logins, content snippet for notes. + const isNoteCard = (e.kind === 'note'); + if (isNoteCard) { + // Single placeholder line — the body is encrypted client-side, + // we don't decrypt it eagerly for every card. + const noteRow = el('div', { class: 'entry-note-row' }); + noteRow.appendChild(el('span', { class: 'entry-note-placeholder' }, + 'Encrypted note · click to read')); + // Same crypto pipeline as a password — copyPassword decrypts the + // body and drops it in the secure clipboard. + const copyBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy note content', + on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, + }); + copyBtn.appendChild(icon('i-copy')); + noteRow.appendChild(copyBtn); + card.appendChild(noteRow); + } else { + const pwRow = el('div', { class: 'entry-pw-row' }); + pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); + const copyBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy password', + on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, + }); + copyBtn.appendChild(icon('i-copy')); + pwRow.appendChild(copyBtn); + // Open URL — only when the site looks like a real http(s) target. + const openUrl = entryOpenUrl(e.site); + if (openUrl) { + const openBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Open ' + openUrl + ' in browser', + on: { click: ev => { ev.stopPropagation(); entryOpenInBrowser(openUrl); } }, + }); + openBtn.appendChild(icon('i-globe')); + pwRow.appendChild(openBtn); + } + card.appendChild(pwRow); + } // meta chips: folder + first 2 tags. "All" is the default "uncategorized" // bucket and shouldn't be shown as a chip (visually duplicates "All items"). @@ -2545,6 +2842,37 @@ function renderPagination(total, totalPages) { }); wrap.appendChild(sizeSel); + // Inline sort dropdown — same options as Settings → Appearance "Sort + // entries by" but accessible without opening the settings panel. + const sortSel = el('select', { + class: 'pagination-size pagination-sort', + on: { change: ev => { + const [by, dir] = ev.target.value.split(':'); + state.sortBy = by; + state.sortDir = dir; + state.currentPage = 1; + localStorage.setItem('sortBy', state.sortBy); + localStorage.setItem('sortDir', state.sortDir); + saveServerSettings(); + render(); + } }, + }); + const SORT_OPTIONS = [ + ['name:asc', 'Name A → Z'], + ['name:desc', 'Name Z → A'], + ['updated:desc', 'Recently updated'], + ['updated:asc', 'Oldest updated'], + ['created:desc', 'Recently created'], + ['created:asc', 'Oldest created'], + ]; + const cur = state.sortBy + ':' + state.sortDir; + SORT_OPTIONS.forEach(([v, label]) => { + const opt = el('option', { value: v }, label); + if (v === cur) opt.selected = true; + sortSel.appendChild(opt); + }); + wrap.appendChild(sortSel); + return wrap; } @@ -2659,24 +2987,34 @@ function renderTableRow(e) { td.appendChild(avatar); const nameWrap = el('span', { class: 'cell-name-wrap' }); nameWrap.appendChild(el('b', null, entryDisplayName(e))); + if (e.kind === 'note') + nameWrap.appendChild(el('span', { class: 'kind-badge', title: 'Secure note' }, 'note')); if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★')); td.appendChild(nameWrap); break; } case 'site': - td = el('td', { class: 'col-site' }, e.site || ''); + td = el('td', { class: 'col-site' }, + e.kind === 'note' ? '' : (e.site || '')); break; case 'user': { td = el('td', { class: 'col-user' }); - td.appendChild(el('span', null, displayUsername(e.username))); - if (e.username) { - const btn = el('button', { - class: 'icon-btn icon-btn-sm', - title: 'Copy username', - on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, - }); - btn.appendChild(icon('i-copy')); - td.appendChild(btn); + if (e.kind === 'note') { + // Notes have no username — show a faint "Encrypted note" + // placeholder instead of the "—" mask the login path uses. + td.appendChild(el('span', { class: 'col-user-note' }, + 'Encrypted note')); + } else { + td.appendChild(el('span', null, displayUsername(e.username))); + if (e.username) { + const btn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy username', + on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, + }); + btn.appendChild(icon('i-copy')); + td.appendChild(btn); + } } break; } @@ -2692,7 +3030,7 @@ function renderTableRow(e) { td = el('td', { class: 'col-actions' }); const pwBtn = el('button', { class: 'icon-btn icon-btn-sm', - title: 'Copy password', + title: e.kind === 'note' ? 'Copy note content' : 'Copy password', on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, }); pwBtn.appendChild(icon('i-copy')); @@ -2954,12 +3292,16 @@ async function openSlideOver(id, opts) { if (!isNew && !e) return; state.selectedId = isNew ? null : id; - // Title with a clear "what mode am I in?" prefix. Plain text so the - // existing .slideover-header h3 ellipsis / overflow rules still work. + // Resolve kind early: opts.kind for new entries (login default), entry's + // own kind for existing. Drives the field layout below. + const kind = isNew ? (opts.kind || 'login') : (e.kind || 'login'); + const isNote = (kind === 'note'); + // Title prefix reflects mode + kind (note vs login). const titleEl = $('#slideoverTitle'); + const kindLabel = isNote ? 'note' : 'entry'; titleEl.textContent = isNew - ? '+ New entry' - : 'Edit · ' + entryDisplayName(e); + ? ('+ New ' + kindLabel) + : ('Edit · ' + entryDisplayName(e)); titleEl.classList.toggle('is-new-mode', isNew); titleEl.classList.toggle('is-edit-mode', !isNew); const body = $('#slideoverBody'); @@ -2967,6 +3309,7 @@ async function openSlideOver(id, opts) { let plain = ''; let plainTotp = ''; + let plainCustom = []; if (!isNew) { plain = await decryptPwd(e.encrypted_password, e.iv); // Decrypt TOTP secret if present. Empty string when no TOTP configured @@ -2975,6 +3318,11 @@ async function openSlideOver(id, opts) { plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv); if (plainTotp === '[ERROR]') plainTotp = ''; } + // Custom fields: same crypto pipeline, but the plaintext is a JSON + // array of {label, value, is_secret}. + if (e.custom_fields && e.custom_fields_iv) { + plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv); + } } // Track original values so we can detect "dirty". For new entries the @@ -2983,36 +3331,61 @@ async function openSlideOver(id, opts) { ? state.view.slice(7) : 'All'; soState = { id: isNew ? null : e.id, + kind: kind, original: { - site: isNew ? (opts.presetSite || '') : e.site, + site: isNew ? (opts.presetSite || '') : (e.site || ''), title: isNew ? (opts.presetTitle || '') : (e.title || ''), username: isNew ? '' : (e.username || ''), - password: plain, + password: plain, // for notes this holds the note body folder: isNew ? defaultFolder : (e.folder || 'All'), tags: isNew ? '' : parseTags(e.tags).join(','), totp: plainTotp, }, tags: isNew ? [] : parseTags(e.tags), + // Working copy of the custom-fields array — mutated in place by + // buildCustomFieldRow handlers. The serialized JSON of this array + // at Save time is what gets encrypted into custom_fields/iv. + customFields: plainCustom.map(f => ({ + label: f.label || '', value: f.value || '', + is_secret: !!f.is_secret, + })), + originalCustomJson: JSON.stringify(plainCustom), originalEncrypted: isNew ? null : e.encrypted_password, originalIV: isNew ? null : e.iv, originalTotpEncrypted: isNew ? null : e.totp_secret, originalTotpIV: isNew ? null : e.totp_iv, + originalCustomEncrypted: isNew ? null : (e.custom_fields || null), + originalCustomIV: isNew ? null : (e.custom_fields_iv || null), }; - // Icon field needs SOMETHING to compute initials/fallback. For new - // entries we pass a synthetic placeholder. - const eForIcon = isNew - ? { id: null, icon_b64: null, site: soState.original.site, - title: soState.original.title } - : e; - body.appendChild(soIconField(eForIcon)); - body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title)); - body.appendChild(soEditableField('Site', 'soSite', soState.original.site)); - body.appendChild(soEditableField('Username', 'soUsername', soState.original.username)); - body.appendChild(soPasswordField(plain)); - body.appendChild(soTotpField(plainTotp)); - body.appendChild(soFolderField(soState.original.folder)); - body.appendChild(soTagsField()); + if (isNote) { + // Notes: minimal layout — name + multiline body + folder + tags. + // No icon (covered by sidebar icon), no site/user/totp. + body.appendChild(soEditableField('Title', 'soTitle', soState.original.title)); + body.appendChild(soNoteBodyField(plain)); + const hist = soHistoryButton(); + if (hist) body.appendChild(hist); + body.appendChild(soCustomFieldsField()); + body.appendChild(soFolderField(soState.original.folder)); + body.appendChild(soTagsField()); + } else { + // Login (existing layout). + const eForIcon = isNew + ? { id: null, icon_b64: null, site: soState.original.site, + title: soState.original.title } + : e; + body.appendChild(soIconField(eForIcon)); + body.appendChild(soEditableField('Display name', 'soTitle', soState.original.title)); + body.appendChild(soEditableField('Site', 'soSite', soState.original.site)); + body.appendChild(soEditableField('Username', 'soUsername', soState.original.username)); + body.appendChild(soPasswordField(plain)); + const histLogin = soHistoryButton(); + if (histLogin) body.appendChild(histLogin); + body.appendChild(soTotpField(plainTotp)); + body.appendChild(soCustomFieldsField()); + body.appendChild(soFolderField(soState.original.folder)); + body.appendChild(soTagsField()); + } // Action row — Save button is hidden until dirty. No Delete here: // the quick-X on each card handles deletion (avoids duplication). @@ -3026,8 +3399,9 @@ async function openSlideOver(id, opts) { actions.appendChild(saveBtn); body.appendChild(actions); - // Wire change detection - ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { + // Wire change detection (selectors absent for notes are ignored). + ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder', + '#soNoteBody'].forEach(sel => { const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); if (el) el.addEventListener('change', soDirtyCheck); }); @@ -3035,12 +3409,14 @@ async function openSlideOver(id, opts) { $('#slideover').classList.add('is-open'); // New entries: Save visible from the start so the action is obvious, - // and auto-focus the Site field (most important pivot field). + // and focus the most relevant pivot field (Title for notes, Site for logins). if (isNew) { const save = document.getElementById('soSaveBtn'); if (save) save.style.display = ''; setTimeout(() => { - const f = document.getElementById('soSite'); + const f = isNote + ? document.getElementById('soTitle') + : document.getElementById('soSite'); if (f) f.focus(); }, 50); } @@ -3048,6 +3424,163 @@ async function openSlideOver(id, opts) { renderGrid(); } +// "Show history" launcher — placed below the password field for logins, +// below the note body for notes. Reads soState.id so it works in edit +// mode only (new entries have no history yet). +function soHistoryButton() { + if (!soState || soState.id == null) return null; + const wrap = el('div', { class: 'slideover-field so-history-wrap' }); + const btn = el('button', { + class: 'btn btn-ghost btn-xs', type: 'button', + }); + btn.appendChild(icon('i-rotate-ccw')); + btn.appendChild(document.createTextNode(' Show previous versions')); + btn.addEventListener('click', () => openHistoryModal(soState.id)); + wrap.appendChild(btn); + return wrap; +} + +// Custom fields editor — dynamic list of {label, value, is_secret} rows. +// Mutates soState.customFields in place; on change calls soDirtyCheck so +// the Save button surfaces. The whole array is re-encrypted on Save (no +// per-row IVs to keep simple). +function soCustomFieldsField() { + const wrap = el('div', { class: 'slideover-field so-custom-wrap' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, + 'Custom fields')); + const list = el('div', { class: 'so-custom-list', id: 'soCustomList' }); + wrap.appendChild(list); + + function renderRows() { + list.innerHTML = ''; + (soState.customFields || []).forEach((f, idx) => { + list.appendChild(buildCustomFieldRow(f, idx, renderRows)); + }); + } + renderRows(); + + const addWrap = el('div', { class: 'so-custom-add' }); + const addBtn = el('button', { class: 'btn btn-ghost btn-sm', type: 'button' }); + addBtn.appendChild(icon('i-plus')); + addBtn.appendChild(document.createTextNode(' Add field')); + addBtn.addEventListener('click', ev => { + // stopPropagation — the document-level "click outside slideover" + // listener would otherwise see this click as outside (the button + // isn't in any of the allow-listed containers) and close the panel. + // Same pattern as #newEntryBtn, health-dashboard "Fix", etc. + ev.stopPropagation(); + soState.customFields = soState.customFields || []; + soState.customFields.push({ label: '', value: '', is_secret: false }); + renderRows(); + soDirtyCheck(); + setTimeout(() => { + const inputs = list.querySelectorAll('.so-custom-label'); + const last = inputs[inputs.length - 1]; + if (last) last.focus(); + }, 0); + }); + addWrap.appendChild(addBtn); + wrap.appendChild(addWrap); + return wrap; +} + +function buildCustomFieldRow(field, idx, rerender) { + const row = el('div', { class: 'so-custom-row' }); + const labelInput = el('input', { + type: 'text', class: 'so-input so-custom-label', + placeholder: 'Label (e.g. PIN, Account #)', + }); + labelInput.value = field.label || ''; + labelInput.addEventListener('input', () => { + field.label = labelInput.value; + soDirtyCheck(); + }); + + const valueInput = el('input', { + type: field.is_secret ? 'password' : 'text', + class: 'so-input so-custom-value', + placeholder: 'Value', + }); + valueInput.value = field.value || ''; + valueInput.addEventListener('input', () => { + field.value = valueInput.value; + soDirtyCheck(); + }); + + // Reveal eye — only meaningful for secret fields. + const eye = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Show / hide' }); + eye.appendChild(icon('i-eye')); + // All button handlers below stopPropagation — see CLAUDE.md + // "Click-outside-slideover bug" for why. + eye.addEventListener('click', ev => { + ev.stopPropagation(); + if (!field.is_secret) return; + valueInput.type = valueInput.type === 'password' ? 'text' : 'password'; + }); + if (!field.is_secret) eye.style.visibility = 'hidden'; + + // Secret-flag toggle (chip-style) — flips both the storage flag and + // the visible input type. + const secretBtn = el('button', { + class: 'so-custom-secret-toggle' + (field.is_secret ? ' is-on' : ''), + type: 'button', + title: field.is_secret ? 'Secret field — value hidden' : 'Public field', + }, field.is_secret ? '🔒' : '👁'); + secretBtn.addEventListener('click', ev => { + ev.stopPropagation(); + field.is_secret = !field.is_secret; + soDirtyCheck(); + rerender(); + }); + + const copyBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Copy value' }); + copyBtn.appendChild(icon('i-copy')); + copyBtn.addEventListener('click', ev => { + ev.stopPropagation(); + const v = field.value || ''; + if (!v) return; + if (Bridge.active) Bridge.copySecure(v, 30000); + else { try { navigator.clipboard.writeText(v); } catch (_) {} } + toast('Copied · clears in 30s'); + }); + + const delBtn = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', + title: 'Remove field' }); + delBtn.appendChild(icon('i-x')); + delBtn.addEventListener('click', ev => { + ev.stopPropagation(); + soState.customFields.splice(idx, 1); + rerender(); + soDirtyCheck(); + }); + + row.appendChild(labelInput); + row.appendChild(valueInput); + row.appendChild(eye); + row.appendChild(secretBtn); + row.appendChild(copyBtn); + row.appendChild(delBtn); + return row; +} + +// Multiline note body. Maps to soState.original.password and the +// encrypted_password+iv columns (same crypto pipeline as login passwords). +function soNoteBodyField(value) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Note')); + const ta = el('textarea', { + id: 'soNoteBody', + class: 'so-input so-note-body', + rows: 12, + placeholder: 'Encrypted with your vault key. Nothing leaves your device.', + }); + ta.value = value || ''; + wrap.appendChild(ta); + return wrap; +} + function soEditableField(label, id, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); @@ -3062,7 +3595,7 @@ function soEditableField(label, id, value) { // base64 data URI via the same POST /entries/{id}/icon endpoint as the // auto-fetched icons — the JS render path doesn't care which source it // came from. -const ICON_MAX_BYTES = 64 * 1024; // matches server-side cap +const ICON_MAX_BYTES = 256 * 1024; // matches server-side cap (256 KB) function soIconField(entry) { const wrap = el('div', { class: 'slideover-field so-icon-field' }); @@ -3101,7 +3634,7 @@ function soIconField(entry) { return; } if (f.size > ICON_MAX_BYTES) { - toast('Icon too large (max 64 KB)', 'error'); + toast('Icon too large (max 256 KB)', 'error'); return; } const reader = new FileReader(); @@ -3398,15 +3931,21 @@ function soDirtyCheck() { if (btn) btn.style.display = ''; return; } + // For notes, soNoteBody plays the role of the password (secret body). + // Selectors that don't exist for the current kind read as empty strings, + // which match the empty originals → never flag dirty. const cur = { title: ($('#soTitle') || {}).value || '', site: ($('#soSite') || {}).value || '', username: ($('#soUsername') || {}).value || '', - password: ($('#soPassword') || {}).value || '', + password: (($('#soPassword') || $('#soNoteBody')) || {}).value || '', folder: ($('#soFolder') || {}).value || '', totp: ($('#soTotpSecret') || {}).value || '', tags: soState.tags.join(','), }; + // Stringify current custom-fields array — same JSON encoding used at + // load time so the comparison is exact. + const curCustom = JSON.stringify(soState.customFields || []); const dirty = cur.title !== soState.original.title || cur.site !== soState.original.site || @@ -3414,7 +3953,8 @@ function soDirtyCheck() { cur.password !== soState.original.password || cur.folder !== soState.original.folder || cur.totp !== soState.original.totp || - cur.tags !== soState.original.tags; + cur.tags !== soState.original.tags || + curCustom !== (soState.originalCustomJson || '[]'); const btn = $('#soSaveBtn'); if (btn) btn.style.display = dirty ? '' : 'none'; } @@ -3428,13 +3968,24 @@ async function soSave() { soState.tags.push(pendingTag); $('#soTagsField').value = ''; } + const kind = soState.kind || 'login'; + const isNote = (kind === 'note'); const title = ($('#soTitle') || {}).value || ''; - const site = $('#soSite').value.trim(); - const user = $('#soUsername').value.trim(); - const pwd = $('#soPassword').value; - const fold = $('#soFolder').value; - const totp = (($('#soTotpSecret') || {}).value || '').trim(); - if (!site || !pwd) return toast('Site and password required', 'error'); + const site = isNote ? '' : ($('#soSite').value.trim()); + const user = isNote ? '' : ($('#soUsername').value.trim()); + // For notes the textarea body is the encrypted payload (reuses + // encrypted_password/iv column pair). + const pwd = isNote + ? (($('#soNoteBody') || {}).value || '') + : ($('#soPassword').value); + const fold = ($('#soFolder') || {}).value || 'All'; + const totp = isNote ? '' : (($('#soTotpSecret') || {}).value || '').trim(); + if (isNote) { + if (!title.trim()) return toast('Title required', 'error'); + if (!pwd) return toast('Note body required', 'error'); + } else { + if (!site || !pwd) return toast('Site and password required', 'error'); + } const isNew = (soState.id == null); @@ -3467,11 +4018,38 @@ async function soSave() { } } + // Custom fields: drop rows with an empty label (treat as removed). + // Re-encrypt only when the array actually changed; otherwise reuse the + // stored ciphertext so the row's updated_at doesn't get bumped for nothing. + const cleanCustom = (soState.customFields || []) + .filter(f => (f.label || '').trim() !== '') + .map(f => ({ + label: (f.label || '').trim(), + value: f.value || '', + is_secret: !!f.is_secret, + })); + let cfEnc = '', cfIv = ''; + const curCustomJson = JSON.stringify(cleanCustom); + if (cleanCustom.length === 0) { + // Empty array → send empty strings → server stores NULL. + cfEnc = ''; cfIv = ''; + } else if (!isNew && curCustomJson === soState.originalCustomJson && + soState.originalCustomEncrypted) { + cfEnc = soState.originalCustomEncrypted; + cfIv = soState.originalCustomIV; + } else { + const e = await encryptCustomFields(cleanCustom); + cfEnc = e.encrypted; + cfIv = e.iv; + } + const body = JSON.stringify({ site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, + custom_fields: cfEnc, custom_fields_iv: cfIv, folder: fold, tags: soState.tags.join(','), + kind, }); try { @@ -3503,8 +4081,9 @@ async function soSave() { render(); if (isNew && targetId) { flashEntry(targetId); - // Auto-fetch favicon for the new entry if the user opted in. - if (state.faviconsEnabled && updated) ensureEntryFavicon(updated); + // Auto-fetch favicon only for logins (notes don't have a site). + if (state.faviconsEnabled && updated && (updated.kind || 'login') === 'login') + ensureEntryFavicon(updated); } } catch (err) { toast(err.message, 'error'); } } @@ -3818,7 +4397,7 @@ async function duplicateEntry(entry) { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site: entry.site, + site: entry.site || '', title: entryDisplayName(entry) + ' (copy)', username: entry.username || '', encrypted_password: entry.encrypted_password, @@ -3827,6 +4406,15 @@ async function duplicateEntry(entry) { tags: entry.tags || '', totp_secret: entry.totp_secret || '', totp_iv: entry.totp_iv || '', + // Preserve the source kind — without this notes were + // sent without 'kind', the server defaulted to 'login', + // then rejected the empty site as "Site required". + kind: entry.kind || 'login', + // Carry the encrypted custom-fields blob across as-is; it's + // already encrypted with the current vault key so the copy + // decrypts the same way as the source. + custom_fields: entry.custom_fields || '', + custom_fields_iv: entry.custom_fields_iv || '', }), }); await loadEntries(); @@ -4063,11 +4651,15 @@ function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } function paletteCommands() { return [ { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openSlideOver(null); } }, + { id: 'new-note', label: 'New note', icon: 'i-edit', run: () => { closePalette(); openSlideOver(null, { kind: 'note' }); } }, + { id: 'shortcuts', label: 'Show keyboard shortcuts (?)', icon: 'i-command', + run: () => { closePalette(); openCheatsheet(); } }, { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, + { id: 'notes', label: 'Show notes', icon: 'i-edit', run: () => { closePalette(); setView('notes'); } }, { id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } }, ]; } @@ -4811,12 +5403,28 @@ async function doChangeMasterPassword() { totpIv = t.iv; } } + // Custom fields — same dance: decrypt with OLD key, encrypt + // with NEW key, send fresh ciphertext. + let cfEnc = '', cfIv = ''; + if (e.custom_fields && e.custom_fields_iv) { + state.cryptoKey = oldKey; + const plainCf = await decryptCustomFields( + e.custom_fields, e.custom_fields_iv); + state.cryptoKey = newKey; + if (plainCf.length > 0) { + const c = await encryptCustomFields(plainCf); + cfEnc = c.encrypted; + cfIv = c.iv; + } + } encrypted.push({ id: e.id, encrypted_password: re.encrypted, iv: re.iv, totp_secret: totpEnc, totp_iv: totpIv, + custom_fields: cfEnc, + custom_fields_iv: cfIv, }); } finally { state.cryptoKey = oldKey; // restore until server confirms @@ -5664,6 +6272,7 @@ function openSettings() { } $('#settingTrayNotif').checked = state.trayNotificationsEnabled !== false; $('#settingTrayNotifRow').style.display = Bridge.active ? '' : 'none'; + $('#settingTrashPurge').value = String(state.trashAutoPurgeDays || 0); $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); @@ -5794,6 +6403,32 @@ async function enterApp() { // settings_json may have flipped it). if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') Bridge.setTrayNotifications(state.trayNotificationsEnabled !== false); + // Trash auto-purge (configured via Settings → Security). Fire-and- + // forget: failures are silent — the user can run "Empty trash" manually. + autoPurgeTrashIfNeeded(); +} + +async function autoPurgeTrashIfNeeded() { + const days = parseInt(state.trashAutoPurgeDays, 10) || 0; + if (days <= 0) return; + try { + const r = await fetch(API + '/entries/trash/old?days=' + days, { + method: 'DELETE', + headers: authHeaders(), + }); + if (!r.ok) return; + const body = await r.json().catch(() => ({})); + const n = parseInt(body.purged, 10) || 0; + if (n > 0) { + toast(n + ' old entr' + (n === 1 ? 'y' : 'ies') + + ' permanently removed from trash'); + // Refresh the count so the sidebar reflects the purge. + await loadEntryCounts(); + render(); + } + } catch (e) { + // Silent — user can manually empty trash if they care. + } } // ============================================================ @@ -5815,6 +6450,7 @@ const SYNCED_SETTING_KEYS = [ 'sidebarCollapsed', 'faviconsEnabled', 'trayNotificationsEnabled', + 'trashAutoPurgeDays', ]; function applySidebarCollapsed() { @@ -5865,6 +6501,9 @@ async function loadServerSettings() { if (Bridge.active && typeof Bridge.setTrayNotifications === 'function') Bridge.setTrayNotifications(v); break; + case 'trashAutoPurgeDays': + localStorage.setItem('trashAutoPurgeDays', String(v)); + break; } }); // Apply visual settings immediately. @@ -6036,11 +6675,28 @@ async function init() { saveServerSettings(); }); $('#newEntryBtn').addEventListener('click', ev => { - // Stop bubbling — the document-level "click outside slideover" - // handler would otherwise close the panel we just opened in the - // same click event (same fix as the health dashboard Fix button). ev.stopPropagation(); - openSlideOver(null); + // Default click → new login (preserves the muscle memory of the + // existing button). The chevron next to it opens the kind picker. + $('#newEntryMenu').classList.add('is-hidden'); + openSlideOver(null, { kind: 'login' }); + }); + $('#newEntryCaretBtn').addEventListener('click', ev => { + ev.stopPropagation(); + $('#newEntryMenu').classList.toggle('is-hidden'); + }); + $$('#newEntryMenu [data-new-kind]').forEach(b => { + b.addEventListener('click', ev => { + ev.stopPropagation(); + const kind = b.getAttribute('data-new-kind') || 'login'; + $('#newEntryMenu').classList.add('is-hidden'); + openSlideOver(null, { kind }); + }); + }); + // Close the dropdown on any other click. + document.addEventListener('click', e => { + if (!e.target.closest('.new-entry-wrap')) + $('#newEntryMenu').classList.add('is-hidden'); }); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', lockVault); @@ -6296,6 +6952,14 @@ async function init() { ? 'Tray notifications enabled' : 'Tray notifications disabled'); }); + $('#settingTrashPurge').addEventListener('change', e => { + const n = parseInt(e.target.value, 10) || 0; + state.trashAutoPurgeDays = n; + localStorage.setItem('trashAutoPurgeDays', String(n)); + saveServerSettings(); + if (n === 0) toast('Trash auto-purge disabled'); + else toast('Trash will auto-purge after ' + n + ' days (next unlock)'); + }); $('#settingFavicons').addEventListener('change', e => { state.faviconsEnabled = e.target.checked; localStorage.setItem('faviconsEnabled', state.faviconsEnabled ? '1' : '0'); @@ -6470,6 +7134,15 @@ async function init() { const visible = filteredEntries(); visible.forEach(en => state.checked.add(en.id)); renderGrid(); + } else if (e.key === '?' && + !/^(INPUT|TEXTAREA|SELECT)$/.test((e.target||{}).tagName) && + !e.ctrlKey && !e.metaKey && !e.altKey) { + // '?' anywhere outside an input shows the hotkey cheatsheet. + // Skipped while the auth screen is up — discovery is for the + // unlocked workflow. + if ($('#appShell').classList.contains('is-hidden')) return; + e.preventDefault(); + openCheatsheet(); } else if (e.key === 'Escape') { // Close in priority order: confirm first (most modal-y) then others if (!$('#confirmModal').classList.contains('is-hidden')) { @@ -6480,6 +7153,14 @@ async function init() { closeChangeMasterModal(); return; } + if (!$('#cheatsheetModal').classList.contains('is-hidden')) { + closeCheatsheet(); + return; + } + if (!$('#historyModal').classList.contains('is-hidden')) { + closeHistoryModal(); + return; + } closePalette(); closeSlideOver(); closeEntryModal(); @@ -6495,6 +7176,12 @@ async function init() { }); $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); + $$('#cheatsheetModal [data-close]').forEach(b => + b.addEventListener('click', closeCheatsheet)); + $$('#historyModal [data-close]').forEach(b => + b.addEventListener('click', closeHistoryModal)); + const cheatBtn = document.getElementById('cheatsheetBtn'); + if (cheatBtn) cheatBtn.addEventListener('click', openCheatsheet); // Quick-search modal (tray menu) — keyboard nav + close const qsInput = document.getElementById('quickSearchInput');