feat(entries): encrypt template at rest, guided tour, import fixes, cleanup

Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-11 18:32:37 +01:00
parent 92ed153bc0
commit a42e4b205d
15 changed files with 381 additions and 2173 deletions
+18 -5
View File
@@ -444,20 +444,27 @@ bulk ne les référencent plus (helpers `AddNullableField`/`BindNullable`) ;
`loadEntries`/`loadTrash` déchiffrent → `e.<f>` en mémoire, donc `loadEntries`/`loadTrash` déchiffrent → `e.<f>` en mémoire, donc
**recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est **recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est
déjà côté client). Liste des champs : `ENCRYPTED_META_FIELDS = déjà côté client). Liste des champs : `ENCRYPTED_META_FIELDS =
['username','site','title','tags']`. Choke-point d'écriture : ['username','site','title','tags','template']`. Choke-point d'écriture :
`withEncryptedMeta(obj)` (chiffre chaque champ, blanchit le clair) — enveloppe `withEncryptedMeta(obj)` (chiffre chaque champ, blanchit le clair) — enveloppe
**chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry, **chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry,
moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry, moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry,
migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au
unlock par `migrateMetadataAtRest` (PUT re-ship, y compris corbeille, bump unlock par `migrateMetadataAtRest` (PUT re-ship, y compris corbeille, bump
`updated_at` assumé une fois). Rotation master-pw re-chiffre les 4 champs sous `updated_at` assumé une fois). Rotation master-pw re-chiffre les champs sous
la nouvelle clé (JS loop `ENCRYPTED_META_FIELDS` + UPDATE serveur). la nouvelle clé (JS loop `ENCRYPTED_META_FIELDS` + UPDATE serveur).
**Cas `template` (ajouté 2026-07-11, subtil)** : contrairement aux 4 autres,
`template` est **omis** par les re-ships partiels (addTag, moveEntryToFolder,
batch) — historiquement le serveur le préservait via `LHasTemplate` (clé
absente). Pour garder ce contrat, `withEncryptedMeta` **skippe un champ absent
de l'objet** (`if (!(f in obj)) continue`) au lieu de synthétiser un `_enc` vide
qui ferait wiper le template stocké. La colonne clair `template` est **gardée**
(fallback migration : `decryptEntryMeta` préfère `_enc`, retombe sur le clair)
— pas encore DROP comme les 4 autres.
**Le `?q=` serveur est neutralisé** (site+username chiffrés → LIKE inutile ; **Le `?q=` serveur est neutralisé** (site+username chiffrés → LIKE inutile ;
le front cherche côté client). **La validation « Site required » serveur est le front cherche côté client). **La validation « Site required » serveur est
retirée** (site='' quand chiffré) — le client la fait. `folder` reste en clair retirée** (site='' quand chiffré) — le client la fait. `folder` reste en clair
(requête serveur de réassignation sur delete-folder). Reste en clair : (requête serveur de réassignation sur delete-folder). Reste en clair :
`folder`, `kind`, `template`, métadonnées d'attachments, nombre de lignes, `folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
timestamps.
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours** 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 mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
@@ -931,7 +938,13 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
(`POST /entries/{id}/icon`) et réassignation-sur-delete-folder (`POST /entries/{id}/icon`) et réassignation-sur-delete-folder
(`PM.Handler.Folders`). `accessed_at` est **exempté** exprès (timestamp de (`PM.Handler.Folders`). `accessed_at` est **exempté** exprès (timestamp de
lecture, non synced). Le bulk « clear all icons » ne bump pas non plus — lecture, non synced). Le bulk « clear all icons » ne bump pas non plus —
assumé device-local (purge de cache favicon). assumé device-local (purge de cache favicon). **`favorite` et `pinned`
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
2026-07-11) — comme l'avatar. `favorite` est quand même dans l'auto-backup +
export/import (round-trip local), juste pas propagé par le sync. Si un jour on
veut les synchroniser : ajouter au snapshot + bumper `updated_at` sur le toggle
+ appliquer au merge.
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at` - **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` / `FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
+58 -3
View File
@@ -86,8 +86,10 @@ Documenté mais à rappeler pour un futur modèle de menace :
- `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés** - `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés**
- `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé) - `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé)
- `vault_entries` : ~~`username`, `site`, `title`, `tags`~~ **chiffrés - `vault_entries` : ~~`username`, `site`, `title`, `tags`, `template`~~ **chiffrés
(2026-07-09)** ; `folder`, `kind`, `template` encore en clair. (username/site/title/tags 2026-07-09 ; template 2026-07-11)** ; `folder`,
`kind` restent en clair (folder = requête serveur de réassignation ;
kind = 2 valeurs, ~0 entropie).
**`username` + `site` + `title` + `tags` chiffrés au repos (✅ 2026-07-09)** : **`username` + `site` + `title` + `tags` chiffrés au repos (✅ 2026-07-09)** :
colonnes `<f>_enc/<f>_iv` (AES-GCM sous la clé du vault). Clé de l'approche : colonnes `<f>_enc/<f>_iv` (AES-GCM sous la clé du vault). Clé de l'approche :
@@ -102,7 +104,15 @@ champs (username/site/title/tags) pour ce compte, `_enc` remplies, affichage/
recherche/favicons OK. La migration est **par utilisateur** (tourne au unlock recherche/favicons OK. La migration est **par utilisateur** (tourne au unlock
sur les entries du compte connecté) — un compte non connecté garde son clair sur les entries du compte connecté) — un compte non connecté garde son clair
jusqu'à sa prochaine connexion (comportement normal, pas une régression). jusqu'à sa prochaine connexion (comportement normal, pas une régression).
Résiduel : `folder`, `kind`, `template`, métadonnées d'attachments, nombre de **`template` chiffré au repos (✅ 2026-07-11)** : même pattern
(`template_enc`/`template_iv`, ajouté à `ENCRYPTED_META_FIELDS`). Colonne clair
`template` gardée comme fallback de migration (`decryptEntryMeta` préfère `_enc`,
retombe sur le clair). Guard dans `withEncryptedMeta` : les re-ships partiels
(add-tag, move-to-folder) omettent `template` → le serveur le préserve via
`LHasTemplate` (clé absente), pas de wipe. 3 tests unitaires.
**Pièces jointes : le contenu est déjà chiffré** (AES-GCM, `encryptBlobBytes`) ;
seuls `filename`/`mime`/`size_bytes` restent en clair (pour lister sans tout
déchiffrer). Résiduel : `folder`, `kind`, métadonnées d'attachments, nombre de
lignes, timestamps. lignes, timestamps.
### 1.4 ✅ Sync password faible — corrigé (2026-07-09) ### 1.4 ✅ Sync password faible — corrigé (2026-07-09)
@@ -353,6 +363,51 @@ cf. la checklist "Entry payload" de CLAUDE.md).
--- ---
## 6b. Revue source complète (2026-07-11) — noté, NON corrigé
Passe manuelle JS + Delphi + HTML + CSS à la recherche d'illogismes, bugs
invisibles, perf et code mort. Rien de bloquant ; classé par type.
### Code mort / nettoyage
- ~~**`js/app-legacy.js` (1549 lignes)** + `index-legacy.html` + `css/style-legacy.css`~~
— ✅ **supprimés (2026-07-11)**. Ancien monolithe pré-découpage §3.1 (le trio
HTML+JS+CSS). Ni embarqués (absents du whitelist `BuildAssets.ps1`) ni
référencés sauf entre eux. 68/68 tests OK après suppression.
- **`#passkeyBtn` (index.html:134)** — bouton `display:none` jamais câblé en JS.
Stub passkey/WebAuthn abandonné. → supprimer le markup (ou implémenter).
- ~~**`#passkeyBtn`** (index.html:134)~~ — ✅ **supprimé (2026-07-11)**.
Bouton `display:none` jamais câblé (stub passkey/WebAuthn abandonné).
- **IDs inutilisés** `#reauthTitle`, `#historyTitle`, `#reauthConfirm`
**laissés volontairement** : un `id` non référencé ne coûte rien et pourrait
servir à un titre dynamique plus tard. Les retirer = churn à bénéfice nul.
### Performance
- **Search sans debounce** (app.js:8515) — **won't-do (YAGNI, 2026-07-11)**.
Pour < 500 entries le render est instantané ; un debounce ajouterait de la
latence au retour de recherche (régression du feel) pour un gros vault
hypothétique. À revisiter seulement si un profiler montre du lag réel.
- **N+1 attachments séquentiel** (buildSyncSnapshot, auto-backup, rotation) —
**déféré (2026-07-11)** : paralléliser touche le chemin sync/rotation (zone
la plus délicate) pour un gain spéculatif ; < 1 s sur loopback à < 500 entries.
Trigger pour revisiter : un backup/sync perceptiblement lent.
### Design / cohérence (tranché)
- **`favorite` / `pinned` device-local** — ✅ **décidé device-local (2026-07-11)**,
comme l'avatar. Absents de `buildSyncSnapshot` → leurs toggles serveur
(Entries.pas:804 favorite, 851 pin) ne bumpent volontairement PAS `updated_at`.
Asymétrie assumée (favorite est dans backup/export mais pas sync). Documenté
dans CLAUDE.md « Settings sync ».
### Vérifié sain (pour ne pas re-chasser)
- `DB.Lock`/`DB.Unlock` **équilibrés** (les écarts au grep étaient des mentions
en commentaire, pas des appels).
- Aucun `except … end` **muet** côté Delphi (pas de swallow silencieux serveur).
- Aucun `==` laxiste suspect en JS ; `setInterval`/`clearInterval` appariés.
- Restore d'entry (Entries.pas:759) et set-icône (965) bumpent bien `updated_at`.
- Soft-delete (712) ne bump pas mais sort du snapshot live → OK (trash non synced).
---
## 7. Verdict ## 7. Verdict
Pour un usage **personnel, local, mono-utilisateur**, le produit est Pour un usage **personnel, local, mono-utilisateur**, le produit est
-343
View File
@@ -1,343 +0,0 @@
:root {
--bg: #0e1015; --bg2: #191d27; --text: #e2e6ea; --text2: #7f8a98;
--accent: #6b7280; --accent-rgb: 107,114,128;
--danger: #ef4444; --success: #22c55e; --warning: #f59e0b;
--border: rgba(255,255,255,0.12); --card: #212734;
--input: #11141a; --vault-bg: rgba(255,255,255,0.06);
}
.light {
--bg: #eef0f4; --bg2: #e2e5eb; --text: #161b24; --text2: #5f6a7a;
--accent: #6b7280; --accent-rgb: 107,114,128;
--card: #ffffff; --input: #ffffff;
--border: rgba(0,0,0,0.16); --vault-bg: rgba(255,255,255,0.7);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
background: linear-gradient(145deg, var(--bg) 0%, var(--bg2) 100%);
font-family: 'Segoe UI', system-ui, sans-serif;
min-height: 100vh; display: flex; justify-content: center;
align-items: flex-start; padding: 1.2rem; color: var(--text);
transition: background 0.3s, color 0.3s;
}
.toast-container { position:fixed; top:1rem; right:1rem; z-index:9999; display:flex; flex-direction:column; gap:0.5rem; }
.toast { padding:0.7rem 1.2rem; border-radius:0.8rem; font-size:0.85rem; animation:slideIn 0.3s ease; color:#fff; }
.toast.error { background:var(--danger); }
.toast.success { background:var(--success); }
.toast { display:flex; align-items:center; gap:0.6rem; max-width:400px; }
.toast-action { background:rgba(0,0,0,0.3); border:1px solid rgba(255,255,255,0.4); color:#fff; font-weight:700; font-size:0.8rem; padding:0.25rem 0.7rem; border-radius:1rem; cursor:pointer; white-space:nowrap; flex-shrink:0; transition:0.15s; }
.toast-action:hover { background:rgba(0,0,0,0.5); }
@keyframes slideIn { from { transform:translateX(100%); opacity:0; } to { transform:translateX(0); opacity:1; } }
.vault {
width:96%; max-width:1700px; background:var(--vault-bg);
backdrop-filter:blur(20px); border:1px solid var(--border);
border-radius:2rem; padding:1.8rem; margin:0.5rem auto;
box-shadow:0 30px 50px rgba(0,0,0,0.4);
user-select:none; -webkit-user-drag:none;
}
h1 { font-size:2rem; margin-bottom:0.6rem; display:flex; align-items:center; gap:0.6rem; flex-wrap:wrap; }
h1 span { background:var(--accent); padding:0.2rem 0.7rem; border-radius:3rem; font-size:0.8rem; color:#fff; }
/* Auth */
.auth-section { background:rgba(0,0,0,0.25); border-radius:1.5rem; padding:1.3rem; margin-bottom:1rem; border:1px solid var(--border); }
.auth-tabs { display:flex; gap:1rem; margin-bottom:0.8rem; }
.auth-tab { background:none; border:none; color:var(--text2); padding:0.4rem 0.8rem; cursor:pointer; border-bottom:2px solid transparent; font-size:0.9rem; }
.auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); }
input, select, textarea {
flex:1; min-width:130px; background:var(--input); border:1px solid #2d3748;
padding:0.65rem 1rem; border-radius:2rem; color:var(--text); font-size:0.85rem;
outline:none; font-family:inherit;
}
input:focus, select:focus { border-color:var(--accent); }
.btn {
background:linear-gradient(135deg, #6b7280, #4b5563); border:none; color:#fff;
font-weight:600; padding:0.65rem 1.3rem; border-radius:2rem; cursor:pointer;
font-size:0.85rem; transition:0.2s; white-space:nowrap;
}
.btn:hover { filter:brightness(1.15); transform:scale(1.02); }
.btn:disabled { opacity:0.5; cursor:not-allowed; transform:none; }
.btn-outline { background:transparent; border:1px solid #475569; color:var(--text2); }
.btn-sm { padding:0.35rem 0.9rem; font-size:0.75rem; }
.btn-xs { padding:0.2rem 0.5rem; font-size:0.7rem; }
.btn-danger { background:var(--danger); }
.input-group { display:flex; gap:0.5rem; margin:0.7rem 0; flex-wrap:wrap; align-items:center; }
/* Toolbar */
.toolbar { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.5rem; margin-bottom:0.7rem; }
.view-dropdown { position:relative; }
.view-dropdown-menu { position:absolute; top:100%; right:0; margin-top:4px; min-width:155px; background:var(--bg2); border:1px solid var(--border); border-radius:0.6rem; padding:0.3rem; box-shadow:0 8px 24px rgba(0,0,0,0.3); z-index:100; display:flex; flex-direction:column; gap:2px; }
.view-opt { background:none; border:none; color:var(--text); padding:0.45rem 0.8rem; border-radius:0.4rem; cursor:pointer; font-size:0.8rem; text-align:left; transition:0.12s; white-space:nowrap; }
.view-opt:hover { background:var(--accent); color:#fff; }
.view-opt.active { background:var(--accent); color:#fff; font-weight:600; }
.toggles-row { display:flex; gap:1.2rem; align-items:center; flex-wrap:wrap; }
.toggle-item { display:flex; align-items:center; gap:0.4rem; font-size:0.75rem; color:var(--text2); }
.toggle-switch { position:relative; width:36px; height:20px; background:#334155; border-radius:10px; cursor:pointer; transition:0.2s; }
.toggle-switch.active { background:var(--accent); }
.toggle-switch::after { content:''; position:absolute; top:2px; left:2px; width:16px; height:16px; background:#fff; border-radius:50%; transition:0.2s; }
.toggle-switch.active::after { left:18px; }
.status-badge { background:var(--bg2); padding:0.25rem 0.8rem; border-radius:2rem; font-size:0.75rem; white-space:nowrap; }
.hidden { display:none !important; }
/* Strength */
.strength-bar { height:4px; border-radius:2px; transition:0.3s; margin-top:0.2rem; }
.s0 { background:var(--danger); width:20%; }
.s1 { background:var(--warning); width:40%; }
.s2 { background:#eab308; width:60%; }
.s3 { background:#84cc16; width:80%; }
.s4 { background:var(--success); width:100%; }
/* ========== SEARCH BOX WITH CLEAR BUTTON (FIXED) ========== */
.search-box {
position: relative;
width: 240px; /* fixed width adjust if needed */
flex-shrink: 0;
}
.search-box input {
width: 100%;
box-sizing: border-box;
padding-right: 30px; /* space for the ✕ button */
}
.clear-search-btn {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text2);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 6px;
display: none;
}
/* Folders */
.folders-bar { display:flex; gap:0.4rem; margin-bottom:0.8rem; flex-wrap:wrap; align-items:center; padding:0.4rem 0.6rem; background:rgba(0,0,0,0.2); border-radius:1rem; }
.folder-chip { background:var(--bg2); border:1px solid var(--border); color:var(--text2); padding:0.3rem 0.8rem; border-radius:2rem; cursor:pointer; font-size:0.78rem; transition:0.2s; white-space:nowrap; }
.folder-chip:hover { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-chip.active { background:var(--chip-color, var(--accent)); color:#fff; border-color:var(--chip-color, var(--accent)); }
.folder-chip.drag-over { border-color:var(--accent)!important; box-shadow:0 0 12px rgba(var(--accent-rgb),0.5); transform:scale(1.05); }
.pw-display.pw-hover { cursor:pointer; }
.folder-count { background:rgba(0,0,0,0.3); padding:0.1rem 0.4rem; border-radius:1rem; margin-left:0.3rem; font-size:0.7rem; }
.folder-delete-btn { background:transparent; border:none; color:var(--danger); cursor:pointer; font-size:0.7rem; margin-left:0.2rem; opacity:0.7; }
.folder-delete-btn:hover { opacity:1; }
.folder-add-btn { background:transparent; border:1px dashed #475569; color:var(--text2); padding:0.3rem 0.6rem; border-radius:2rem; cursor:pointer; font-size:0.75rem; transition:0.2s; }
.folder-add-btn:hover { border-color:var(--accent); color:var(--accent); }
.light .entry-card, .light .entry-row, .light .entry-compact { box-shadow:0 1px 4px rgba(0,0,0,0.08); }
#addFolderSelect { min-width:110px; max-width:150px; background:var(--input); border:1px solid #2d3748; color:var(--text); padding:0.5rem 0.8rem; border-radius:2rem; font-size:0.8rem; cursor:pointer; }
/* Entries */
#entriesContainer { user-select:none; -webkit-user-drag:none; }
#entriesContainer.grid-view { display:grid; grid-template-columns: repeat(auto-fill, minmax(180px,1fr)); gap:0.6rem; }
#entriesContainer.list-view { display:flex; flex-direction:column; gap:0.4rem; }
#entriesContainer.compact-view { display:flex; flex-direction:column; gap:0.2rem; }
#entriesContainer.table-view { overflow-x:auto; }
#entriesContainer.table-view table { width:100%; border-collapse:collapse; }
#entriesContainer.table-view th { text-align:left; padding:0.4rem 0.6rem; color:var(--text2); font-size:0.75rem; border-bottom:1px solid var(--border); }
#entriesContainer.table-view td { padding:0.4rem 0.6rem; font-size:0.8rem; border-bottom:1px solid rgba(255,255,255,0.03); }
.entry-card, .entry-row, .entry-compact, .table-row-drag { cursor:pointer; user-select:none; }
.entry-card.drag-over, .entry-row.drag-over, .entry-compact.drag-over, .table-row-drag.drag-over { border-color:var(--accent)!important; box-shadow:0 0 15px rgba(var(--accent-rgb),0.3); }
.entry-card.selected { border-color:var(--accent)!important; box-shadow:0 0 0 2px var(--accent),0 0 18px rgba(var(--accent-rgb),0.35); background:rgba(var(--accent-rgb),0.18); }
.entry-row.selected, .entry-compact.selected { border-color:var(--accent)!important; box-shadow:0 0 0 2px var(--accent),0 0 18px rgba(var(--accent-rgb),0.35); background:rgba(var(--accent-rgb),0.14); border-left:3px solid var(--accent); }
.table-row-drag.selected td { background:rgba(var(--accent-rgb),0.14)!important; box-shadow:inset 0 0 0 1px var(--accent); border-bottom:1px solid var(--accent); }
.entry-card { background:var(--card); border:1px solid var(--border); border-radius:0.8rem; padding:0.9rem 2.8rem 0.9rem 0.9rem; transition:0.2s; position:relative; word-break:break-word; box-shadow:0 2px 6px rgba(0,0,0,0.2); }
.entry-card:hover { border-color:rgba(255,255,255,0.15); transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.3); }
.card-site { font-weight:700; font-size:0.9rem; color:var(--text); margin-bottom:0.2rem; }
.card-user { color:var(--text2); font-size:0.75rem; margin-bottom:0.3rem; }
.card-folder { font-size:0.65rem; color:var(--accent); margin-bottom:0.3rem; background:rgba(var(--accent-rgb),0.15); display:inline-block; padding:0.1rem 0.5rem; border-radius:1rem; }
.card-password { background:var(--bg2); padding:0.3rem 0.5rem; border-radius:0.6rem; display:flex; align-items:center; justify-content:space-between; font-family:monospace; font-size:0.75rem; gap:0.2rem; }
#entriesContainer.card-view .entry-card { padding:1.2rem 3rem 1.2rem 1.2rem; }
#entriesContainer.card-view .card-site { font-size:1.05rem; }
#entriesContainer.card-view .card-password { font-size:0.9rem; }
#entriesContainer.card-view .entry-card:hover { transform:translateY(-3px); }
.entry-row { background:var(--card); border:1px solid var(--border); border-radius:0.8rem; padding:0.7rem 2.8rem 0.7rem 0.9rem; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:0.4rem; position:relative; box-shadow:0 2px 6px rgba(0,0,0,0.15); }
.entry-compact { display:flex; align-items:center; gap:0.5rem; padding:0.35rem 2.8rem 0.35rem 0.7rem; background:var(--card); border-radius:0.5rem; border:1px solid var(--border); font-size:0.8rem; position:relative; box-shadow:0 1px 4px rgba(0,0,0,0.12); }
.action-btns { position:absolute; top:4px; right:6px; display:flex; gap:4px; z-index:1; }
.delete-btn { width:20px; height:20px; background:transparent; color:var(--danger); border:none; cursor:pointer; font-size:0.9rem; font-weight:700; display:flex; align-items:center; justify-content:center; }
.delete-btn:hover { color:#fff; transform:scale(1.2); }
.light .delete-btn:hover { color:#000!important; }
.edit-btn { width:20px; height:20px; background:transparent; color:var(--accent); border:none; cursor:pointer; font-size:0.75rem; display:flex; align-items:center; justify-content:center; }
.edit-btn:hover { color:#fff; transform:scale(1.2); }
.light .edit-btn:hover { color:#000!important; }
.star-btn { width:20px; height:20px; background:transparent; border:none; cursor:pointer; font-size:0.85rem; display:flex; align-items:center; justify-content:center; color:var(--text2); }
.star-btn:hover { transform:scale(1.3); }
.entry-card.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.entry-row.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.entry-compact.favorite { border-color:rgba(255,200,0,0.3); background:rgba(255,200,0,0.05); }
.table-row-drag.favorite td { background:rgba(255,200,0,0.05); }
/* Grouped view */
#entriesContainer.grouped-view { display:flex; flex-direction:column; gap:0.15rem; }
.grouped-header { position:sticky; top:0; z-index:2; background:var(--bg); padding:0.55rem 0.8rem; border-radius:0.5rem; font-weight:600; font-size:0.9rem; color:var(--accent); display:flex; align-items:center; gap:0.5rem; border-bottom:2px solid var(--accent); margin-top:0.4rem; }
.grouped-header:first-child { margin-top:0; }
#entriesContainer.grouped-view .entry-row { border-left:3px solid transparent; transition:0.15s; padding-left:1rem; }
#entriesContainer.grouped-view .entry-row:hover { border-left-color:var(--accent); }
/* Detail view */
#entriesContainer.detail-view { display:flex; flex-direction:column; gap:1rem; }
.detail-nav { display:flex; align-items:center; justify-content:center; gap:1rem; padding:0.5rem 0; position:sticky; top:0; z-index:2; background:var(--bg); }
.detail-nav button { background:var(--bg2); border:1px solid var(--border); color:var(--text); padding:0.4rem 1rem; border-radius:0.5rem; cursor:pointer; transition:0.15s; font-size:0.85rem; }
.detail-nav button:hover { border-color:var(--accent); color:var(--accent); }
.detail-nav button:disabled { opacity:0.4; cursor:default; }
.detail-nav .detail-counter { font-size:0.8rem; color:var(--text2); min-width:80px; text-align:center; }
.detail-card { background:var(--bg2); border:1px solid var(--border); border-radius:1rem; padding:1.5rem; display:flex; flex-direction:column; gap:0.9rem; max-width:520px; margin:0 auto; width:100%; }
.detail-field { display:flex; flex-direction:column; gap:0.15rem; }
.detail-label { font-size:0.7rem; color:var(--text2); text-transform:uppercase; letter-spacing:0.5px; }
.detail-value { font-size:1.2rem; word-break:break-all; }
.detail-value.pw-display { font-family:monospace; letter-spacing:2px; font-size:1.3rem; cursor:default; }
.detail-folder { display:flex; gap:0.5rem; align-items:center; }
.detail-actions { display:flex; gap:0.5rem; margin-top:0.3rem; }
.detail-actions button { flex:1; padding:0.5rem; border-radius:0.5rem; border:1px solid var(--border); background:var(--bg); cursor:pointer; transition:0.15s; font-size:0.85rem; }
.detail-actions button:hover { border-color:var(--accent); background:var(--bg2); }
.entry-info { display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap; flex:1; }
.entry-site { font-weight:700; color:var(--text); }
.entry-user { color:var(--text2); }
.entry-folder { font-size:0.7rem; color:var(--accent); background:rgba(var(--accent-rgb),0.15); padding:0.1rem 0.5rem; border-radius:1rem; }
.password-field { display:flex; align-items:center; gap:0.3rem; background:var(--bg2); padding:0.2rem 0.5rem; border-radius:2rem; }
.password-text { font-family:monospace; color:var(--text2); font-size:0.8rem; }
.icon-btn { background:none; border:1px solid #475569; color:var(--text2); border-radius:2rem; padding:0.2rem 0.5rem; font-size:0.65rem; cursor:pointer; }
.icon-btn:hover { background:rgba(var(--accent-rgb),0.2); }
/* Settings Dropdown */
.settings-dropdown { position:relative; display:inline-block; }
.settings-menu { position:absolute; top:100%; right:0; background:var(--bg2); border:1px solid var(--border); border-radius:0.8rem; padding:0.5rem; min-width:200px; z-index:1000; box-shadow:0 10px 25px rgba(0,0,0,0.4); }
.settings-item { display:flex; justify-content:space-between; align-items:center; padding:0.4rem 0.6rem; border-radius:0.5rem; font-size:0.8rem; color:var(--text2); cursor:pointer; }
.settings-item:hover { background:rgba(255,255,255,0.05); }
.settings-item select { background:var(--input); border:1px solid #334155; color:var(--text); padding:0.2rem 0.5rem; border-radius:1rem; font-size:0.75rem; }
/* Trash */
.trash-badge { background:var(--danger); color:#fff; padding:0.15rem 0.5rem; border-radius:1rem; font-size:0.65rem; margin-left:0.3rem; }
.trash-info { font-size:0.7rem; color:var(--text2); margin-top:0.2rem; }
.restore-btn { background:none; color:#fff; border:none; padding:0.2rem 0.6rem; border-radius:1.5rem; cursor:pointer; font-size:0.7rem; }
.restore-btn:hover { filter:brightness(1.2); }
.empty-trash-btn { background:var(--danger); color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
.empty-trash-btn:hover { filter:brightness(1.2); }
/* Edit Modal */
.edit-modal { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.7); display:none; justify-content:center; align-items:center; z-index:1001; }
.edit-modal.show { display:flex; }
.edit-box { background:var(--bg2); border-radius:1.5rem; padding:1.5rem; min-width:380px; max-width:90%; }
.edit-box h3 { margin-bottom:1rem; color:var(--text); }
.edit-box label { color:var(--text2); font-size:0.8rem; display:block; margin-bottom:0.2rem; }
.edit-box input, .edit-box select { width:100%; margin-bottom:0.5rem; }
/* Custom modals */
.custom-modal-overlay { position:fixed; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.6); display:flex; justify-content:center; align-items:center; z-index:10001; display:none; }
.custom-modal-overlay.show { display:flex; }
.custom-modal { background:var(--bg2); border:1px solid var(--border); border-radius:1.2rem; padding:1.5rem; min-width:300px; max-width:90%; }
.custom-modal h3 { margin-bottom:1rem; color:var(--text); }
.custom-modal input { width:100%; margin-bottom:1rem; }
.custom-modal .modal-actions { display:flex; gap:0.5rem; justify-content:flex-end; }
/* Confirm popup near element */
.custom-confirm { position:fixed; background:var(--bg2); border:1px solid var(--accent); border-radius:0.8rem; padding:0.7rem 1rem; z-index:9999; box-shadow:0 10px 30px rgba(0,0,0,0.5); display:none; font-size:0.8rem; color:var(--text); white-space:nowrap; }
.custom-confirm.show { display:block; }
.custom-confirm .confirm-text { margin-bottom:0.5rem; }
.custom-confirm .confirm-btns { display:flex; gap:0.4rem; }
.custom-confirm .confirm-yes { background:var(--danger); color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
.custom-confirm .confirm-no { background:#334155; color:#fff; border:none; padding:0.3rem 0.8rem; border-radius:1.5rem; cursor:pointer; font-size:0.75rem; }
/* Zigzag toast */
.toast-zigzag { position:fixed; z-index:9998; padding:0.4rem 0.7rem; border-radius:0.5rem; font-size:0.72rem; font-weight:600; pointer-events:none; animation:zigzagUp 1.2s ease-out forwards; white-space:nowrap; }
.toast-zigzag.success { background:rgba(34,197,94,0.95); color:#fff; }
.toast-zigzag.error { background:rgba(239,68,68,0.95); color:#fff; }
@keyframes zigzagUp { 0%{opacity:1;transform:translate(0,0) scale(1)} 20%{opacity:0.9;transform:translate(-10px,-14px) scale(0.9)} 40%{opacity:0.65;transform:translate(12px,-28px) scale(0.75)} 70%{opacity:0.3;transform:translate(-8px,-42px) scale(0.6)} 100%{opacity:0;transform:translate(0,-60px) scale(0.4)} }
.idle-warning { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); background:rgba(0,0,0,0.95); color:#fff; padding:2rem; border-radius:2rem; z-index:10000; text-align:center; display:none; }
.idle-warning.show { display:block; }
@media(max-width:700px) {
.vault { padding:1rem; border-radius:1.5rem; width:98%; }
.entry-row { flex-direction:column; }
#entriesContainer.grid-view { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.toolbar { flex-direction:column; }
.folders-bar { flex-direction:column; align-items:stretch; }
}
/* Floating action button */
.fab {
position: fixed; bottom: 2rem; right: 2rem;
width: 56px; height: 56px; border-radius: 50%;
background: var(--accent); color: #fff; border: none;
font-size: 1.8rem; cursor: pointer; box-shadow: 0 8px 20px rgba(0,0,0,0.4);
display: flex; align-items: center; justify-content: center;
transition: 0.2s; z-index: 100;
}
.fab:hover { transform: scale(1.1); filter: brightness(1.1); }
.fab-trash { left: 2rem; right: auto; font-size:1.4rem; background:transparent; box-shadow:none; }
.fab-trash.active { box-shadow:0 0 18px rgba(var(--accent-rgb),0.5); }
/* Generic modal overlay (if you dont already have it) */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7); display: none;
justify-content: center; align-items: center; z-index: 1001;
}
.modal-overlay.show { display: flex; }
/* Modal box (used by both edit and add modals) */
.modal-box {
background: var(--bg2); border-radius: 1.5rem; padding: 1.5rem;
min-width: 380px; max-width: 90%;
}
.modal-box h3 { margin-bottom: 1rem; color: var(--text); }
.modal-box label { color: var(--text2); font-size: 0.8rem; display: block; margin-bottom: 0.2rem; }
.modal-box input, .modal-box select { width: 100%; margin-bottom: 0.5rem; }
/* Batch select */
.select-checkbox {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
accent-color: var(--accent);
}
.batch-actions {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg2);
border: 1px solid var(--accent);
border-radius: 2rem;
padding: 0.5rem 1.5rem;
display: flex;
gap: 0.8rem;
align-items: center;
z-index: 1000;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
font-size: 0.85rem;
color: var(--text);
}
.batch-actions button {
font-size: 0.78rem;
padding: 0.35rem 0.8rem;
}
.selected-count {
font-weight: 600;
color: var(--accent);
}
#rectSelect {
position:fixed; pointer-events:none; z-index:999;
border:1px solid var(--accent);
background:rgba(var(--accent-rgb),0.1);
display:none;
}
#trashBtn.drag-over {
border-color:var(--danger)!important;
box-shadow:0 0 15px rgba(239,68,68,0.4);
background:rgba(239,68,68,0.15);
}
/* Batch confirm modal backdrop */
.batch-confirm-overlay { background:rgba(0,0,0,0.3); }
+23
View File
@@ -2207,6 +2207,29 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
.settings-tab:hover { background: var(--bg-elev-2); } .settings-tab:hover { background: var(--bg-elev-2); }
.settings-tab.is-active { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); } .settings-tab.is-active { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
.slideover-field.is-tab-hidden { display: none; } .slideover-field.is-tab-hidden { display: none; }
/* Guided tour (spotlight + bubble). Backdrop blocks app clicks; the spot's
huge box-shadow is the dimmer (no separate overlay needed). */
.tour-backdrop { position: fixed; inset: 0; z-index: 300; }
.tour-spot {
position: fixed; z-index: 301; pointer-events: none; border-radius: 8px;
border: 2px solid var(--accent);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, .6);
transition: top .2s ease, left .2s ease, width .2s ease, height .2s ease;
}
.tour-bubble {
position: fixed; z-index: 302; max-width: 300px;
background: var(--bg-elev-2, var(--bg-2)); color: var(--text);
border: 1px solid var(--border); border-radius: 10px;
padding: 14px 16px; box-shadow: 0 8px 30px rgba(0, 0, 0, .45);
}
.tour-bubble-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
.tour-bubble-body { font-size: 12px; color: var(--text-dim); line-height: 1.5; }
.tour-bubble-foot {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-top: 12px;
}
.tour-bubble-count { font-size: 11px; color: var(--text-faint); }
.tour-bubble-btns { display: flex; gap: 6px; }
/* Hidden section + per-row + no-results banner driven by JS. */ /* Hidden section + per-row + no-results banner driven by JS. */
.slideover-field.is-search-hidden, .slideover-field.is-search-hidden,
.setting-row.is-search-hidden { display: none; } .setting-row.is-search-hidden { display: none; }
@@ -1044,6 +1044,7 @@ begin
' site_enc = :senc, site_iv = :siv, ' + ' site_enc = :senc, site_iv = :siv, ' +
' title_enc = :tenc, title_iv = :tiv2, ' + ' title_enc = :tenc, title_iv = :tiv2, ' +
' tags_enc = :genc, tags_iv = :giv, ' + ' tags_enc = :genc, tags_iv = :giv, ' +
' template_enc = :tplenc, template_iv = :tpliv, ' +
' updated_at = CURRENT_TIMESTAMP ' + ' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid'; 'WHERE id = :id AND user_id = :uid';
@@ -1065,6 +1066,8 @@ begin
var LTIv := LEntry.GetValue<string>('title_iv', ''); var LTIv := LEntry.GetValue<string>('title_iv', '');
var LGEnc := LEntry.GetValue<string>('tags_enc', ''); var LGEnc := LEntry.GetValue<string>('tags_enc', '');
var LGIv := LEntry.GetValue<string>('tags_iv', ''); var LGIv := LEntry.GetValue<string>('tags_iv', '');
var LTplEnc := LEntry.GetValue<string>('template_enc', '');
var LTplIv := LEntry.GetValue<string>('template_iv', '');
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]); raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
@@ -1110,6 +1113,12 @@ begin
else LQ.ParamByName('genc').Value := LGEnc; else LQ.ParamByName('genc').Value := LGEnc;
if LGIv = '' then LQ.ParamByName('giv').Clear if LGIv = '' then LQ.ParamByName('giv').Clear
else LQ.ParamByName('giv').Value := LGIv; else LQ.ParamByName('giv').Value := LGIv;
LQ.ParamByName('tplenc').DataType := ftMemo;
LQ.ParamByName('tpliv').DataType := ftMemo;
if LTplEnc = '' then LQ.ParamByName('tplenc').Clear
else LQ.ParamByName('tplenc').Value := LTplEnc;
if LTplIv = '' then LQ.ParamByName('tpliv').Clear
else LQ.ParamByName('tpliv').Value := LTplIv;
LQ.ExecSQL; LQ.ExecSQL;
end; end;
// Password history is encrypted with the OLD vault key — we // Password history is encrypted with the OLD vault key — we
+38 -11
View File
@@ -168,11 +168,15 @@ begin
var LKindVal := LQ.FieldByName('kind').AsString; var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login'; if LKindVal = '' then LKindVal := 'login';
LObj.AddPair('kind', LKindVal); LObj.AddPair('kind', LKindVal);
// Template subtype. Empty = generic; otherwise drives UI labels. // Template subtype. Encrypted at rest (template_enc/iv); the cleartext
// `template` column is kept as a migration fallback (decryptEntryMeta
// prefers _enc, falls back to cleartext until the row is swept).
if LQ.FieldByName('template').IsNull then if LQ.FieldByName('template').IsNull then
LObj.AddPair('template', TJSONNull.Create) LObj.AddPair('template', TJSONNull.Create)
else else
LObj.AddPair('template', LQ.FieldByName('template').AsString); LObj.AddPair('template', LQ.FieldByName('template').AsString);
AddNullableField(LObj, 'template_enc', LQ.FieldByName('template_enc'));
AddNullableField(LObj, 'template_iv', LQ.FieldByName('template_iv'));
// Stable cross-device identity (always populated post-migration). // Stable cross-device identity (always populated post-migration).
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString); LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
// Custom fields: opaque ciphertext + IV, treated identically to // Custom fields: opaque ciphertext + IV, treated identically to
@@ -328,6 +332,7 @@ var
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid,
LTemplateEnc, LTemplateIv,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
@@ -368,6 +373,8 @@ begin
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', ''); LIcon := LBody.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LBody.GetValue<string>('template', '')); LTemplate:= Trim(LBody.GetValue<string>('template', ''));
LTemplateEnc := LBody.GetValue<string>('template_enc', '');
LTemplateIv := LBody.GetValue<string>('template_iv', '');
// Caller may bring its own UUID (sync restore / import preserving // Caller may bring its own UUID (sync restore / import preserving
// identity). Otherwise the server mints a fresh one. // identity). Otherwise the server mints a fresh one.
LUuid := Trim(LBody.GetValue<string>('uuid', '')); LUuid := Trim(LBody.GetValue<string>('uuid', ''));
@@ -401,10 +408,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' + ' icon_b64, template, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)'; ' :cf, :cfiv, :ic, :tpl, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
// site/username/title/tags cleartext columns dropped — only *_enc written. // site/username/title/tags cleartext columns dropped — only *_enc written.
BindNullable(LQ, 'uenc', LUserEnc); BindNullable(LQ, 'uenc', LUserEnc);
@@ -444,6 +451,8 @@ begin
LQ.ParamByName('tpl').DataType := ftString; LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate; else LQ.ParamByName('tpl').AsString := LTemplate;
BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
@@ -486,7 +495,7 @@ var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LTemplate, LTemplateEnc, LTemplateIv,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LHasTemplate: Boolean; LHasTemplate: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
@@ -528,9 +537,13 @@ begin
LCf := LBody.GetValue<string>('custom_fields', ''); LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
// Template is only updated when the caller explicitly sends it — // Template is only updated when the caller explicitly sends it —
// partial PUTs (drag-to-folder) must not wipe it. // partial PUTs (drag-to-folder) must not wipe it. withEncryptedMeta always
// ships template (blanked) + template_enc together, so this one flag gates
// both the cleartext and ciphertext columns.
LHasTemplate := LBody.GetValue('template') <> nil; LHasTemplate := LBody.GetValue('template') <> nil;
LTemplate := Trim(LBody.GetValue<string>('template', '')); LTemplate := Trim(LBody.GetValue<string>('template', ''));
LTemplateEnc := LBody.GetValue<string>('template_enc', '');
LTemplateIv := LBody.GetValue<string>('template_iv', '');
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -581,7 +594,8 @@ begin
// template column is updated only when the caller sent it, so a // template column is updated only when the caller sent it, so a
// partial PUT (drag-to-folder, move-to-folder) doesn't wipe it. // partial PUT (drag-to-folder, move-to-folder) doesn't wipe it.
var LTemplateSet := ''; var LTemplateSet := '';
if LHasTemplate then LTemplateSet := ', template=:tpl'; if LHasTemplate then
LTemplateSet := ', template=:tpl, template_enc=:tplenc, template_iv=:tpliv';
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET username_enc=:uenc, username_iv=:uiv, ' + 'SET username_enc=:uenc, username_iv=:uiv, ' +
@@ -631,6 +645,8 @@ begin
LQ.ParamByName('tpl').DataType := ftString; LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate; else LQ.ParamByName('tpl').AsString := LTemplate;
BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv);
end; end;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
@@ -1174,7 +1190,8 @@ var
LBody, LObj, LEntry: TJSONObject; LBody, LObj, LEntry: TJSONObject;
LArr, LIds: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid, LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplate, LTemplateEnc, LTemplateIv, LUuid,
LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LQ, LTomb: TFDQuery; LQ, LTomb: TFDQuery;
begin begin
@@ -1232,10 +1249,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template, uuid, created_at, updated_at) ' + ' icon_b64, template, template_enc, template_iv, uuid, created_at, updated_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)'; ' :cf, :cfiv, :ic, :tpl, :tplenc, :tpliv, :uuid, :c, :c2)';
// Declare optional param types ONCE — the prepared statement is // Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the // reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail // type set before the first .Clear call would otherwise fail
@@ -1285,8 +1302,16 @@ begin
LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', ''); LIcon := LEntry.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LEntry.GetValue<string>('template', '')); LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
LTemplateIv := LEntry.GetValue<string>('template_iv', '');
LUuid := Trim(LEntry.GetValue<string>('uuid', '')); LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
if LUuid = '' then LUuid := NewUUIDv4; if LUuid = '' then LUuid := NewUUIDv4;
// Preserve the file's original timestamps on restore; fall back
// to now for foreign imports (CSV) that carry none.
LCreatedAt := Trim(LEntry.GetValue<string>('created_at', ''));
if LCreatedAt = '' then LCreatedAt := LNow;
LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', ''));
if LUpdatedAt = '' then LUpdatedAt := LNow;
// Ciphertext is always required. Site is required only for // Ciphertext is always required. Site is required only for
// logins — notes legitimately have no site (their body lives // logins — notes legitimately have no site (their body lives
@@ -1322,9 +1347,11 @@ begin
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if LTemplate = '' then LQ.ParamByName('tpl').Clear if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate; else LQ.ParamByName('tpl').AsString := LTemplate;
BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LCreatedAt;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LUpdatedAt;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
LIds.AddElement(TJSONNumber.Create(LNewId)); LIds.AddElement(TJSONNumber.Create(LNewId));
+5
View File
@@ -372,6 +372,11 @@ begin
AddColumnIfMissing('vault_entries', 'title_iv', 'TEXT'); AddColumnIfMissing('vault_entries', 'title_iv', 'TEXT');
AddColumnIfMissing('vault_entries', 'tags_enc', 'TEXT'); AddColumnIfMissing('vault_entries', 'tags_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'tags_iv', 'TEXT'); AddColumnIfMissing('vault_entries', 'tags_iv', 'TEXT');
// template encrypted at rest too (§1.3) — hides the entry category
// (credit-card / ssh-key…) from a stolen DB. Cleartext `template` column
// below stays as migration fallback until every row carries template_enc.
AddColumnIfMissing('vault_entries', 'template_enc', 'TEXT');
AddColumnIfMissing('vault_entries', 'template_iv', 'TEXT');
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,..."). // Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
// Fetched on demand by the Delphi favicon proxy when the user opts in. // Fetched on demand by the Delphi favicon proxy when the user opts in.
// NULL = no icon cached → JS falls back to the first-letter avatar. // NULL = no icon cached → JS falls back to the first-letter avatar.
-218
View File
@@ -1,218 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔐 Vault — Legacy UI</title>
<link rel="stylesheet" href="css/style-legacy.css">
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div class="idle-warning" id="idleWarning">
<h3>⏰ Auto-lock</h3>
<p>Vault locks in <span id="idleCountdown">30</span>s</p>
<button class="btn btn-sm" onclick="resetIdle()">Stay Unlocked</button>
</div>
<div class="edit-modal" id="editModal">
<div class="edit-box">
<h3>✏️ Edit Entry</h3>
<label>Website / App</label><input type="text" id="editSite" placeholder="example.com">
<label style="margin-top:.6rem">Username / Email</label><input type="text" id="editUsername" placeholder="user@example.com">
<label style="margin-top:.6rem">Password</label>
<div style="position:relative">
<input type="password" id="editPassword" placeholder="Password" style="width:100%;padding-right:40px">
<button type="button" onclick="toggleEditPassword()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem">👁️</button>
</div>
<label style="margin-top:.6rem">Folder</label>
<select id="editFolder" style="width:100%;margin-bottom:.4rem"></select>
<input type="hidden" id="editId">
<div style="display:flex;gap:.5rem;margin-top:1rem">
<button class="btn" onclick="saveEdit()">💾 Save</button>
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
</div>
</div>
</div>
<!-- Add Password Modal -->
<div class="modal-overlay" id="addModal">
<div class="modal-box">
<h3> New Password</h3>
<label>Website / App</label><input type="text" id="addSite" placeholder="example.com">
<label style="margin-top:.6rem">Username / Email (optional)</label><input type="text" id="addUsername" placeholder="user@example.com">
<label style="margin-top:.6rem">Password</label>
<div style="position:relative">
<input type="password" id="addPassword" placeholder="Password" style="width:100%;padding-right:40px" oninput="checkAddStrength()">
<button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate">🎲</button>
</div>
<div class="strength-bar s0" id="addStrengthBar" style="margin-top:0.2rem;"></div>
<label style="margin-top:.6rem">Folder</label>
<select id="addFolder" style="width:100%;margin-bottom:.4rem"></select>
<div style="display:flex;gap:.5rem;margin-top:1rem">
<button class="btn" id="addEntryBtn" onclick="addEntry()">💾 Save</button>
<button class="btn btn-outline" onclick="closeAdd()">Cancel</button>
</div>
</div>
</div>
<!-- Floating action buttons -->
<button class="fab" onclick="openAdd()" title="Add password">+</button>
<button class="fab fab-trash" id="trashBtn" onclick="toggleTrash()" title="Trash">🗑️</button>
<div class="vault">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem">
<h1 style="margin-bottom:0">🔐 Vault <span>XAMPP</span></h1>
<button class="btn btn-outline btn-xs" onclick="toggleTheme()" title="Toggle theme">🌓</button>
</div>
<!-- Auth Section -->
<div id="authSection" class="auth-section">
<div class="auth-tabs">
<button class="auth-tab active" onclick="switchTab('login')">Login</button>
<button class="auth-tab" onclick="switchTab('register')">Register</button>
</div>
<div id="loginForm">
<div class="input-group">
<input type="text" id="loginUsername" placeholder="Username" autocomplete="off">
<input type="password" id="loginPassword" placeholder="Master Password" autocomplete="off">
<button class="btn" id="loginBtn" onclick="login()">🔓 Unlock</button>
<button class="btn btn-outline btn-sm" onclick="loginWithPasskey()" title="Use passkey" style="white-space:nowrap">🔐 Passkey</button>
</div>
</div>
<div id="registerForm" class="hidden">
<div class="input-group">
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="off">
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off" oninput="checkRegStrength()">
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
</div>
<div class="strength-bar s0" id="regStrengthBar" style="margin-top:0.4rem;margin-bottom:0.2rem;flex-basis:100%"></div>
</div>
</div>
<!-- Vault Section -->
<div id="vaultSection" class="hidden">
<!-- Top bar -->
<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;align-items:center;flex-wrap:wrap;gap:.4rem">
<span class="status-badge" id="connectionStatus">🟢 Connected</span>
<span id="entryCount" style="color:var(--text2);font-size:.8rem"></span>
<div style="display:flex;gap:.4rem;align-items:center">
<span id="currentUser" style="color:var(--text2);font-size:.8rem"></span>
<div class="settings-dropdown">
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="toggleSettings()">⚙️</button>
<div class="settings-menu hidden" id="settingsMenu">
<div class="settings-item">
<span>Auto-lock</span>
<select id="autoLockTimer" onchange="setAutoLock()">
<option value="0">No lock</option>
<option value="1">1 min</option>
<option value="5" selected>5 min</option>
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hour</option>
</select>
</div>
<div class="settings-item" onclick="toggleSound();event.stopPropagation();">
<span>Sound</span>
<div class="toggle-switch" id="soundToggleSwitch"></div>
</div>
<div class="settings-item" onclick="toggleTheme();event.stopPropagation();">
<span>Theme</span>
<div class="toggle-switch" id="themeToggleSwitch"></div>
</div>
<div class="settings-item" onclick="toggleViewBtn();event.stopPropagation();">
<span>👁️ View password</span>
<div class="toggle-switch active" id="showViewBtnToggle"></div>
</div>
<div class="settings-item" onclick="toggleShowEmail();event.stopPropagation();">
<span>📧 Show email</span>
<div class="toggle-switch active" id="showEmailToggle"></div>
</div>
<div class="settings-item" onclick="if(window.PublicKeyCredential){registerPasskey()}else{toast('Passkeys not supported','error')};event.stopPropagation();">
<span>🔐 Set up passkey</span>
<span style="color:var(--accent);font-size:0.7rem"></span>
</div>
</div>
</div>
<button class="btn btn-outline btn-sm" onclick="doLogout()">🔒 Lock</button>
</div>
</div>
<!-- Add Entry Form -->
<!-- <form onsubmit="return false" class="input-group" autocomplete="off"> -->
<!-- <input type="text" id="siteInput" placeholder="Website *" autocomplete="off"> -->
<!-- <input type="text" id="usernameInput" placeholder="Username (optional)" autocomplete="off"> -->
<!-- <div style="flex:1;min-width:130px;position:relative"> -->
<!-- <input type="password" id="passwordInput" placeholder="Password *" autocomplete="new-password" style="width:100%;padding-right:40px" oninput="checkStrength()"> -->
<!-- <button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate password">🎲</button> -->
<!-- </div> -->
<!-- <div class="strength-bar s0" id="strengthBar" style="flex-basis:100%;margin-top:-0.3rem;margin-bottom:0.3rem"></div> -->
<!-- <select id="addFolderSelect"></select> -->
<!-- <button type="button" class="btn" id="addBtn" onclick="addEntry()"> Add</button> -->
<!-- </form> -->
<!-- Toolbar with search + trash + views -->
<div class="toolbar">
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Search..." oninput="searchEntries()" autocomplete="off">
<button type="button" class="clear-search-btn" id="clearSearchBtn" onclick="clearSearch()" title="Clear search"></button>
</div>
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
<button class="btn btn-outline btn-sm" onclick="showShortcutsHelp()" title="Shortcuts">⌨️</button>
<div class="view-dropdown" id="viewDropdown">
<button class="btn btn-outline btn-sm" id="viewDropdownBtn">🟫 Grid ▾</button>
<div class="view-dropdown-menu hidden" id="viewDropdownMenu">
<button class="view-opt" data-view="grid">🟫 Grid</button>
<button class="view-opt" data-view="compact">📝 Compact</button>
<button class="view-opt" data-view="list">📋 List</button>
<button class="view-opt" data-view="table">📊 Table</button>
<button class="view-opt" data-view="card">🃏 Card</button>
<button class="view-opt" data-view="grouped">📂 Grouped</button>
<button class="view-opt" data-view="detail">🔍 Detail</button>
</div>
</div>
</div>
</div>
<div class="folders-bar" id="foldersBar"></div>
<div id="trashActions" class="hidden" style="margin-bottom:.5rem;text-align:right;">
<button class="empty-trash-btn" onclick="emptyTrash()">🗑️ Empty Trash</button>
</div>
<div id="entriesContainer" class="grid-view">
<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>
</div>
<div style="margin-top:.6rem;text-align:right">
<button class="btn btn-outline btn-sm" onclick="showExportModal()">📤 Export</button>
</div>
</div>
</div>
<!-- Generator Modal -->
<div id="genModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:none;justify-content:center;align-items:center;z-index:2000">
<div style="background:var(--bg2);border-radius:1.5rem;padding:1.5rem;min-width:320px;max-width:90%">
<h3 style="margin-bottom:.8rem;color:var(--text)">🎲 Generator</h3>
<div style="background:var(--input);padding:.8rem;border-radius:1rem;font-family:monospace;text-align:center;color:#4ade80;margin:.6rem 0;word-break:break-all" id="genPreview">Click Generate</div>
<div style="display:flex;align-items:center;gap:.6rem;margin:.8rem 0">
<span>Length:</span>
<input type="range" id="pwdLen" min="8" max="64" value="16" oninput="onLenChange()" style="flex:1">
<span id="lenVal" style="background:var(--input);padding:.2rem .6rem;border-radius:1rem;min-width:30px;text-align:center">16</span>
</div>
<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0">
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useUpper" checked onchange="genPwd()"> A-Z</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useLower" checked onchange="genPwd()"> a-z</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
</div>
<div style="display:flex;gap:0.3rem;margin-top:0.8rem;flex-wrap:wrap">
<button class="btn btn-sm" onclick="genPreset(16,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 16</button>
<button class="btn btn-sm" onclick="genPreset(20,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 20</button>
<button class="btn btn-sm btn-danger" onclick="genPreset(32,'upper+lower+num+sym')" style="flex:1;min-width:70px">🛡️ Paranoid 32</button>
</div>
<div style="display:flex;gap:.4rem;margin-top:.6rem">
<button class="btn" onclick="genPwd()" style="flex:1">🔄</button>
<button class="btn" onclick="useGen()" style="flex:1">✅ Use</button>
<button class="btn btn-outline" onclick="closeGen()">Cancel</button>
</div>
</div>
</div>
<script src="js/app-legacy.js"></script>
</body>
</html>
+52 -41
View File
@@ -131,10 +131,6 @@
<svg><use href="#i-unlock"/></svg> <svg><use href="#i-unlock"/></svg>
Unlock Unlock
</button> </button>
<button id="passkeyBtn" type="button" class="btn btn-ghost btn-block" style="display:none">
<svg><use href="#i-lock"/></svg>
Use a passkey
</button>
<button id="recoveryBtn" type="button" class="btn btn-link btn-block" style="font-size:12px;margin-top:4px"> <button id="recoveryBtn" type="button" class="btn btn-link btn-block" style="font-size:12px;margin-top:4px">
Forgot master password? Use a recovery code Forgot master password? Use a recovery code
</button> </button>
@@ -422,6 +418,16 @@
<button class="settings-tab" data-tab="sync"><svg><use href="#i-rotate-ccw"/></svg>Sync &amp; Backup</button> <button class="settings-tab" data-tab="sync"><svg><use href="#i-rotate-ccw"/></svg>Sync &amp; Backup</button>
</div> </div>
<div class="slideover-body" id="settingsBody"> <div class="slideover-body" id="settingsBody">
<div class="slideover-field">
<div class="slideover-field-label">Getting started</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
A quick guided tour of the main features — search, autofill,
tools and sync.
</p>
<button class="btn btn-ghost btn-sm" id="startTourBtn">
<svg><use href="#i-log-in"/></svg> How it works
</button>
</div>
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Appearance</div> <div class="slideover-field-label">Appearance</div>
<div class="setting-row"> <div class="setting-row">
@@ -515,6 +521,38 @@
</div> </div>
</div> </div>
<div class="slideover-field">
<div class="slideover-field-label">Startup &amp; tray</div>
<div class="setting-row" id="settingTrayNotifRow">
<span>
Show tray notifications
<small class="setting-hint">
The first time the app minimises to the tray
it shows a small balloon explaining how to
restore it. Turn off if you've seen it.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingTrayNotif">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutoStartRow">
<span>
Start with Windows
<small class="setting-hint">
Launch this app in the tray when you sign in to
Windows — so the autofill hotkeys are ready
immediately. Device-only setting (per Windows user).
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutoStart">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Security</div> <div class="slideover-field-label">Security</div>
<div class="setting-row"> <div class="setting-row">
@@ -574,34 +612,6 @@
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</div> </div>
<div class="setting-row" id="settingTrayNotifRow">
<span>
Show tray notifications
<small class="setting-hint">
The first time the app minimises to the tray
it shows a small balloon explaining how to
restore it. Turn off if you've seen it.
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingTrayNotif">
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row" id="settingAutoStartRow">
<span>
Start with Windows
<small class="setting-hint">
Launch this app in the tray when you sign in to
Windows — so the autofill hotkeys are ready
immediately. Device-only setting (per Windows user).
</small>
</span>
<label class="toggle">
<input type="checkbox" id="settingAutoStart">
<span class="toggle-slider"></span>
</label>
</div>
</div> </div>
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Autofill</div> <div class="slideover-field-label">Autofill</div>
@@ -649,10 +659,11 @@
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Clipboard privacy</div> <div class="slideover-field-label">Clipboard privacy</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5"> <p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Windows keeps copied passwords in <b>clipboard history (Win+V)</b>. Passwords copied from this app are <b>excluded from Windows
To prevent that, disable history in <i>Settings → System → Clipboard</i>. clipboard history (Win+V)</b> and cloud clipboard, and cleared
automatically when the app is minimised — no manual setup needed.
</p> </p>
<button class="btn btn-ghost btn-sm" id="openClipboardSettings">Why?</button> <button class="btn btn-ghost btn-sm" id="openClipboardSettings">How?</button>
</div> </div>
<div class="slideover-field"> <div class="slideover-field">
@@ -819,11 +830,15 @@
No PIN set. No PIN set.
</p> </p>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4"> <p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
Shortcut unlock with a 46 digit PIN. Stored DPAPI-encrypted on Shortcut unlock with a 412 digit PIN. Stored DPAPI-encrypted on
this device. <b>Master password is still required for sensitive this device. <b>Master password is still required for sensitive
actions</b> (export, change master password, recovery code…). actions</b> (export, change master password, recovery code…).
</p> </p>
<div class="setting-row" style="margin:0 0 8px"> <div style="display:flex;gap:6px;flex-wrap:wrap;margin:0 0 8px">
<button class="btn btn-ghost btn-sm" id="pinSetBtn">Set PIN</button>
<button class="btn btn-ghost btn-sm is-danger" id="pinRemoveBtn" style="display:none">Remove PIN</button>
</div>
<div class="setting-row" style="margin:0">
<span> <span>
Unlock method Unlock method
<small class="setting-hint">Master pw (default), PIN only, or both required.</small> <small class="setting-hint">Master pw (default), PIN only, or both required.</small>
@@ -834,10 +849,6 @@
<option value="both">Master password + PIN</option> <option value="both">Master password + PIN</option>
</select> </select>
</div> </div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" id="pinSetBtn">Set PIN</button>
<button class="btn btn-ghost btn-sm is-danger" id="pinRemoveBtn" style="display:none">Remove PIN</button>
</div>
</div> </div>
<div class="slideover-field"> <div class="slideover-field">
-1550
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -414,6 +414,8 @@ function parseEntriesFromJSON(text) {
custom_fields: cf, custom_fields: cf,
attachments: atts, attachments: atts,
icon_b64: String(e.icon_b64 || '').trim(), icon_b64: String(e.icon_b64 || '').trim(),
created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').trim(),
}); });
} }
return { entries, skipped, columns: null, folders, return { entries, skipped, columns: null, folders,
@@ -472,6 +474,10 @@ async function encryptImportEntry(plain) {
custom_fields_iv: cfIv, custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '', icon_b64: plain.icon_b64 || '',
template: plain.template || '', template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports).
created_at: plain.created_at || '',
updated_at: plain.updated_at || '',
}); });
} }
@@ -693,6 +699,10 @@ async function doImport() {
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(enc), body: JSON.stringify(enc),
}); });
// PUT ignores icon_b64 (dedicated endpoint owns it), so
// restore the file's icon separately — else overwriting
// an entry whose icon was cleared never brings it back.
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64);
overwritten++; overwritten++;
} catch (_) { /* skip the single row on failure */ } } catch (_) { /* skip the single row on failure */ }
} }
+14 -2
View File
@@ -1493,7 +1493,7 @@ async function decryptEntryMeta(list) {
// (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run // (blanked on write) + ciphertext `f_enc`/`f_iv`. Search/sort/render all run
// client-side on the decrypted in-memory value, so encrypting these is // client-side on the decrypted in-memory value, so encrypting these is
// transparent. `folder` stays cleartext (server folder-reassign query). // transparent. `folder` stays cleartext (server folder-reassign query).
const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags']; const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags', 'template'];
// Choke point for the write path: take an entry body object whose metadata // Choke point for the write path: take an entry body object whose metadata
// fields hold PLAINTEXT, encrypt each into <f>_enc/<f>_iv, and blank the // fields hold PLAINTEXT, encrypt each into <f>_enc/<f>_iv, and blank the
@@ -1503,6 +1503,12 @@ const ENCRYPTED_META_FIELDS = ['username', 'site', 'title', 'tags'];
async function withEncryptedMeta(obj) { async function withEncryptedMeta(obj) {
if (!obj) return obj; if (!obj) return obj;
for (const f of ENCRYPTED_META_FIELDS) { for (const f of ENCRYPTED_META_FIELDS) {
// Partial re-ships (add-tag, move-to-folder, batch ops) omit `template`
// on purpose — the server preserves it when the key is absent
// (LHasTemplate). Synthesising an empty one here would blank the key
// and wipe the stored template. The 4 core fields are always present,
// so this only ever skips `template`.
if (!(f in obj)) continue;
const plain = obj[f] || ''; const plain = obj[f] || '';
if (plain) { if (plain) {
const c = await encryptPwd(plain); const c = await encryptPwd(plain);
@@ -8061,6 +8067,8 @@ async function enterApp() {
// Fire-and-forget periodic backup. Defer a few seconds so the unlock // Fire-and-forget periodic backup. Defer a few seconds so the unlock
// path isn't blocked by file I/O + AES-GCM over the full vault. // path isn't blocked by file I/O + AES-GCM over the full vault.
setTimeout(() => { runAutoBackupIfDue(); }, 5000); setTimeout(() => { runAutoBackupIfDue(); }, 5000);
// First-run guided tour (spotlights the headline features once).
maybeStartTour();
} }
async function autoPurgeTrashIfNeeded() { async function autoPurgeTrashIfNeeded() {
@@ -9123,8 +9131,12 @@ async function init() {
// Autofill picker modal close button // Autofill picker modal close button
$$('#autofillPickerModal [data-close]').forEach(b => $$('#autofillPickerModal [data-close]').forEach(b =>
b.addEventListener('click', closeAutofillPicker)); b.addEventListener('click', closeAutofillPicker));
$('#startTourBtn').addEventListener('click', () => {
closeSettings();
setTimeout(startTour, 250); // let the panel slide out first
});
$('#openClipboardSettings').addEventListener('click', () => { $('#openClipboardSettings').addEventListener('click', () => {
toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); toast('Copies use the ExcludeClipboardContentFromMonitorProcessing flag, so Windows skips them in Win+V history and cloud sync.');
}); });
$('#exportBtn').addEventListener('click', doExport); $('#exportBtn').addEventListener('click', doExport);
$('#exportCsvBtn').addEventListener('click', doExportCSV); $('#exportCsvBtn').addEventListener('click', doExportCSV);
+113
View File
@@ -348,6 +348,119 @@ function closeCheatsheet() {
document.getElementById('cheatsheetModal').classList.add('is-hidden'); document.getElementById('cheatsheetModal').classList.add('is-hidden');
} }
// ============================================================
// Guided tour ("How it works") — spotlights real UI elements with a
// bubble, no GIFs. Cheaper than baking videos into assets.res and never
// goes stale when the UI changes. Auto-runs once, re-launchable from Settings.
// ============================================================
const TOUR_STEPS = [
{ sel: '#searchInput', title: 'Search',
body: 'Find any entry instantly (Ctrl+K). Anywhere in Windows, press Ctrl+Shift+Q for quick search — copy or autofill without opening the app.' },
{ sel: '#newEntryBtn', title: 'Add entries',
body: 'Create a login, secure note, card, SSH key and more. The ▾ caret picks the type.' },
{ sel: '.view-toggle', title: 'Views',
body: 'Switch between cards, list and table. Your choice is remembered.' },
{ sel: '.sidebar-section[data-section="tools"]', title: 'Tools',
body: 'Authenticator (2FA codes), Vault health score and the password generator live here.' },
{ sel: '#settingsBtn', title: 'Settings',
body: 'WebDAV sync, encrypted auto-backup, autofill hotkeys (Ctrl+Shift+L) and security options.' },
{ sel: '#cheatsheetBtn', title: 'Shortcuts',
body: 'Every keyboard shortcut, anytime — or just press ?.' },
];
let tourIdx = -1;
function tourSeen() {
return (Bridge.active ? null : localStorage.getItem('tourSeen')) === '1';
}
function markTourSeen() {
if (Bridge.active) Bridge.setPref('tourSeen', '1');
localStorage.setItem('tourSeen', '1'); // fast path + fallback
}
// Auto-launch on first unlock. Bridge pref is the source of truth (survives
// the port-rotation localStorage wipe); fall back to localStorage when no Bridge.
async function maybeStartTour() {
let seen = localStorage.getItem('tourSeen') === '1';
if (Bridge.active) {
try { seen = (await Bridge.getPref('tourSeen')) === '1'; } catch (_) {}
}
if (!seen) setTimeout(startTour, 600); // let the app shell settle first
}
function startTour() {
tourIdx = 0;
let bd = document.getElementById('tourBackdrop');
if (!bd) {
bd = el('div', { id: 'tourBackdrop', class: 'tour-backdrop' });
const spot = el('div', { id: 'tourSpot', class: 'tour-spot' });
const bubble = el('div', { id: 'tourBubble', class: 'tour-bubble' });
document.body.append(bd, spot, bubble);
}
window.addEventListener('resize', showTourStep);
showTourStep();
}
function showTourStep() {
// Skip any step whose target isn't in the DOM (feature hidden/disabled).
while (tourIdx < TOUR_STEPS.length &&
!document.querySelector(TOUR_STEPS[tourIdx].sel)) tourIdx++;
if (tourIdx >= TOUR_STEPS.length) return endTour();
const step = TOUR_STEPS[tourIdx];
const target = document.querySelector(step.sel);
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
// Reposition after any scroll settles so the spotlight lands on the rect.
setTimeout(() => positionTour(target, step), 120);
}
function positionTour(target, step) {
const spot = document.getElementById('tourSpot');
const bubble = document.getElementById('tourBubble');
if (!spot || !bubble) return;
const r = target.getBoundingClientRect();
const pad = 6;
spot.style.top = (r.top - pad) + 'px';
spot.style.left = (r.left - pad) + 'px';
spot.style.width = (r.width + pad * 2) + 'px';
spot.style.height = (r.height + pad * 2) + 'px';
const last = tourIdx === TOUR_STEPS.length - 1;
bubble.innerHTML =
'<div class="tour-bubble-title">' + step.title + '</div>' +
'<div class="tour-bubble-body">' + step.body + '</div>' +
'<div class="tour-bubble-foot">' +
'<span class="tour-bubble-count">' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '</span>' +
'<span class="tour-bubble-btns">' +
'<button class="btn btn-ghost btn-sm" id="tourSkip">Skip</button>' +
'<button class="btn btn-primary btn-sm" id="tourNext">' +
(last ? 'Done' : 'Next') + '</button>' +
'</span></div>';
bubble.querySelector('#tourSkip').onclick = endTour;
bubble.querySelector('#tourNext').onclick = () => { tourIdx++; showTourStep(); };
// Place the bubble below the target if there's room, else above.
bubble.style.visibility = 'hidden';
bubble.style.top = '0px'; bubble.style.left = '0px';
const bh = bubble.offsetHeight, bw = bubble.offsetWidth;
const gap = 12;
let top = r.bottom + gap;
if (top + bh > window.innerHeight - 8) top = Math.max(8, r.top - gap - bh);
let left = r.left;
if (left + bw > window.innerWidth - 8) left = window.innerWidth - 8 - bw;
bubble.style.top = Math.max(8, top) + 'px';
bubble.style.left = Math.max(8, left) + 'px';
bubble.style.visibility = '';
}
function endTour() {
tourIdx = -1;
window.removeEventListener('resize', showTourStep);
['tourBackdrop', 'tourSpot', 'tourBubble'].forEach(id => {
const n = document.getElementById(id);
if (n) n.remove();
});
markTourSeen();
}
function openQuickSearchModal(hideAfter, forFill) { function openQuickSearchModal(hideAfter, forFill) {
const modal = document.getElementById('quickSearchModal'); const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput'); const input = document.getElementById('quickSearchInput');
+39
View File
@@ -224,3 +224,42 @@ test('decryptPwd: wrong key returns "[ERROR]" (not garbage plaintext)', async ()
T.state.cryptoKey = k2.cryptoKey; T.state.cryptoKey = k2.cryptoKey;
assert.equal(await T.decryptPwd(encrypted, iv), '[ERROR]'); assert.equal(await T.decryptPwd(encrypted, iv), '[ERROR]');
}); });
// --- Metadata-at-rest (§1.3): template encrypted like username/site/... ---
test('withEncryptedMeta: template is encrypted at rest + round-trips', async () => {
assert.ok(T.ENCRYPTED_META_FIELDS.includes('template'),
'template must be an encrypted meta field');
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
const body = await T.withEncryptedMeta({
username: 'u', site: 's', title: 't', tags: '', template: 'credit-card',
});
assert.equal(body.template, '', 'cleartext template blanked on write');
assert.ok(body.template_enc && body.template_iv, 'ciphertext written');
assert.notEqual(body.template_enc, 'credit-card', 'not stored in cleartext');
// GET returns the ciphertext (cleartext column blanked) → decrypt restores it.
const row = { template: '', template_enc: body.template_enc, template_iv: body.template_iv };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'credit-card');
});
test('withEncryptedMeta: omitting template preserves it (partial re-ship guard)', async () => {
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
// add-tag / move-to-folder bodies carry no `template` key. The choke point
// must NOT synthesise template_enc='' — that would make the server wipe the
// stored template (LHasTemplate fires on the present-but-empty key).
const body = await T.withEncryptedMeta({ site: 's', title: 't', username: 'u', tags: 'x' });
assert.ok(!('template' in body), 'no cleartext template key added');
assert.ok(!('template_enc' in body), 'no ciphertext template key added');
});
test('decryptEntryMeta: un-migrated row keeps its cleartext template', async () => {
// Row predates encryption: cleartext `template` present, no template_enc.
const row = { template: 'ssh-key' };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'ssh-key', 'cleartext preserved until migration');
});
+2
View File
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below) // merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot, applyRemoteSnapshot, buildSyncSnapshot,
// metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
}; };
`; `;