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:
@@ -444,20 +444,27 @@ bulk ne les référencent plus (helpers `AddNullableField`/`BindNullable`) ;
|
||||
`loadEntries`/`loadTrash` déchiffrent → `e.<f>` en mémoire, donc
|
||||
**recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est
|
||||
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
|
||||
**chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry,
|
||||
moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry,
|
||||
migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au
|
||||
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).
|
||||
**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 front cherche côté client). **La validation « Site required » serveur est
|
||||
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 :
|
||||
`folder`, `kind`, `template`, métadonnées d'attachments, nombre de lignes,
|
||||
timestamps.
|
||||
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
|
||||
|
||||
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
|
||||
@@ -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
|
||||
(`PM.Handler.Folders`). `accessed_at` est **exempté** exprès (timestamp de
|
||||
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`
|
||||
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
|
||||
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
|
||||
|
||||
+58
-3
@@ -86,8 +86,10 @@ Documenté mais à rappeler pour un futur modèle de menace :
|
||||
|
||||
- `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés**
|
||||
- `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé)
|
||||
- `vault_entries` : ~~`username`, `site`, `title`, `tags`~~ **chiffrés
|
||||
(2026-07-09)** ; `folder`, `kind`, `template` encore en clair.
|
||||
- `vault_entries` : ~~`username`, `site`, `title`, `tags`, `template`~~ **chiffrés
|
||||
(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)** :
|
||||
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
|
||||
sur les entries du compte connecté) — un compte non connecté garde son clair
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
Pour un usage **personnel, local, mono-utilisateur**, le produit est
|
||||
|
||||
@@ -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 don’t 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); }
|
||||
@@ -2207,6 +2207,29 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
||||
.settings-tab:hover { background: var(--bg-elev-2); }
|
||||
.settings-tab.is-active { color: var(--accent-fg); background: var(--accent); border-color: var(--accent); }
|
||||
.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. */
|
||||
.slideover-field.is-search-hidden,
|
||||
.setting-row.is-search-hidden { display: none; }
|
||||
|
||||
@@ -1044,6 +1044,7 @@ begin
|
||||
' site_enc = :senc, site_iv = :siv, ' +
|
||||
' title_enc = :tenc, title_iv = :tiv2, ' +
|
||||
' tags_enc = :genc, tags_iv = :giv, ' +
|
||||
' template_enc = :tplenc, template_iv = :tpliv, ' +
|
||||
' updated_at = CURRENT_TIMESTAMP ' +
|
||||
'WHERE id = :id AND user_id = :uid';
|
||||
|
||||
@@ -1065,6 +1066,8 @@ begin
|
||||
var LTIv := LEntry.GetValue<string>('title_iv', '');
|
||||
var LGEnc := LEntry.GetValue<string>('tags_enc', '');
|
||||
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
|
||||
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
|
||||
|
||||
@@ -1110,6 +1113,12 @@ begin
|
||||
else LQ.ParamByName('genc').Value := LGEnc;
|
||||
if LGIv = '' then LQ.ParamByName('giv').Clear
|
||||
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;
|
||||
end;
|
||||
// Password history is encrypted with the OLD vault key — we
|
||||
|
||||
@@ -168,11 +168,15 @@ begin
|
||||
var LKindVal := LQ.FieldByName('kind').AsString;
|
||||
if LKindVal = '' then LKindVal := 'login';
|
||||
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
|
||||
LObj.AddPair('template', TJSONNull.Create)
|
||||
else
|
||||
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).
|
||||
LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString);
|
||||
// Custom fields: opaque ciphertext + IV, treated identically to
|
||||
@@ -328,6 +332,7 @@ var
|
||||
LBody, LObj: TJSONObject;
|
||||
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
|
||||
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LTemplate, LUuid,
|
||||
LTemplateEnc, LTemplateIv,
|
||||
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
|
||||
LQ: TFDQuery;
|
||||
begin
|
||||
@@ -368,6 +373,8 @@ begin
|
||||
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||
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
|
||||
// identity). Otherwise the server mints a fresh one.
|
||||
LUuid := Trim(LBody.GetValue<string>('uuid', ''));
|
||||
@@ -401,10 +408,10 @@ begin
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' 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, ' +
|
||||
' :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;
|
||||
// site/username/title/tags cleartext columns dropped — only *_enc written.
|
||||
BindNullable(LQ, 'uenc', LUserEnc);
|
||||
@@ -444,6 +451,8 @@ begin
|
||||
LQ.ParamByName('tpl').DataType := ftString;
|
||||
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
||||
else LQ.ParamByName('tpl').AsString := LTemplate;
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('c2').AsString := LNow;
|
||||
@@ -486,7 +495,7 @@ var
|
||||
LUserId, LId: Integer;
|
||||
LBody: TJSONObject;
|
||||
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;
|
||||
LHasTemplate: Boolean;
|
||||
LQ: TFDQuery;
|
||||
@@ -528,9 +537,13 @@ begin
|
||||
LCf := LBody.GetValue<string>('custom_fields', '');
|
||||
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||
// 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;
|
||||
LTemplate := Trim(LBody.GetValue<string>('template', ''));
|
||||
LTemplateEnc := LBody.GetValue<string>('template_enc', '');
|
||||
LTemplateIv := LBody.GetValue<string>('template_iv', '');
|
||||
finally
|
||||
LBody.Free;
|
||||
end;
|
||||
@@ -581,7 +594,8 @@ begin
|
||||
// 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';
|
||||
if LHasTemplate then
|
||||
LTemplateSet := ', template=:tpl, template_enc=:tplenc, template_iv=:tpliv';
|
||||
LQ.SQL.Text :=
|
||||
'UPDATE vault_entries ' +
|
||||
'SET username_enc=:uenc, username_iv=:uiv, ' +
|
||||
@@ -631,6 +645,8 @@ begin
|
||||
LQ.ParamByName('tpl').DataType := ftString;
|
||||
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
||||
else LQ.ParamByName('tpl').AsString := LTemplate;
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
end;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('id').AsInteger := LId;
|
||||
@@ -1174,7 +1190,8 @@ var
|
||||
LBody, LObj, LEntry: TJSONObject;
|
||||
LArr, LIds: TJSONArray;
|
||||
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;
|
||||
LQ, LTomb: TFDQuery;
|
||||
begin
|
||||
@@ -1232,10 +1249,10 @@ begin
|
||||
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
|
||||
' encrypted_password, iv, encryption_method, ' +
|
||||
' 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, ' +
|
||||
' :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
|
||||
// reused across every imported entry, and FireDAC needs the
|
||||
// type set before the first .Clear call would otherwise fail
|
||||
@@ -1285,8 +1302,16 @@ begin
|
||||
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
|
||||
LIcon := LEntry.GetValue<string>('icon_b64', '');
|
||||
LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
|
||||
LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
|
||||
LTemplateIv := LEntry.GetValue<string>('template_iv', '');
|
||||
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
|
||||
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
|
||||
// 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 LTemplate = '' then LQ.ParamByName('tpl').Clear
|
||||
else LQ.ParamByName('tpl').AsString := LTemplate;
|
||||
BindNullable(LQ, 'tplenc', LTemplateEnc);
|
||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||
LQ.ParamByName('uuid').AsString := LUuid;
|
||||
LQ.ParamByName('c').AsString := LNow;
|
||||
LQ.ParamByName('c2').AsString := LNow;
|
||||
LQ.ParamByName('c').AsString := LCreatedAt;
|
||||
LQ.ParamByName('c2').AsString := LUpdatedAt;
|
||||
LQ.ExecSQL;
|
||||
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
|
||||
LIds.AddElement(TJSONNumber.Create(LNewId));
|
||||
|
||||
@@ -372,6 +372,11 @@ begin
|
||||
AddColumnIfMissing('vault_entries', 'title_iv', 'TEXT');
|
||||
AddColumnIfMissing('vault_entries', 'tags_enc', '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,...").
|
||||
// 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.
|
||||
|
||||
@@ -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
@@ -131,10 +131,6 @@
|
||||
<svg><use href="#i-unlock"/></svg>
|
||||
Unlock
|
||||
</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">
|
||||
Forgot master password? Use a recovery code
|
||||
</button>
|
||||
@@ -422,6 +418,16 @@
|
||||
<button class="settings-tab" data-tab="sync"><svg><use href="#i-rotate-ccw"/></svg>Sync & Backup</button>
|
||||
</div>
|
||||
<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-label">Appearance</div>
|
||||
<div class="setting-row">
|
||||
@@ -515,6 +521,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slideover-field">
|
||||
<div class="slideover-field-label">Startup & 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-label">Security</div>
|
||||
<div class="setting-row">
|
||||
@@ -574,34 +612,6 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</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-label">Autofill</div>
|
||||
@@ -649,10 +659,11 @@
|
||||
<div class="slideover-field">
|
||||
<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">
|
||||
Windows keeps copied passwords in <b>clipboard history (Win+V)</b>.
|
||||
To prevent that, disable history in <i>Settings → System → Clipboard</i>.
|
||||
Passwords copied from this app are <b>excluded from Windows
|
||||
clipboard history (Win+V)</b> and cloud clipboard, and cleared
|
||||
automatically when the app is minimised — no manual setup needed.
|
||||
</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 class="slideover-field">
|
||||
@@ -819,11 +830,15 @@
|
||||
No PIN set.
|
||||
</p>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
|
||||
Shortcut unlock with a 4–6 digit PIN. Stored DPAPI-encrypted on
|
||||
Shortcut unlock with a 4–12 digit PIN. Stored DPAPI-encrypted on
|
||||
this device. <b>Master password is still required for sensitive
|
||||
actions</b> (export, change master password, recovery code…).
|
||||
</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>
|
||||
Unlock method
|
||||
<small class="setting-hint">Master pw (default), PIN only, or both required.</small>
|
||||
@@ -834,10 +849,6 @@
|
||||
<option value="both">Master password + PIN</option>
|
||||
</select>
|
||||
</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 class="slideover-field">
|
||||
|
||||
-1550
File diff suppressed because it is too large
Load Diff
@@ -414,6 +414,8 @@ function parseEntriesFromJSON(text) {
|
||||
custom_fields: cf,
|
||||
attachments: atts,
|
||||
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,
|
||||
@@ -472,6 +474,10 @@ async function encryptImportEntry(plain) {
|
||||
custom_fields_iv: cfIv,
|
||||
icon_b64: plain.icon_b64 || '',
|
||||
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' }),
|
||||
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++;
|
||||
} catch (_) { /* skip the single row on failure */ }
|
||||
}
|
||||
|
||||
@@ -1493,7 +1493,7 @@ async function decryptEntryMeta(list) {
|
||||
// (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
|
||||
// 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
|
||||
// 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) {
|
||||
if (!obj) return obj;
|
||||
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] || '';
|
||||
if (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
|
||||
// path isn't blocked by file I/O + AES-GCM over the full vault.
|
||||
setTimeout(() => { runAutoBackupIfDue(); }, 5000);
|
||||
// First-run guided tour (spotlights the headline features once).
|
||||
maybeStartTour();
|
||||
}
|
||||
|
||||
async function autoPurgeTrashIfNeeded() {
|
||||
@@ -9123,8 +9131,12 @@ async function init() {
|
||||
// Autofill picker modal close button
|
||||
$$('#autofillPickerModal [data-close]').forEach(b =>
|
||||
b.addEventListener('click', closeAutofillPicker));
|
||||
$('#startTourBtn').addEventListener('click', () => {
|
||||
closeSettings();
|
||||
setTimeout(startTour, 250); // let the panel slide out first
|
||||
});
|
||||
$('#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);
|
||||
$('#exportCsvBtn').addEventListener('click', doExportCSV);
|
||||
|
||||
@@ -348,6 +348,119 @@ function closeCheatsheet() {
|
||||
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) {
|
||||
const modal = document.getElementById('quickSearchModal');
|
||||
const input = document.getElementById('quickSearchInput');
|
||||
|
||||
@@ -224,3 +224,42 @@ test('decryptPwd: wrong key returns "[ERROR]" (not garbage plaintext)', async ()
|
||||
T.state.cryptoKey = k2.cryptoKey;
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
|
||||
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
||||
// merge (async, coupled — tests stub the io seams below)
|
||||
applyRemoteSnapshot, buildSyncSnapshot,
|
||||
// metadata-at-rest
|
||||
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user