diff --git a/CLAUDE.md b/CLAUDE.md index e7b8ff3..cb64b65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -358,7 +358,9 @@ Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" 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`). +username, folder, tags, kind, template`). `template` est le sous-type +(ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le +label de card/table — vide pour login/note génériques. 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 @@ -375,7 +377,13 @@ sur certaines actions : `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) + (même vault key, pas besoin de re-chiffrer). Pour `icon_b64` : + inclure dans le body POST. Pour attachments : boucle séparée après + create qui GET source attachments + POST sur le nouveau id. +7. **`moveEntryToFolder`** (js/app.js) — PUT partiel sans ces champs = + wipe silencieux (TOTP perdu, custom_fields perdus, et notes rejetées + en 400 "Site required" parce que `kind` default à 'login'). Re-ship + le payload complet, seul `folder` change. Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`. @@ -403,9 +411,11 @@ client-side with the vault key. mime, size are stored in cleartext (leaked metadata) so the listing doesn't have to decrypt all rows on slideover-open. - **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side. -- **Master pw rotation** : attachments are NOT re-encrypted on rotation - → they become inaccessible. Known limitation, document for users who - rotate master pw (rotate before adding heavy attachments). +- **Master pw rotation** : attachments ARE re-encrypted client-side after + the entries flip. Loop fetches each blob via `GET /attachments/:id`, + decrypts with old key, re-encrypts with new key, PUTs the new blob via + `PUT /attachments/:id`. Best-effort: a failure on one attachment shows + a warning but doesn't undo the rotation. - **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing entries only, never on new). Upload via hidden file input + paperclip button. Download reuses `Bridge.saveFile` (native Save As dialog). diff --git a/TEST_PLAN.md b/TEST_PLAN.md new file mode 100644 index 0000000..5c97d46 --- /dev/null +++ b/TEST_PLAN.md @@ -0,0 +1,102 @@ +# Test plan — commit `fa7ea19` (native save + auto-backup + folder customization + attachments + UX bundle) + +Coche au fur et à mesure. Les **🔥 regression** sont les bugs déjà vus à +re-vérifier explicitement. + +## 0. Setup +- [✅] `delphi-backend\assets\BuildAssets.cmd` + F9 (sinon embedded ≠ disque) +- [✅] Lance avec `config.txt` présent (debug panel + log) +- [✅] Crée 2 users de test ou utilise vault existant non critique + +## 1. Native Save As +- [✅] Settings → Export encrypted JSON → maître pw OK → backup pw → boîte "Enregistrer sous" Windows native (pas le popup Téléchargements Edge) +- [✅] Export CSV → idem natif +- [✅] Annule la dialog → toast "save cancelled" attendu, pas d'erreur silencieuse + +## 2. Auto-backup +- [✅] Settings → Auto-backup → toggle on → prompt pwd (< 6 chars rejeté) +- [✅] "Choose…" → folder Windows valide → path s'affiche +- [✅] "Backup now" → fichier `vault-autobackup-yyyymmdd-HHmmss.json` créé dans le folder +- [✅] Lance plusieurs "Backup now" (interval = 1 jour) → fichiers s'accumulent → après N+1, le plus vieux est purgé (vérifier `keep` retention) +- [✅] Toggle off → re-on → prompt pwd à nouveau (confirme que le pwd est effacé) +- [ ] Restart app, unlock → 5s plus tard, si `interval` passé, backup auto silencieux (regarde le folder) +- [✅] **Restore** : install fresh → import "Encrypted JSON" → choisis un backup → tape le pwd → entries restaurées intactes + +## 3. Folder customization +- [✅] Sidebar → + Folders → modal s'ouvre → name+color+icon → Create → folder visible avec custom icon + color +- [✅] Hover sur folder → pencil apparaît → click → modal pré-rempli → change icon → Save → re-render OK +- [✅] **Folder chip sur card** : crée entry dans "Banking" → la chip sous le pwd montre l'icône shield + couleur (pas le default folder gris) 🔥 +- [✅] Drag-reorder : grab folder → drag au-dessus d'un autre → ligne d'insertion cyan visible (above/below selon mouse Y) → drop → ordre changé persisté (refresh app) + +## 4. Drag entries vers folders 🔥 +- [✅] Drag d'une LOGIN entry → folder → toast "Moved to X" → entry change de folder +- [✅] Drag d'une NOTE entry → folder → toast "Moved to X" (régression v1, doit marcher maintenant) +- [✅] Drag d'une entry avec **TOTP configuré** → folder → ouvre l'entry après → **TOTP toujours là** (pas wipé) +- [✅] Drag d'une entry avec **custom_fields** → folder → custom_fields préservés +- [✅] Drag d'une entry vers son propre folder → no-op (pas d'erreur) + +## 5. Recently used +- [✅] Vue vide au start (rien jamais touché) → "No entries" +- [✅] Copy password d'une entry → attends 2s → sidebar "Recently used" → entry visible en tête +- [✅] Ouvre une entry (slideover) → idem +- [✅] Copy 5 entries rapidement → max 10 visibles, ordre = plus récent d'abord +- [✅] Lock vault + unlock → liste rechargée du serveur (accessed_at persiste) + +## 6. Encrypted attachments +- [✅] Crée entry, sauve, ré-ouvre → section "Attachments" visible (pas sur new entry) +- [✅] "Attach file" → file picker → upload PDF/PNG < 5 MB → toast OK → liste affiche filename+size+mime +- [✅] Upload fichier > 5 MB → toast erreur "File too large" +- [✅] Download → boîte Save As → fichier décrypté identique à l'original (hash check si paranoid) +- [✅] Delete → confirm modal → row disparaît +- [✅] Crée 2nd attachment, supprime entry parent → vérifier que les rows entry_attachments sont aussi delete (CASCADE FK) via `sqlite vault.db "select count(*) from entry_attachments"` +- [✅] Master pw rotation avec attachments → toast "Re-encrypting N attachment(s)…" puis "Attachments re-encrypted" → download d'un attachment **après rotation** réussit (re-chiffré client-side dans le flux change-master) + +## 7. Password expiry +- [✅] Settings → Flag aged → 90 days +- [✅] Backdoor : `sqlite vault.db "UPDATE vault_entries SET password_changed_at = '2024-01-01' WHERE id = X"` → recharge → badge "Aged" visible sur la card de X +- [✅] Edit l'entry, change le password, Save → badge disparaît (password_changed_at bumpé) +- [✅] Edit l'entry, change **uniquement le title**, Save → badge **toujours là** (password_changed_at pas bumpé — le CASE SQL filtre) +- [✅] Filters dropdown → "Aged password" → liste filtrée + +## 8. Print recovery code +- [✅] Settings → Recovery → Generate (ou regenerate) → modal avec code → bouton "Print" +- [✅] Click Print → preview Edge → vérifie : code 32px monospace, account name, date, instructions 3 étapes, page A4 propre (le reste de l'app caché) +- [✅] Annule la print preview → app revient normalement (cleanup du #printRecoveryArea) + +## 9. Audit log +- [✅] Sidebar → Tools → Audit log → table visible avec actions chronologiques +- [✅] Fais une action (add entry, copy pwd) → refresh audit log → nouvelle ligne en tête +- [✅] Search dans audit log → filtre marche + +## 10. Pinned entries +- [✅] Sur n'importe quelle card, click pushpin → toast "Pinned to top" → entry remonte en tête de la liste +- [✅] Re-click → toast "Unpinned" → entry retrouve sa place dans le sort courant +- [✅] Change le sort (name asc → date desc) → entries pinned restent en tête, le tri interne s'applique aux deux groupes +- [✅] Compact mode (Settings → Compact actions) → kebab menu contient "Pin to top" / "Unpin" +- [✅] Pin 3 entries, lock vault, unlock → les 3 sont toujours en tête (persist serveur) +- [ ] Master pw rotation → pinned préservé (pas wipé) +- [✅] Duplicate d'une entry pinned → la copie est **non pinned** (comportement voulu) + +## 11. Search history +- [✅] Tape une recherche, attends 1s sans bouger → recherche ajoutée à l'historique (pas besoin d'Enter) +- [✅] Vide la searchbar → focus → dropdown "Recent searches" avec les dernières +- [✅] Click sur un item → re-filtre et bump en tête +- [✅] X individuel sur un item → suppression isolée +- [✅] "Clear" → vide tout l'historique +- [✅] Dropdown disparaît au click hors searchbar / Escape / blur => un petit probleme > focus search bar > dropdown search affiché > clique à nouveau search (focus non changé) dropdown non affiché + +## 12. Regressions générales 🔥 +- [✅] Quick unlock encore fonctionnel après update +- [✅] Master password rotation OK + entries déchiffrables après +- [✅] Autofill (Ctrl+Shift+L/P) toujours OK +- [ ] Recovery code redeem fonctionne (sur compte test) +- [✅] Lock vault → unlock → toutes entries présentes +- [ ] Trash → restore → entry revient avec tous ses champs (kind, custom_fields, TOTP) + +## 13. Console / log scrutiny +- [ ] Ouvre debug panel → execute scénarios ci-dessus → log Delphi sans `FAILED`/`ERROR` non attendu +- [ ] Si tu peux ouvrir console JS (DevTools désactivé en prod, faut hack — sinon skip) → pas d'erreur JS rouge pendant les flows + +--- + +**Ordre conseillé** : 0 → 4 → 6 → 7 → 1 → 2 → 3 → 5 → 8 → 9 → 10 → 11 → 12 → 13. Le drag entries (#4) est le plus à risque (régression confirmée hier), le faire tôt + à fond. diff --git a/css/style.css b/css/style.css index d47bc55..f503d41 100644 --- a/css/style.css +++ b/css/style.css @@ -304,6 +304,35 @@ input[type="range"]::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--accent-soft); background: var(--bg); } +/* Existing-tag autocomplete dropdown anchored under the chip-input. */ +.tag-suggest { + position: absolute; + top: 100%; left: 0; right: 0; + margin-top: 4px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + z-index: 60; + overflow: hidden; + max-height: 240px; + overflow-y: auto; +} +.tag-suggest.is-hidden { display: none; } +.tag-suggest-item { + display: flex; align-items: center; gap: 8px; + width: 100%; + padding: 7px 10px; + background: transparent; + border: none; + color: var(--text); + font-size: 13px; + text-align: left; + cursor: pointer; +} +.tag-suggest-item:hover { background: var(--bg-elev-2); } +.tag-suggest-item svg { width: 12px; height: 12px; color: var(--text-faint); } + .chip-input input { flex: 1; min-width: 80px; @@ -605,6 +634,81 @@ input[type="range"]::-webkit-slider-thumb { opacity: 1; } .search kbd { position: absolute; right: 8px; } + +/* Search history dropdown */ +.search-history { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-lg); + z-index: 50; + overflow: hidden; +} +.search-history.is-hidden { display: none; } +.search-history-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + border-bottom: 1px solid var(--border-soft); + font-size: 11px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.4px; +} +.search-history-clear { + background: transparent; + border: none; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; +} +.search-history-clear:hover { color: var(--danger); background: var(--danger-soft); } +.search-history-row { + display: flex; + align-items: stretch; +} +.search-history-row:hover { background: var(--bg-elev-2); } +.search-history-item { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + padding: 8px 12px; + background: transparent; + border: none; + color: var(--text); + font-size: 13px; + cursor: pointer; + text-align: left; +} +.search-history-item svg { width: 13px; height: 13px; color: var(--text-faint); } +.search-history-del { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + flex-shrink: 0; + background: transparent; + border: none; + color: var(--text-dim); + cursor: pointer; + transition: color var(--t-fast), background var(--t-fast); +} +.search-history-del { + font-size: 18px; + line-height: 1; + font-weight: 400; +} +.search-history-row:hover .search-history-del { color: var(--text); } +.search-history-del:hover { color: var(--danger); background: var(--danger-soft); } .topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } /* View toggle (Cards / List) */ @@ -709,6 +813,13 @@ input[type="range"]::-webkit-slider-thumb { background: var(--accent-soft); box-shadow: inset 0 0 0 2px var(--accent); } +/* Keyboard cursor (j/k nav) — distinct from selected so a focused card + stays visible after the slideover closes. */ +.entry-card.is-cursor, +.entry-row.is-cursor { + outline: 2px solid var(--accent); + outline-offset: -2px; +} /* === List view layout === */ /* Cards are flattened into a single horizontal row using flexbox + `order`. @@ -904,7 +1015,7 @@ input[type="range"]::-webkit-slider-thumb { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.entry-fav, .entry-del, .entry-dup { +.entry-fav, .entry-del, .entry-dup, .entry-pin { color: var(--text-faint); background: transparent; border: none; padding: 4px; @@ -913,6 +1024,8 @@ input[type="range"]::-webkit-slider-thumb { } .entry-fav.is-on { color: var(--warning); } .entry-fav:hover { color: var(--warning); transform: scale(1.1); } +.entry-pin.is-on { color: var(--accent); } +.entry-pin:hover { color: var(--accent); transform: scale(1.1); } .entry-dup { color: var(--text-dim); opacity: 0; @@ -1733,6 +1846,18 @@ input[type="range"]::-webkit-slider-thumb { } .new-entry-menu .dropdown-item:hover { background: var(--accent-soft); } .new-entry-menu .dropdown-item svg { width: 14px; height: 14px; } +.new-entry-menu .dropdown-sep { + height: 1px; + background: var(--border-soft); + margin: 4px 0; +} +.new-entry-menu .dropdown-section { + padding: 4px 10px 2px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); +} /* ---- Quick search modal (tray menu) ------------------ */ .quick-search-panel { @@ -1861,6 +1986,12 @@ input[type="range"]::-webkit-slider-thumb { z-index: 50; } .slideover.is-open { transform: translateX(0); } +/* Push the app shell to the left when the slideover is open, so the + table, sort buttons, pagination and search bar are no longer hidden + behind it. Width matches .slideover (420px) — bumped to 100% via the + responsive override below 720px. */ +body:has(.slideover.is-open) #appShell { margin-right: 420px; } +#appShell { transition: margin-right var(--t-base); } .slideover-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; @@ -2156,6 +2287,9 @@ input[type="range"]::-webkit-slider-thumb { .topbar { padding: 12px 16px; } .content { padding: 16px; } .slideover { width: 100%; } + /* On a narrow viewport the slideover fills the screen — no point + pushing the shell, the table isn't visible anyway. */ + body:has(.slideover.is-open) #appShell { margin-right: 0; } } /* ---- 17. AUTOFILL PICKER -------------------------------- */ diff --git a/delphi-backend/Handlers/PM.Handler.Attachments.pas b/delphi-backend/Handlers/PM.Handler.Attachments.pas index c6f4ee5..8a670b5 100644 --- a/delphi-backend/Handlers/PM.Handler.Attachments.pas +++ b/delphi-backend/Handlers/PM.Handler.Attachments.pas @@ -260,6 +260,125 @@ begin end; end; +// ===== GET /attachments/all ================================================== +// Lightweight listing of every attachment id+iv for the current user. +// Used by the master-pw rotation flow to enumerate what needs re-encryption. +// No blob shipped — fetched per-id only when the client is ready to re-encrypt. + +procedure HandleListAllAttachments(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT id, entry_id FROM entry_attachments ' + + 'WHERE user_id = :uid'; + 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('entry_id', TJSONNumber.Create(LQ.FieldByName('entry_id').AsInteger)); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== PUT /attachments/{id} ================================================= +// Update only the ciphertext + iv. Used by master-pw rotation to swap to +// the new vault key. Filename/mime/size stay untouched. + +procedure HandleUpdateAttachmentBlob(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LBody: TJSONObject; + LBlob, LIv: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LBlob := LBody.GetValue('encrypted_blob', ''); + LIv := LBody.GetValue('iv', ''); + finally + LBody.Free; + end; + + if (LBlob = '') or (LIv = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Missing encrypted_blob / iv'); + Exit; + end; + if Length(LBlob) > MAX_ATTACHMENT_B64 then + begin + TJSONHelper.SendError(AResponse, 413, 'Attachment too large'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE entry_attachments SET encrypted_blob = :blob, iv = :iv ' + + 'WHERE id = :id AND user_id = :uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('blob').AsString := LBlob; + LQ.ParamByName('iv').AsString := LIv; + LQ.ExecSQL; + if LQ.RowsAffected = 0 then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendOK(AResponse, 'Updated'); +end; + // ===== DELETE /attachments/{id} ============================================== procedure HandleDeleteAttachment(ARequest: TIdHTTPRequestInfo; @@ -311,7 +430,9 @@ end; initialization Router.Register('GET', '/entries/(\d+)/attachments', HandleListAttachments); Router.Register('POST', '/entries/(\d+)/attachments', HandleCreateAttachment); + Router.Register('GET', '/attachments/all', HandleListAllAttachments); Router.Register('GET', '/attachments/(\d+)', HandleGetAttachment); + Router.Register('PUT', '/attachments/(\d+)', HandleUpdateAttachmentBlob); Router.Register('DELETE', '/attachments/(\d+)', HandleDeleteAttachment); end. diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index bb792c0..ccb3d15 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -772,6 +772,7 @@ var LValid: Boolean; LEntryId: Integer; LEncPwd, LIv, LTotpSec, LTotpIv: string; + LNewToken, LNewCsrf: string; begin try LUserId := Authenticate(ARequest, AResponse); @@ -941,18 +942,18 @@ begin LQ.ParamByName('iv').AsString := LIv; // 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; + LQ.ParamByName('ts').DataType := ftMemo; + LQ.ParamByName('tiv').DataType := ftMemo; + LQ.ParamByName('cf').DataType := ftMemo; + LQ.ParamByName('cfiv').DataType := ftMemo; if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear - else LQ.ParamByName('ts').AsString := LTotpSec; + else LQ.ParamByName('ts').Value := LTotpSec; if LTotpIv = '' then LQ.ParamByName('tiv').Clear - else LQ.ParamByName('tiv').AsString := LTotpIv; + else LQ.ParamByName('tiv').Value := LTotpIv; if LCf = '' then LQ.ParamByName('cf').Clear - else LQ.ParamByName('cf').AsString := LCf; + else LQ.ParamByName('cf').Value := LCf; if LCfIv = '' then LQ.ParamByName('cfiv').Clear - else LQ.ParamByName('cfiv').AsString := LCfIv; + else LQ.ParamByName('cfiv').Value := LCfIv; LQ.ExecSQL; end; // Password history is encrypted with the OLD vault key — we @@ -993,6 +994,11 @@ begin end; DeleteAllUserSessions(LUserId); + // Immediately mint a fresh session for the calling client so the + // very next request doesn't bounce with ESessionRejected. The user + // hasn't logged out — they rotated their key, the UI session is + // still legitimate. + CreateSession(LUserId, LNewToken, LNewCsrf); finally LBody.Free; end; @@ -1004,6 +1010,8 @@ begin LObj.AddPair('message', 'Master password changed'); LObj.AddPair('salt', LNewSalt); LObj.AddPair('kdfIterations', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); + LObj.AddPair('token', LNewToken); + LObj.AddPair('csrf', LNewCsrf); TJSONHelper.SendJSON(AResponse, LObj); end; diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index c653aa4..95433d4 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -104,6 +104,7 @@ begin else LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger)); + LObj.AddPair('pinned', TJSONNumber.Create(LQ.FieldByName('pinned').AsInteger)); LObj.AddPair('tags', LQ.FieldByName('tags').AsString); // TOTP fields are NULL when the entry has no 2FA configured. We emit // JSON null instead of '' so the client can distinguish "no TOTP" from @@ -126,6 +127,11 @@ begin var LKindVal := LQ.FieldByName('kind').AsString; if LKindVal = '' then LKindVal := 'login'; LObj.AddPair('kind', LKindVal); + // Template subtype. Empty = generic; otherwise drives UI labels. + if LQ.FieldByName('template').IsNull then + LObj.AddPair('template', TJSONNull.Create) + else + LObj.AddPair('template', LQ.FieldByName('template').AsString); // 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". @@ -167,7 +173,7 @@ var LUserId, LNewId: Integer; LBody, LObj: TJSONObject; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, - LKind, LCf, LCfIv: string; + LKind, LCf, LCfIv, LIcon, LTemplate: string; LQ: TFDQuery; begin try @@ -192,6 +198,8 @@ begin LKind := LBody.GetValue('kind', 'login'); LCf := LBody.GetValue('custom_fields', ''); LCfIv := LBody.GetValue('custom_fields_iv', ''); + LIcon := LBody.GetValue('icon_b64', ''); + LTemplate:= Trim(LBody.GetValue('template', '')); finally LBody.Free; end; @@ -221,9 +229,9 @@ begin 'INSERT INTO vault_entries ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + - ' created_at, updated_at, password_changed_at) ' + + ' icon_b64, template, created_at, updated_at, password_changed_at) ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + - ' :cf, :cfiv, :c, :c2, :c)'; + ' :cf, :cfiv, :ic, :tpl, :c, :c2, :c)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -236,23 +244,28 @@ begin // assigned a string and sometimes Clear()ed to NULL — without a // prior typed assignment, .Clear raises "data type unknown" on // SQLite. Declare ftString up front for the optional TOTP fields. - LQ.ParamByName('ts').DataType := ftString; - LQ.ParamByName('tiv').DataType := ftString; + LQ.ParamByName('ts').DataType := ftMemo; + LQ.ParamByName('tiv').DataType := ftMemo; // Store empty TOTP fields as NULL so the GET endpoint emits JSON null // rather than '' — keeps client-side "has TOTP?" checks unambiguous. if LTotpSec = '' then LQ.ParamByName('ts').Clear else - LQ.ParamByName('ts').AsString := LTotpSec; + LQ.ParamByName('ts').Value := LTotpSec; if LTotpIv = '' then LQ.ParamByName('tiv').Clear else - LQ.ParamByName('tiv').AsString := LTotpIv; + LQ.ParamByName('tiv').Value := 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('cf').DataType := ftMemo; + LQ.ParamByName('cfiv').DataType := ftMemo; + if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf; + if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; + LQ.ParamByName('ic').DataType := ftMemo; + if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; + LQ.ParamByName('tpl').DataType := ftString; + if LTemplate = '' then LQ.ParamByName('tpl').Clear + else LQ.ParamByName('tpl').AsString := LTemplate; LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c2').AsString := LNow; LQ.ExecSQL; @@ -284,7 +297,8 @@ var LUserId, LId: Integer; LBody: TJSONObject; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, - LKind, LCf, LCfIv: string; + LKind, LCf, LCfIv, LTemplate: string; + LHasTemplate: Boolean; LQ: TFDQuery; begin try @@ -315,6 +329,10 @@ begin LKind := LBody.GetValue('kind', 'login'); LCf := LBody.GetValue('custom_fields', ''); LCfIv := LBody.GetValue('custom_fields_iv', ''); + // Template is only updated when the caller explicitly sends it — + // partial PUTs (drag-to-folder) must not wipe it. + LHasTemplate := LBody.GetValue('template') <> nil; + LTemplate := Trim(LBody.GetValue('template', '')); finally LBody.Free; end; @@ -365,6 +383,10 @@ begin // password_changed_at fires only when the ciphertext actually // changes — same conditional used above for history insertion. + // template column is updated only when the caller sent it, so a + // partial PUT (drag-to-folder, move-to-folder) doesn't wipe it. + var LTemplateSet := ''; + if LHasTemplate then LTemplateSet := ', template=:tpl'; LQ.SQL.Text := 'UPDATE vault_entries ' + 'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + @@ -372,7 +394,8 @@ begin ' custom_fields=:cf, custom_fields_iv=:cfiv, ' + ' updated_at=:c, ' + ' password_changed_at = CASE WHEN encrypted_password <> :e ' + - ' THEN :c ELSE password_changed_at END ' + + ' THEN :c ELSE password_changed_at END' + + LTemplateSet + ' ' + 'WHERE id=:id AND user_id=:uid'; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -383,23 +406,29 @@ begin LQ.ParamByName('t').AsString := LTags; // Declare TOTP param types so .Clear works on first use (FireDAC // needs an inferred or explicit DataType before NULL binding). - LQ.ParamByName('ts').DataType := ftString; - LQ.ParamByName('tiv').DataType := ftString; + LQ.ParamByName('ts').DataType := ftMemo; + LQ.ParamByName('tiv').DataType := ftMemo; // Clearing TOTP (user removed 2FA from this entry) is signaled by an // empty string in the request → store NULL in the DB. if LTotpSec = '' then LQ.ParamByName('ts').Clear else - LQ.ParamByName('ts').AsString := LTotpSec; + LQ.ParamByName('ts').Value := LTotpSec; if LTotpIv = '' then LQ.ParamByName('tiv').Clear else - LQ.ParamByName('tiv').AsString := LTotpIv; + LQ.ParamByName('tiv').Value := 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('cf').DataType := ftMemo; + LQ.ParamByName('cfiv').DataType := ftMemo; + if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf; + if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; + if LHasTemplate then + begin + LQ.ParamByName('tpl').DataType := ftString; + if LTemplate = '' then LQ.ParamByName('tpl').Clear + else LQ.ParamByName('tpl').AsString := LTemplate; + end; LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('uid').AsInteger := LUserId; @@ -558,6 +587,51 @@ begin TJSONHelper.SendOK(AResponse, 'Toggled'); end; +// ===== POST /entries/{id}/pin ================================================ +// Same shape as /favorite: flip the pinned bit, no body required. +procedure HandleTogglePin(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'toggle_pin', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Toggled'); +end; + // ===== POST /entries/{id}/touch ============================================== // Bumps accessed_at. Called from JS on copy / slideover-open so the sidebar // "Recent" view can show what the user actually uses. Auth-only (no CSRF @@ -637,12 +711,12 @@ begin LBody.Free; end; - // 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. - // 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 + // Soft cap on the SERIALISED data URI ('data:image/...;base64,...'). The + // favicon fetcher allows 256 KB raw, which becomes ~350 KB after base64 + // + prefix overhead. Cap at 512 KB chars so a max-raw fetch + a bit of + // headroom still passes (the silent 413 here was deleting deepseek's + // 200+ KB icon on lock/unlock since saveEntryIcon swallows the error). + if Length(LIcon) > 524288 then begin TJSONHelper.SendError(AResponse, 413, 'Icon too large'); Exit; @@ -658,7 +732,7 @@ begin 'WHERE id=:id AND user_id=:uid'; LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite if LIcon = '' then LQ.ParamByName('ic').Clear - else LQ.ParamByName('ic').AsString := LIcon; + else LQ.ParamByName('ic').Value := LIcon; LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ExecSQL; @@ -862,10 +936,11 @@ end; procedure HandleBulkImport(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var - LUserId, I, LImported: Integer; + LUserId, I, LImported, LNewId: Integer; LBody, LObj, LEntry: TJSONObject; - LArr: TJSONArray; - LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string; + LArr, LIds: TJSONArray; + LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, + LKind, LCf, LCfIv, LIcon, LTemplate: string; LQ: TFDQuery; begin try @@ -894,6 +969,10 @@ begin LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); LImported := 0; + // Track newly-inserted IDs in input order so the client can upload + // attachments to the right entry afterwards. Skipped rows emit -1 + // so the array remains positionally aligned with the input. + LIds := TJSONArray.Create; DB.Lock; try @@ -905,14 +984,24 @@ 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)'; - // Declare optional TOTP param types ONCE — the prepared statement - // is reused across every imported entry, and FireDAC needs the + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + + ' icon_b64, template, created_at, updated_at) ' + + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + + ' :cf, :cfiv, :ic, :tpl, :c, :c2)'; + // Declare optional param types ONCE — the prepared statement is + // reused across every imported entry, and FireDAC needs the // type set before the first .Clear call would otherwise fail - // for a row without TOTP. - LQ.ParamByName('ts').DataType := ftString; - LQ.ParamByName('tiv').DataType := ftString; + // for a row that omits the field. ftMemo (unlimited TEXT) is + // mandatory for icon_b64 + custom_fields which often exceed + // FireDAC's default ftString cap (~4000 chars) — a 256 KB + // icon would otherwise be silently truncated to nothing on + // the next read. + LQ.ParamByName('ts').DataType := ftMemo; + LQ.ParamByName('tiv').DataType := ftMemo; + LQ.ParamByName('cf').DataType := ftMemo; + LQ.ParamByName('cfiv').DataType := ftMemo; + LQ.ParamByName('ic').DataType := ftMemo; + LQ.ParamByName('tpl').DataType := ftString; for I := 0 to LArr.Count - 1 do begin @@ -926,11 +1015,27 @@ begin LTags := Trim(LEntry.GetValue('tags', '')); LTotpSec := LEntry.GetValue('totp_secret', ''); LTotpIv := LEntry.GetValue('totp_iv', ''); + LKind := LEntry.GetValue('kind', 'login'); + if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login'; + LCf := LEntry.GetValue('custom_fields', ''); + LCfIv := LEntry.GetValue('custom_fields_iv', ''); + LIcon := LEntry.GetValue('icon_b64', ''); + LTemplate:= Trim(LEntry.GetValue('template', '')); - // Skip silently if a row is missing the minimum required fields - // (site + ciphertext). Better than failing the whole batch on - // one bad row when the user is importing 500+ entries. - if (LSite = '') or (LEnc = '') or (LIV = '') then Continue; + // Ciphertext is always required. Site is required only for + // logins — notes legitimately have no site (their body lives + // inside the encrypted blob). Skip silently rather than fail + // the whole batch on one bad row. + if (LEnc = '') or (LIV = '') then + begin + LIds.AddElement(TJSONNumber.Create(-1)); + Continue; + end; + if (LKind = 'login') and (LSite = '') then + begin + LIds.AddElement(TJSONNumber.Create(-1)); + Continue; + end; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; @@ -941,12 +1046,20 @@ begin LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('t').AsString := LTags; if LTotpSec = '' then LQ.ParamByName('ts').Clear - else LQ.ParamByName('ts').AsString := LTotpSec; + else LQ.ParamByName('ts').Value := LTotpSec; if LTotpIv = '' then LQ.ParamByName('tiv').Clear - else LQ.ParamByName('tiv').AsString := LTotpIv; + else LQ.ParamByName('tiv').Value := LTotpIv; + LQ.ParamByName('k').AsString := LKind; + if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf; + if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; + if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; + if LTemplate = '' then LQ.ParamByName('tpl').Clear + else LQ.ParamByName('tpl').AsString := LTemplate; LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c2').AsString := LNow; LQ.ExecSQL; + LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); + LIds.AddElement(TJSONNumber.Create(LNewId)); Inc(LImported); end; finally @@ -967,6 +1080,7 @@ begin LogAudit(LUserId, Format('bulk_import %d entries', [LImported]), GetClientIP(ARequest)); LObj := TJSONObject.Create; LObj.AddPair('imported', TJSONNumber.Create(LImported)); + LObj.AddPair('ids', LIds); TJSONHelper.SendJSON(AResponse, LObj); end; @@ -1026,6 +1140,7 @@ initialization Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); Router.Register('POST', '/entries/(\d+)/touch', HandleTouchEntry); + Router.Register('POST', '/entries/(\d+)/pin', HandleTogglePin); Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory); Router.Register('GET', '/entries/count', HandleEntriesCount); diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 973202f..6a4d72f 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -178,6 +178,11 @@ type // the tray icon mute. Configured from the JS settings panel. property ShowNotifications: Boolean read FShowNotifications write FShowNotifications; + // Already-shown flag for the one-time "still running in the tray" + // balloon. Exposed so the host can pre-mark it true on autostart + // launches (the user didn't actively minimise — no need to inform them). + property BalloonShown: Boolean + read FBalloonShown write FBalloonShown; // Fired on main thread when the autofill hotkey fires. // Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript // to let JS match the title against vault entries. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 7fef05b..0081509 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -306,6 +306,14 @@ begin // the "aged password" badge. Legacy rows: NULL → JS falls back to // updated_at, then created_at. AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME'); + // Pinned entries float to the top of every view, regardless of sort. + // Independent from favorite (which is a filter, not a sort override). + AddColumnIfMissing('vault_entries', 'pinned', 'INTEGER DEFAULT 0'); + // Template identifier: empty/NULL = generic login or note; otherwise a + // string like 'credit-card', 'ssh-key', 'server', 'recovery-codes'. + // Drives the card/table label so notes-with-fields read as "Credit card" + // instead of the generic "Encrypted note" placeholder. + AddColumnIfMissing('vault_entries', 'template', 'TEXT'); // Per-folder customisation. NULL = no override → JS uses the default // accent + i-folder symbol. AddColumnIfMissing('folders', 'color', 'TEXT'); diff --git a/delphi-backend/Source/PM.Favicon.pas b/delphi-backend/Source/PM.Favicon.pas index f1f3467..6634659 100644 --- a/delphi-backend/Source/PM.Favicon.pas +++ b/delphi-backend/Source/PM.Favicon.pas @@ -1,4 +1,4 @@ -unit PM.Favicon; +unit PM.Favicon; { Favicon proxy — fetches a website's icon and returns a base64 data URI @@ -44,7 +44,7 @@ const 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. - MIN_REAL_ICON_BYTES = 500; + MIN_REAL_ICON_BYTES = 300; // Privacy stance: DDG-only fetches. We don't fall back to the site's // own /favicon.ico because that would leak DNS to every domain stored // in the vault. For sites DDG doesn't index, the user can upload a diff --git a/delphi-backend/Source/PM.QuickUnlock.pas b/delphi-backend/Source/PM.QuickUnlock.pas index 56296ca..e9926e2 100644 --- a/delphi-backend/Source/PM.QuickUnlock.pas +++ b/delphi-backend/Source/PM.QuickUnlock.pas @@ -1,4 +1,4 @@ -unit PM.QuickUnlock; +unit PM.QuickUnlock; { Quick unlock — persistent on-device cache of the vault key, encrypted @@ -85,18 +85,42 @@ function CryptUnprotectData(pDataIn: PDataBlob; ppszDataDescr: PPWideChar; function LocalFree(hMem: HLOCAL): HLOCAL; stdcall; external 'kernel32.dll' name 'LocalFree'; +var LoadedConfig:Boolean=False; + StorageDir_:String=''; // --------------------------------------------------------------------------- // Storage helpers // --------------------------------------------------------------------------- - +procedure LoadConfig; +begin + if LoadedConfig then + exit; + Var ConfigList := TStringList.Create; + try + StorageDir_ := TPath.Combine(ExtractFileDir(ParamStr(0)),'config.txt'); + if TFile.Exists(StorageDir_) then + begin + ConfigList.LoadFromFile(StorageDir_); + StorageDir_ := ConfigList.Values['PathUnlock']; + if StorageDir_.ToLower.Equals('same') then + StorageDir_ := ExtractFileDir(ParamStr(0)) + end + else + StorageDir_ := ''; + finally + FreeAndNil(ConfigList); + LoadedConfig :=True; + end; +end; function StorageDir: string; begin // %LOCALAPPDATA%\PMServer — per-user, roaming-disabled. DPAPI keys live // alongside the user profile so they survive Windows updates but not // a profile reset. - Result := TPath.Combine( - GetEnvironmentVariable('LOCALAPPDATA'), - 'PMServer'); + LoadConfig; + if StorageDir_.IsEmpty then + Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'),'PMServer') + else + Result :=StorageDir_; end; function StorageFile: string; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index c9d6565..cb5841c 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -13,6 +13,8 @@ interface + + uses System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, @@ -33,7 +35,10 @@ uses PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, PM.Favicon, FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check - +const + // Bump on each release. Surfaced to JS via cmd://app/version, displayed + // in Settings → Account so users can report bugs with the right build. + APP_VERSION = '1.0.0'; type // Concrete class chosen at compile time. Both inherit from // TTMSFNCCustomWebBrowser so we use that as the field type — events @@ -212,11 +217,15 @@ begin // initial Show before we hide it — minimises the visible flash. if FindCmdLineSwitch('tray', True) then begin + // Suppress the first-time tray balloon for autostart launches — + // it's noise when Windows itself put us in the tray (the user + // didn't actively minimise). Manual launches still see it once. + FBridge.BalloonShown := True; TThread.ForceQueue(nil, procedure begin FBridge.MinimizeToTray; - LogLine('Launched with --tray, minimised at startup'); + LogLine('Launched with --tray, minimised at startup (balloon suppressed)'); end); end; end; @@ -742,6 +751,24 @@ begin else if ACmd = 'app/theme' then FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark') + // Build/version string exposed to JS for the Settings → About panel. + // Hardcoded const — bump manually on releases. Kept simple to avoid + // pulling Windows resource version info at runtime. + else if ACmd = 'app/version' then + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onVersionResult)' + + 'Bridge.onVersionResult("' + APP_VERSION + '")') + + // Launch context: the HKCU Run entry passes -tray so we can tell + // "Windows started me at boot" from "user double-clicked the exe". + // Useful for Settings → Account display and for conditional behavior + // (e.g. skip first-time tooltips on autostart). + else if ACmd = 'app/launch-mode' then + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onLaunchModeResult)' + + 'Bridge.onLaunchModeResult("' + + IfThen(FindCmdLineSwitch('tray', True), 'auto', 'manual') + '")') + // 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. diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 8d365b3..64e26fd 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 679eb43..5cb65c0 100644 --- a/index.html +++ b/index.html @@ -45,6 +45,7 @@ + @@ -121,7 +122,7 @@ Unlock - @@ -266,6 +267,7 @@ Ctrl+K +
@@ -311,6 +313,24 @@ New note + + + + + +
@@ -647,9 +667,12 @@
Account
-

+

Signed in as

+

+ PMServer +

@@ -711,6 +734,8 @@ Master password +