diff --git a/CLAUDE.md b/CLAUDE.md index dbefa83..bd059b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -444,20 +444,27 @@ bulk ne les référencent plus (helpers `AddNullableField`/`BindNullable`) ; `loadEntries`/`loadTrash` déchiffrent → `e.` 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` / diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index 834080b..3a960b4 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -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 `_enc/_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 diff --git a/css/style-legacy.css b/css/style-legacy.css deleted file mode 100644 index f15b100..0000000 --- a/css/style-legacy.css +++ /dev/null @@ -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); } \ No newline at end of file diff --git a/css/style.css b/css/style.css index b7bf704..ecede60 100644 --- a/css/style.css +++ b/css/style.css @@ -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; } diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index cafac35..fe7ddbe 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -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('title_iv', ''); var LGEnc := LEntry.GetValue('tags_enc', ''); var LGIv := LEntry.GetValue('tags_iv', ''); + var LTplEnc := LEntry.GetValue('template_enc', ''); + var LTplIv := LEntry.GetValue('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 diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index b93a815..2279cc5 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -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('custom_fields_iv', ''); LIcon := LBody.GetValue('icon_b64', ''); LTemplate:= Trim(LBody.GetValue('template', '')); + LTemplateEnc := LBody.GetValue('template_enc', ''); + LTemplateIv := LBody.GetValue('template_iv', ''); // Caller may bring its own UUID (sync restore / import preserving // identity). Otherwise the server mints a fresh one. LUuid := Trim(LBody.GetValue('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('custom_fields', ''); LCfIv := LBody.GetValue('custom_fields_iv', ''); // Template is only updated when the caller explicitly sends it — - // partial PUTs (drag-to-folder) must not wipe it. + // 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('template', '')); + LTemplateEnc := LBody.GetValue('template_enc', ''); + LTemplateIv := LBody.GetValue('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('custom_fields_iv', ''); LIcon := LEntry.GetValue('icon_b64', ''); LTemplate:= Trim(LEntry.GetValue('template', '')); + LTemplateEnc := LEntry.GetValue('template_enc', ''); + LTemplateIv := LEntry.GetValue('template_iv', ''); LUuid := Trim(LEntry.GetValue('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('created_at', '')); + if LCreatedAt = '' then LCreatedAt := LNow; + LUpdatedAt := Trim(LEntry.GetValue('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)); diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index f1dce6e..039c628 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -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. diff --git a/index-legacy.html b/index-legacy.html deleted file mode 100644 index f3e900f..0000000 --- a/index-legacy.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - - - 🔐 Vault — Legacy UI - - - -
-
-

⏰ Auto-lock

-

Vault locks in 30s

- -
- -
-
-

✏️ Edit Entry

- - - -
- - -
- - - -
- - -
-
-
- - - - - - -
-
-

🔐 Vault XAMPP

- -
- - -
-
- - -
-
-
- - - - -
-
- -
- - - -
- - - - - - - \ No newline at end of file diff --git a/index.html b/index.html index 53d5482..9344ad4 100644 --- a/index.html +++ b/index.html @@ -131,10 +131,6 @@ Unlock - @@ -422,6 +418,16 @@
+
+
Getting started
+

+ A quick guided tour of the main features — search, autofill, + tools and sync. +

+ +
Appearance
@@ -515,6 +521,38 @@
+
+
Startup & tray
+
+ + Show tray notifications + + 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. + + + +
+
+ + Start with Windows + + 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). + + + +
+
+
Security
@@ -574,34 +612,6 @@
-
- - Show tray notifications - - 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. - - - -
-
- - Start with Windows - - 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). - - - -
Autofill
@@ -649,10 +659,11 @@
Clipboard privacy

- Windows keeps copied passwords in clipboard history (Win+V). - To prevent that, disable history in Settings → System → Clipboard. + Passwords copied from this app are excluded from Windows + clipboard history (Win+V) and cloud clipboard, and cleared + automatically when the app is minimised — no manual setup needed.

- +
@@ -819,11 +830,15 @@ No PIN set.

- 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. Master password is still required for sensitive actions (export, change master password, recovery code…).

-
+
+ + +
+
Unlock method Master pw (default), PIN only, or both required. @@ -834,10 +849,6 @@
-
- - -
diff --git a/js/app-legacy.js b/js/app-legacy.js deleted file mode 100644 index 82093a6..0000000 --- a/js/app-legacy.js +++ /dev/null @@ -1,1550 +0,0 @@ -// Backend detection: Apache/XAMPP serves under /password-manager/, Delphi at root. -const API = (window.location.pathname.indexOf('/password-manager/') === 0) - ? '/password-manager/api.php' - : ''; -let token = sessionStorage.getItem('authToken'); -let csrfToken = sessionStorage.getItem('csrfToken') || ''; -let curUser = sessionStorage.getItem('currentUsername'); -function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } -function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; } -let view = localStorage.getItem('vaultView') || 'grid'; -let detailIndex = 0; -let showView = localStorage.getItem('showViewBtn') !== 'false'; -let showMail = localStorage.getItem('showEmail') !== 'false'; -let dark = localStorage.getItem('darkTheme') !== 'false'; -let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5'); -let order = JSON.parse(localStorage.getItem('entryOrder') || '[]'); -let selectedFolder = localStorage.getItem('selectedFolder') || 'All'; -let entries = []; -let folders = ['All']; -let genPwdVal = ''; -let cryptoKey = null; -let searchQuery = ''; -let idleT, warnT, countT; -let _loadVer = 0; -let draggedId = null; -let showTrash = false; -let selectedIds = new Set(); -let lastSelectedId = null; -let arrowAnchor = -1; -let arrowFocus = -1; -let rectState = { active: false, startX: 0, startY: 0, el: null, started: false }; - -// ==================== SOUND ==================== -let soundEnabled = localStorage.getItem('soundEnabled') !== 'false'; -let audioCtx = null; - -function getAudioContext() { - if (!audioCtx) { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - } - return audioCtx; -} - -function playTone(freq, duration, type = 'sine', volume = 0.08) { - if (!soundEnabled) return; - try { - const ctx = getAudioContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = type; - osc.frequency.setValueAtTime(freq, ctx.currentTime); - gain.gain.setValueAtTime(volume, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(ctx.currentTime); - osc.stop(ctx.currentTime + duration); - } catch (e) { /* ignore */ } -} - -function playSound(type) { - if (!soundEnabled) return; - switch (type) { - case 'click': playTone(800, 0.08, 'sine', 0.06); break; - case 'success': - playTone(523, 0.1, 'sine', 0.1); - setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 100); - setTimeout(() => playTone(784, 0.15, 'sine', 0.1), 200); - break; - case 'error': - playTone(200, 0.2, 'square', 0.06); - setTimeout(() => playTone(150, 0.3, 'square', 0.06), 150); - break; - case 'delete': - playTone(150, 0.15, 'triangle', 0.08); - break; - case 'copy': - playTone(1200, 0.05, 'sine', 0.07); - break; - case 'generate': - playTone(440, 0.05, 'sine', 0.05); - setTimeout(() => playTone(554, 0.05, 'sine', 0.05), 60); - setTimeout(() => playTone(659, 0.05, 'sine', 0.05), 120); - setTimeout(() => playTone(880, 0.1, 'sine', 0.07), 180); - break; - case 'open': - playTone(600, 0.12, 'sine', 0.06); - setTimeout(() => playTone(800, 0.1, 'sine', 0.06), 80); - break; - case 'close': - playTone(800, 0.08, 'sine', 0.05); - setTimeout(() => playTone(600, 0.1, 'sine', 0.05), 80); - break; - case 'login': - playTone(523, 0.1, 'sine', 0.08); - setTimeout(() => playTone(659, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(784, 0.2, 'sine', 0.1), 200); - break; - case 'register': - playTone(440, 0.1, 'sine', 0.08); - setTimeout(() => playTone(554, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(659, 0.15, 'sine', 0.1), 200); - break; - } -} - -function toggleSound() { - soundEnabled = !soundEnabled; - localStorage.setItem('soundEnabled', soundEnabled); - if (soundEnabled) playTone(440, 0.05); - syncSettingsUI(); -} - -// ==================== TOAST ==================== -function toast(m, t, action) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.innerHTML = '' + m + ''; if (action) { const btn = document.createElement('button'); btn.className = 'toast-action'; btn.textContent = action.label; btn.onclick = function(e) { e.stopPropagation(); action.cb(); d.remove(); }; d.appendChild(btn); c.appendChild(d); } else { c.appendChild(d); setTimeout(() => d.remove(), 3000); } } -function showZigzagToast(elem, msg, type) { - const t = document.createElement('div'); - t.className = 'toast-zigzag ' + (type || 'success'); - t.textContent = msg; - document.body.appendChild(t); - const r = elem.getBoundingClientRect(); - t.style.left = r.left + 'px'; - t.style.top = r.top + 'px'; - setTimeout(() => t.remove(), 1500); -} - -// ==================== THEME ==================== -function applyTheme() { document.body.classList.toggle('light', !dark); } -function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); syncSettingsUI(); playSound('click'); } - -// ==================== CRYPTO ==================== -function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); } -async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; } -async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } } -async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); } -async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } } - -// ==================== AUTO-LOCK ==================== -function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); } -function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } } - -// ==================== USERNAME ==================== -function saveUsername() { const f = document.getElementById('addUsername'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); } -function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('addUsername'); if (s && f) f.value = s; } - -// ==================== FOLDERS ==================== -async function loadFolders() { - if (!token) return; - try { - const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } }); - if (r.ok) { - const data = await r.json(); - if (Array.isArray(data)) { - folders = data.filter(f => typeof f === 'string'); - if (!folders.includes('All')) folders.unshift('All'); - } else { - folders = ['All']; - } - } else { - folders = ['All']; - } - } catch (e) { - folders = ['All']; - } -} - -async function addFolderToServer(name) { - try { - const r = await fetch(API + '/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ name }) - }); - if (r.ok) { await loadFolders(); return true; } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); - return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } -} - -async function deleteFolderFromServer(name) { - try { - const r = await fetch(API + '/folders/' + encodeURIComponent(name), { - method: 'DELETE', - headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } - }); - if (r.ok) { - await loadFolders(); - if (selectedFolder === name) { selectedFolder = 'All'; localStorage.setItem('selectedFolder', 'All'); } - return true; - } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); - return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } -} - -function folderColor(name) { - if (name === 'All') return ''; - let hash = 0; - for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); - const hue = ((hash % 360) + 360) % 360; - return `style="--chip-color:hsl(${hue},60%,55%)"`; -} -function renderFolders() { - const bar = document.getElementById('foldersBar'); - if (!bar) return; - entries.forEach(e => { if (!e.folder || typeof e.folder !== 'string') e.folder = 'All'; }); - const counts = {}; - entries.forEach(e => { const f = e.folder; counts[f] = (counts[f] || 0) + 1; }); - let html = ''; - folders.forEach(f => { - if (!f) return; - const count = counts[f] || 0; - const color = folderColor(f); - html += `📁 ${esc(f)}${count}${f !== 'All' ? `` : ''}`; - }); - html += ``; - bar.innerHTML = html; - // Make folders drop targets for moving entries - bar.querySelectorAll('.folder-chip[data-folder]').forEach(chip => { - chip.addEventListener('dragover', e => { e.preventDefault(); chip.classList.add('drag-over'); }); - chip.addEventListener('dragleave', () => chip.classList.remove('drag-over')); - chip.addEventListener('drop', async function(e) { - e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const entry = entries.find(x => x.id === id); - if (!entry) return; - const folder = this.dataset.folder; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - let moved = 0; - for (const sid of ids) { - const e2 = entries.find(x => x.id === sid); - if (!e2 || e2.folder === folder) continue; - const enc = await encryptPwd(e2.password); - const r = await fetch(API + '/entries/' + sid, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e2.site, username: e2.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - if (r.ok) { e2.folder = folder; moved++; } - } - renderFolders(); - render(); - if (moved) playSound('success'); - }); - }); -} - - - -function selectFolder(f) { - selectedFolder = f; - localStorage.setItem('selectedFolder', f); - renderFolders(); - populateFolderSelects(); - render(); - playSound('click'); -} - -function showAddFolderModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📁 New Folder

`; - document.body.appendChild(overlay); - document.getElementById('cancelAddFolder').onclick = () => overlay.remove(); - document.getElementById('confirmAddFolder').onclick = async () => { - const name = document.getElementById('newFolderName').value.trim(); - if (!name) { toast('Enter a name', 'error'); return; } - const ok = await addFolderToServer(name); - if (ok) { renderFolders(); populateFolderSelects(); overlay.remove(); toast('📁 Folder created!'); playSound('success'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); -} - -function showDeleteFolderConfirm(folderName) { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

🗑️ Delete Folder

Delete "${folderName}"? Entries move to "All".

`; - document.body.appendChild(overlay); - document.getElementById('cancelDeleteFolder').onclick = () => overlay.remove(); - document.getElementById('confirmDeleteFolder').onclick = async () => { - const ok = await deleteFolderFromServer(folderName); - if (ok) { renderFolders(); populateFolderSelects(); render(); overlay.remove(); toast('📁 Folder deleted'); playSound('delete'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); -} - -// ==================== TRASH ==================== -function toggleTrash() { - showTrash = !showTrash; - const btn = document.getElementById('trashBtn'); - const actions = document.getElementById('trashActions'); - if (btn) { btn.classList.toggle('active', showTrash); btn.title = showTrash ? 'Back to entries' : 'Trash'; } - if (actions) actions.classList.toggle('hidden', !showTrash); - loadEntries(); - playSound('click'); -} -async function restoreEntry(id, noToast) { - try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { if (!noToast) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } } catch (e) { if (!noToast) toast('⚠️ Error', 'error'); } -} -async function toggleFavorite(id) { - try { await fetch(API + '/entries/' + id + '/favorite', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); const e = entries.find(x => x.id == id); if (e) e.favorite = e.favorite ? 0 : 1; renderFolders(); render(); playSound('click'); } catch (e) {} -} -async function permanentDelete(id, silent) { - const doDelete = async () => { - try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!silent) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } } catch (e) { if (!silent) toast('⚠️ Error', 'error'); } - }; - if (silent) { await doDelete(); return; } - const btn = document.querySelector('.delete-btn[data-id="' + id + '"]'); - if (btn) showBatchConfirm(btn, 'Permanently delete?', doDelete); - else showBatchConfirm(document.body, 'Permanently delete?', doDelete); -} -async function emptyTrash() { - const btn = document.querySelector('.empty-trash-btn'); - showBatchConfirm(btn || document.body, 'Delete ALL trashed entries?', async () => { - try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } - }); -} -function timeAgo(dateStr) { - if (!dateStr) return ''; - const now = new Date(); const d = new Date(dateStr + 'Z'); - const days = 30 - Math.floor((now - d) / (1000 * 60 * 60 * 24)); - return days <= 0 ? 'Expiring' : days + 'd left'; -} - -// ==================== SETTINGS ==================== -function toggleSettings() { - document.getElementById('settingsMenu').classList.toggle('hidden'); -} -function syncSettingsUI() { - document.getElementById('soundToggleSwitch').classList.toggle('active', soundEnabled); - document.getElementById('themeToggleSwitch').classList.toggle('active', !dark); - document.getElementById('showViewBtnToggle').classList.toggle('active', showView); - document.getElementById('showEmailToggle').classList.toggle('active', showMail); - document.getElementById('autoLockTimer').value = lockMin; -} -async function registerPasskey() { - try { - const r = await fetch(API + '/passkey/register/begin', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } - }); - if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; } - const opts = await r.json(); - opts.challenge = b642ab(opts.challenge); - opts.user.id = b642ab(opts.user.id); - if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } - const cred = await navigator.credentials.create({ publicKey: opts }); - const result = { - id: cred.id, - response: { - clientDataJSON: a2b64(cred.response.clientDataJSON), - attestationObject: a2b64(cred.response.attestationObject) - } - }; - const r2 = await fetch(API + '/passkey/register/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify(result) - }); - if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); } - else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); } -} - -// ==================== INIT ==================== -function init() { - document.getElementById('autoLockTimer').value = lockMin; - applyTheme(); - // document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; - loadUsername(); - const sl = localStorage.getItem('savedLoginUser'); - if (sl) document.getElementById('loginUsername').value = sl; - syncSettingsUI(); - // View dropdown - const vdd = document.getElementById('viewDropdown'); - const vBtn = document.getElementById('viewDropdownBtn'); - const vMenu = document.getElementById('viewDropdownMenu'); - const vIcons = {grid:'🟫',compact:'📝',list:'📋',table:'📊',card:'🃏',grouped:'📂',detail:'🔍'}; - const updateViewBtn = () => { vBtn.textContent = (vIcons[view] || '🟫') + ' ' + view.charAt(0).toUpperCase() + view.slice(1) + ' ▾'; }; - updateViewBtn(); - vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.toggle('active', o.dataset.view === view)); - vBtn.onclick = (e) => { e.stopPropagation(); vMenu.classList.toggle('hidden'); }; - vMenu.onclick = (e) => { - const opt = e.target.closest('.view-opt'); - if (!opt) return; - vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.remove('active')); - opt.classList.add('active'); - view = opt.dataset.view; - detailIndex = 0; - localStorage.setItem('vaultView', view); - updateViewBtn(); - render(); - playSound('click'); - }; - document.addEventListener('click', () => vMenu.classList.add('hidden')); - const clearBtn = document.getElementById('clearSearchBtn'); - if (clearBtn) clearBtn.style.display = 'none'; - document.getElementById('addModal').addEventListener('click', e => { - if (e.target === e.currentTarget) closeAdd(); - }); - // Close settings when clicking outside - document.addEventListener('click', (e) => { - const menu = document.getElementById('settingsMenu'); - const btn = document.getElementById('settingsBtn'); - if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) { - menu.classList.add('hidden'); - } - }); -} - -function toggleViewBtn() { showView = !showView; localStorage.setItem('showViewBtn', showView); syncSettingsUI(); render(); playSound('click'); } -function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEmail', showMail); syncSettingsUI(); render(); playSound('click'); } - -// ==================== GENERATOR ==================== -function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); playSound('open'); } -function closeGen() { document.getElementById('genModal').style.display = 'none'; playSound('close'); } -function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); } -function genPreset(len, chars) { - document.getElementById('pwdLen').value = len; - document.getElementById('lenVal').textContent = len; - document.getElementById('useUpper').checked = chars.includes('upper'); - document.getElementById('useLower').checked = chars.includes('lower'); - document.getElementById('useNum').checked = chars.includes('num'); - document.getElementById('useSym').checked = chars.includes('sym'); - genPwd(); - playSound('click'); -} -function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; const max = 256 - (256 % c.length); const buf = new Uint8Array(1); for (let i = 0; i < l; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += c.charAt(buf[0] % c.length); } genPwdVal = p; document.getElementById('genPreview').textContent = p; } -function useGen() { - if (!genPwdVal) genPwd(); - // Put the generated password into the add‑modal’s password field - const pwdField = document.getElementById('addPassword'); - if (pwdField) { - pwdField.value = genPwdVal; - checkAddStrength(); // update the strength bar - } - navigator.clipboard.writeText(genPwdVal); - toast('🎲 Copied!'); - closeGen(); // closes the generator modal, not the add modal -} -function populateFolderSelects() { - ['addFolder', 'editFolder'].forEach(id => { - const select = document.getElementById(id); - if (!select) return; - select.innerHTML = ''; - folders.forEach(f => { - if (!f) return; - const option = document.createElement('option'); - option.value = f; - option.textContent = '📁 ' + f; - if (f === selectedFolder) option.selected = true; - select.appendChild(option); - }); - }); -} -function openAdd() { - document.getElementById('addSite').value = ''; - document.getElementById('addPassword').value = ''; - document.getElementById('addStrengthBar').className = 'strength-bar s0'; - loadUsername(); - populateFolderSelects(); - document.getElementById('addModal').classList.add('show'); - document.getElementById('addSite').focus(); - playSound('open'); -} - -function closeAdd() { - document.getElementById('addModal').classList.remove('show'); - playSound('close'); -} - -function checkRegStrength() { - const p = document.getElementById('regPassword').value; - const bar = document.getElementById('regStrengthBar'); - let s = 0; - if (p.length >= 8) s++; - if (p.length >= 12) s++; - if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; - if (/\d/.test(p)) s++; - if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; - bar.className = 'strength-bar s' + Math.min(4, s); -} -function checkAddStrength() { - const p = document.getElementById('addPassword').value; - const bar = document.getElementById('addStrengthBar'); - let s = 0; - if (p.length >= 8) s++; - if (p.length >= 12) s++; - if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; - if (/\d/.test(p)) s++; - if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; - bar.className = 'strength-bar s' + Math.min(4, s); -} -// ==================== AUTH ==================== -function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); } - -async function loginWithPasskey() { - if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } - const u = document.getElementById('loginUsername').value.trim(); - if (!u) { toast('Enter username first', 'error'); return; } - document.getElementById('loginBtn').disabled = true; - try { - const r = await fetch(API + '/passkey/login/begin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: u }) - }); - if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; } - const opts = await r.json(); - opts.challenge = b642ab(opts.challenge); - opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); }); - const cred = await navigator.credentials.get({ publicKey: opts }); - const result = { - id: cred.id, - response: { - clientDataJSON: a2b64(cred.response.clientDataJSON), - authenticatorData: a2b64(cred.response.authenticatorData), - signature: a2b64(cred.response.signature), - userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null - } - }; - const r2 = await fetch(API + '/passkey/login/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(result) - }); - const d = await r2.json(); - if (r2.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u; - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', curUser); - // Try to restore crypto key from sessionStorage - const restored = await restoreCryptoKey(); - if (!restored) { - // Need master password once to derive crypto key - const mp = await new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

🔑 One more step

Enter your master password to unlock the vault

`; - document.body.appendChild(overlay); - document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value); - overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } }); - }); - const m = document.querySelector('.custom-modal-overlay.show'); - if (m) m.remove(); - cryptoKey = await deriveKey(mp, d.salt); - persistCryptoKey(); - } - await loadFolders(); - toast('✅ Biometric login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } -} -async function login() { - const u = document.getElementById('loginUsername').value.trim(); - const p = document.getElementById('loginPassword').value; - if (!u || !p) { toast('Fill all fields', 'error'); return; } - document.getElementById('loginBtn').disabled = true; - localStorage.setItem('savedLoginUser', u); - try { - const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } -} - -async function register() { - const u = document.getElementById('regUsername').value.trim(); - const p = document.getElementById('regPassword').value; - if (u.length < 3) { toast('Username min 3', 'error'); return; } - if (p.length < 8) { toast('Password min 8', 'error'); return; } - document.getElementById('registerBtn').disabled = true; - try { - const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Created!'); - playSound('register'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('registerBtn').disabled = false; } -} - -async function doLogout() { - saveUsername(); - if (token) { - try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {} - } - clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); - document.getElementById('idleWarning').classList.remove('show'); - token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false; - sessionStorage.clear(); - document.getElementById('authSection').classList.remove('hidden'); - document.getElementById('vaultSection').classList.add('hidden'); - document.getElementById('loginPassword').value = ''; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; - document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); -} - -function showVault() { - document.getElementById('authSection').classList.add('hidden'); - document.getElementById('vaultSection').classList.remove('hidden'); - document.getElementById('currentUser').textContent = '👤 ' + curUser; - //document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; - document.getElementById('autoLockTimer').value = lockMin; - loadUsername(); - renderFolders(); - populateFolderSelects(); - syncSettingsUI(); - document.querySelectorAll('.fab').forEach(b => b.classList.remove('hidden')); - resetIdle(); -} - -// ==================== ENTRIES ==================== -function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; } - -async function loadEntries(q) { - const ver = ++_loadVer; - try { - let url = API + '/entries?deleted=' + (showTrash ? '1' : '0'); - if (q) url += '&search=' + encodeURIComponent(q); - const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }); - if (ver !== _loadVer) return; - if (r.ok) { - const raw = await r.json(); - if (ver !== _loadVer) return; - entries = []; - for (const e of raw) { - if (e.encryption_method === 'client') { - const pw = await decryptPwd(e.encrypted_password, e.iv); - entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } else { - entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } - } - entries = applyOrder(entries); - entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); - document.getElementById('connectionStatus').textContent = '🟢 Connected'; - document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)'; - renderFolders(); - populateFolderSelects(); - render(); - } else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); } - } catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); } -} - -function getFilteredEntries(noFolder) { - if (showTrash) return entries; - if (noFolder || selectedFolder === 'All') return entries; - return entries.filter(e => (e.folder || 'All') === selectedFolder); -} - -function getGridCols() { - const c = document.getElementById('entriesContainer'); - if (!c || !c.firstElementChild) return 1; - const w = c.firstElementChild.offsetWidth; - const gap = parseInt(getComputedStyle(c).columnGap) || 0; - return Math.max(1, Math.round(c.offsetWidth / (w + gap))); -} - -// ==================== RENDER ==================== -function render() { - const c = document.getElementById('entriesContainer'); - c.className = ''; - if (showTrash) c.classList.add('trash-view'); - c.classList.add(view + '-view'); - const filtered = getFilteredEntries(); - if (!filtered.length) { c.innerHTML = '
' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '
'; return; } - if (view === 'table') { - let h = '' + (showMail ? '' : '') + '' + (!showTrash ? '' : '') + ''; - filtered.forEach(e => { - const sel = selectedIds.has(e.id); - h += '' + - '' + - '' + - (showMail ? '' : '') + - ''; - if (!showTrash) { - h += '' + - ''; - } else { - h += '' + - ''; - } - h += ''; - }); - h += '
SiteUserPasswordFolderDeletedActions
' + (e.favorite ? '⭐' : '') + '🌐 ' + highlightText(e.site, searchQuery) + '👤 ' + highlightText(e.username, searchQuery) + '••••••••📁 ' + esc(e.folder || 'All') + '' + - ' ' + - ' ' + - ' ' + - '' + - '🗑️ ' + timeAgo(e.deleted_at) + '' + - ' ' + - '' + - '
'; - c.innerHTML = h; - } else if (view === 'grouped') { - c.innerHTML = groupedC(getFilteredEntries(true)); - } else if (view === 'detail') { - c.innerHTML = detailC(getFilteredEntries(view === 'grouped')); - } else { - c.innerHTML = filtered.map(e => { - if (view === 'grid' || view === 'card') return gridC(e); - if (view === 'compact') return compC(e); - return listC(e); - }).join(''); - } - attachEvents(); - setupDrag(); -} - -function gridC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + '
'; - html += '
📁 ' + esc(e.folder || 'All') + '
'; - if (!showTrash) { - html += '
••••••••
' + - '
'; - } else { - html += '
🗑️ ' + timeAgo(e.deleted_at) + '
'; - } - html += '
'; - return html; -} -function listC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - return html; -} -function compC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '🌐 ' + highlightText(e.site, searchQuery) + ''; - if (showMail) html += '👤 ' + highlightText(e.username, searchQuery) + ''; - if (!showTrash) { - html += '📁 ' + esc(e.folder || 'All') + ''; - html += '••••••••'; - html += ''; - } else { - html += '🗑️ ' + timeAgo(e.deleted_at) + ''; - } - html += '
'; - return html; -} - -function groupedC(list) { - const groups = {}; - list.forEach(e => { - const f = e.folder || 'All'; - if (!groups[f]) groups[f] = []; - groups[f].push(e); - }); - let html = ''; - for (const [folder, items] of Object.entries(groups)) { - html += '
📁 ' + esc(folder) + ' ' + items.length + '
'; - items.forEach(e => { - const sel = selectedIds.has(e.id); - html += '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - }); - } - return html; -} - -function detailC(list) { - if (detailIndex >= list.length) detailIndex = 0; - if (detailIndex < 0) detailIndex = list.length - 1; - const e = list[detailIndex]; - const hasPrev = detailIndex > 0, hasNext = detailIndex < list.length - 1; - const sel = selectedIds.has(e.id); - let html = '
'; - html += ''; - html += '' + (detailIndex + 1) + ' of ' + list.length + ''; - html += ''; - html += '
'; - html += '
'; - html += '
Site🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
Username👤 ' + highlightText(e.username, searchQuery) + '
'; - if (!showTrash) { - html += '
Password••••••••
'; - html += '
Folder📁 ' + esc(e.folder || 'All') + '
'; - html += '
' + - '' + - '' + - '' + - '' + - '
'; - } else { - html += '
Deleted🗑️ ' + timeAgo(e.deleted_at) + '
'; - html += '
' + - '' + - '' + - '
'; - } - html += '
'; - return html; -} - -function goDetail(dir) { - const list = getFilteredEntries(); - detailIndex += dir; - if (detailIndex < 0) detailIndex = list.length - 1; - if (detailIndex >= list.length) detailIndex = 0; - render(); -} - -// ==================== EVENTS ==================== -function showConfirm(btn, message, callback) { - const id = btn.dataset.id; - const existing = document.querySelector('.custom-confirm'); - if (existing) existing.remove(); - - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - document.body.appendChild(confirm); - - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - confirm.offsetWidth + rect.width; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - - const cleanup = () => { - confirm.remove(); - document.removeEventListener('keydown', keyHandler); - }; - - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { - e.preventDefault(); - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - } else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { - e.preventDefault(); - cleanup(); - } - }; - - yesBtn.onclick = () => { - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - }; - noBtn.onclick = () => cleanup(); - - // Focus the confirm box so keyboard events are captured - confirm.tabIndex = 0; - confirm.focus(); - document.addEventListener('keydown', keyHandler); - - // Close if clicking outside - setTimeout(() => { - document.addEventListener('click', function closeConfirm(e) { - if (!confirm.contains(e.target) && e.target !== btn) { - cleanup(); - document.removeEventListener('click', closeConfirm); - } - }); - }, 10); -} -function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.password : ''; } -function showBatchConfirm(btn, message, callback) { - const existing = document.querySelector('.batch-confirm-overlay'); - if (existing) existing.remove(); - const overlay = document.createElement('div'); - overlay.className = 'batch-confirm-overlay'; - overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:transparent;'; - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.style.cssText = 'position:fixed;background:var(--bg2);border:1px solid var(--accent);border-radius:0.8rem;padding:0.7rem 1rem;z-index:10000;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-size:0.8rem;color:var(--text);white-space:nowrap;'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - 20; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - overlay.appendChild(confirm); - document.body.appendChild(overlay); - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - const cleanup = () => { overlay.remove(); document.removeEventListener('keydown', keyHandler); }; - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { e.preventDefault(); cleanup(); callback(); playSound('delete'); } - else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { e.preventDefault(); cleanup(); } - }; - yesBtn.onclick = () => { cleanup(); callback(); playSound('delete'); }; - noBtn.onclick = () => cleanup(); - overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; - confirm.tabIndex = 0; confirm.focus(); - document.addEventListener('keydown', keyHandler); -} -function attachEvents() { - document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const id = parseInt(this.dataset.id); if (showTrash) { permanentDelete(id); } else { showConfirm(this, 'Delete this entry?', async id2 => { await delEntry(id2, true); await loadEntries(); toast('📦 Moved to trash', 'success', { label: '↩ Undo', cb: async () => { await restoreEntry(id2, true); await loadEntries(); toast('↩ Restored'); playSound('success'); } }); playSound('delete'); }); } }); - document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); }); - document.querySelectorAll('.star-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); toggleFavorite(this.dataset.id); }); - document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const pw = entryPw(this.dataset.id); try { await navigator.clipboard.writeText(pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } }); - // Double-click entry to edit - document.querySelectorAll('[draggable="true"], .detail-card').forEach(el => { - el.addEventListener('dblclick', function(ev) { - const id = this.dataset.id; - if (id && !showTrash) { - ev.preventDefault(); - selectedIds.clear(); - selectedIds.add(parseInt(id)); - updateBatchBar(); - render(); - openEdit(id); - } - }); - }); - if (showView) { - document.querySelectorAll('.pw-display.pw-hover').forEach(el => { - el.addEventListener('mouseenter', function() { - const pw = entryPw(this.id.replace('p-', '')); - this.textContent = pw; - }); - el.addEventListener('mouseleave', function() { - this.textContent = '••••••••'; - }); - }); - } -} -function setupDrag() { - const c = document.getElementById('entriesContainer'); if (!c) return; - c.querySelectorAll('[draggable="true"]').forEach(el => { - el.ondragstart = function(e) { draggedId = this.dataset.id; const dragIds = selectedIds.has(parseInt(draggedId)) && selectedIds.size > 1 ? [...selectedIds] : [parseInt(draggedId)]; c.querySelectorAll('[draggable="true"]').forEach(card => { card.classList.toggle('drag-dim', dragIds.includes(parseInt(card.dataset.id))); }); e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; if (dragIds.length > 1) { const cv = document.createElement('canvas'); cv.width = 100; cv.height = 50; const g = cv.getContext('2d'); for (let i = dragIds.length - 1; i >= 0; i--) { const ox = i * 4, oy = i * 4; g.fillStyle = i === 0 ? 'rgba(30,40,55,0.9)' : 'rgba(59,130,246,0.15)'; g.fillRect(ox, oy, 80, 36); g.strokeStyle = 'rgba(255,255,255,0.15)'; g.strokeRect(ox, oy, 80, 36); } g.fillStyle = 'rgba(0,0,0,0.7)'; g.fillRect(0, 34, 100, 16); g.fillStyle = '#fff'; g.font = '11px sans-serif'; g.textAlign = 'center'; g.fillText(dragIds.length + ' items', 50, 46); cv.style.position = 'fixed'; cv.style.top = '-1000px'; document.body.appendChild(cv); e.dataTransfer.setDragImage(cv, 6, 10); setTimeout(() => cv.remove(), 50); } }; - el.ondragend = function(e) { c.querySelectorAll('.drag-dim').forEach(card => card.classList.remove('drag-dim')); draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); document.getElementById('trashBtn')?.classList.remove('drag-over'); }; - el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); }; - el.ondragleave = function(e) { this.classList.remove('drag-over'); }; - el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId) return; const ids = selectedIds.has(fromId) && selectedIds.size > 1 ? [...selectedIds] : [fromId]; if (ids.length === 1 && ids[0] === toId) return; const base = order.length > 0 ? order : entries.filter(e => e).map(e => e.id); const filtered = base.filter(id => !ids.includes(id)); const idx = filtered.indexOf(toId); idx > -1 ? filtered.splice(idx, 0, ...ids) : filtered.push(...ids); order = filtered; // Ensure no entries are lost from order -entries.forEach(e => { if (!order.includes(e.id)) order.push(e.id); }); localStorage.setItem('entryOrder', JSON.stringify(order)); const map = new Map(entries.filter(e => e).map(e => [e.id, e])); entries = order.map(id => map.get(id)).filter(e => e); entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); render(); }; - }); -} - -// ==================== EDIT ==================== -function openEdit(id) { - let e = null; - for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } } - if (!e) return; - const folderSelect = document.getElementById('editFolder'); - folderSelect.innerHTML = ''; - folders.forEach(f => { - if (!f) return; - const option = document.createElement('option'); - option.value = f; - option.textContent = '📁 ' + f; - if (f === (e.folder || 'All')) option.selected = true; - folderSelect.appendChild(option); - }); - document.getElementById('editId').value = id; - document.getElementById('editSite').value = e.site; - document.getElementById('editUsername').value = e.username; - document.getElementById('editPassword').value = e.password; - document.getElementById('editPassword').type = 'password'; - document.getElementById('editModal').classList.add('show'); - document.getElementById('editSite').focus(); - playSound('open'); -} -function closeEdit() { document.getElementById('editModal').classList.remove('show'); render(); playSound('close'); } -function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; } -async function saveEdit() { - const id = document.getElementById('editId').value; - const site = document.getElementById('editSite').value.trim(); - const username = document.getElementById('editUsername').value.trim(); - const password = document.getElementById('editPassword').value; - const folder = document.getElementById('editFolder').value; - if (!site || !password) { toast('Site and password required', 'error'); return; } - try { - const enc = await encryptPwd(password); - const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); - if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); } - else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Error', 'error'); } -} -//======================== batch selection ========================== -function toggleSelectEntry(id, e) { - if (e?.shiftKey && lastSelectedId !== null) { - const ids = getFilteredEntries().map(x => x.id); - const i1 = ids.indexOf(lastSelectedId); - const i2 = ids.indexOf(id); - if (i1 > -1 && i2 > -1) { - const start = Math.min(i1, i2), end = Math.max(i1, i2); - for (let i = start; i <= end; i++) selectedIds.add(ids[i]); - } - } else if (e?.ctrlKey || e?.metaKey) { - if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id); - } else { - if (selectedIds.size === 1 && selectedIds.has(id)) { selectedIds.clear(); } - else { selectedIds.clear(); selectedIds.add(id); } - } - lastSelectedId = id; - arrowAnchor = -1; - arrowFocus = -1; - updateBatchBar(); - render(); -} - -function clearSelection() { - selectedIds.clear(); - lastSelectedId = null; - arrowAnchor = -1; - arrowFocus = -1; - hideBatchBar(); - render(); -} - -function updateBatchBar() { - const existing = document.getElementById('batchBar'); - if (existing) existing.remove(); - if (selectedIds.size === 0) return; - const bar = document.createElement('div'); - bar.id = 'batchBar'; - bar.className = 'batch-actions'; - bar.innerHTML = `${selectedIds.size} selected`; - if (showTrash) { - bar.innerHTML += ` - - - `; - } else { - bar.innerHTML += ` - - - - `; - } - bar.innerHTML += ``; - document.body.appendChild(bar); -} - -function hideBatchBar() { - const bar = document.getElementById('batchBar'); - if (bar) bar.remove(); -} - -async function batchDelete() { - const count = selectedIds.size; - const ids = [...selectedIds]; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Move ' + count + ' entries to trash?', async () => { - for (const id of ids) await delEntry(id, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + count + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const id of ids) await restoreEntry(id, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); }, - onExpire: null - }); - playSound('delete'); - }); -} - -async function batchPermanentDelete() { - const count = selectedIds.size; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Permanently delete ' + count + ' entries?', async () => { - for (const id of selectedIds) await permanentDelete(id, true); - toast('🗑️ Permanently deleted ' + count + ' entries'); - playSound('error'); - clearSelection(); - await loadEntries(); - }); -} - -async function batchRestore() { - const count = selectedIds.size; - for (const id of selectedIds) await restoreEntry(id, true); - toast('✅ Restored ' + count + ' entries'); - playSound('success'); - clearSelection(); - await loadEntries(); -} - -async function batchMove() { - const folder = document.getElementById('batchFolder')?.value || 'All'; - for (const id of selectedIds) { - const e = entries.find(x => x.id == id); - if (e) { - e.folder = folder; - const enc = await encryptPwd(e.password); - await fetch(API + '/entries/' + id, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - } - } - clearSelection(); - loadEntries(); -} -// ==================== ADD / DELETE ==================== -async function addEntry() { - const site = document.getElementById('addSite').value.trim(); - const user = document.getElementById('addUsername').value.trim(); - const pass = document.getElementById('addPassword').value; - if (!site || !pass) { toast('❌ Site and password required', 'error'); return; } - if (user) localStorage.setItem('savedUsername', user); - const btn = document.getElementById('addEntryBtn'); - btn.disabled = true; - try { - const enc = await encryptPwd(pass); - const folder = document.getElementById('addFolder').value; - const r = await fetch(API + '/entries', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder }) - }); - if (r.ok) { - closeAdd(); - toast('✅ Saved!'); - loadEntries(); - playSound('success'); - } else { - const d = await r.json(); - toast('❌ ' + (d.error || 'Failed'), 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } - finally { btn.disabled = false; } -} -async function delEntry(id, noToast) { - try { - const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); - if (r.ok) { selectedIds.delete(id); order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!noToast) { toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } } - } catch (e) { if (!noToast) toast('Error', 'error'); } -} - -// ==================== UTILS ==================== -function searchEntries() { - const input = document.getElementById('searchInput'); - searchQuery = input.value.trim(); - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = searchQuery ? 'block' : 'none'; - loadEntries(searchQuery); -} -function showExportModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📤 Export Passwords

Re-enter master password to export plaintext passwords

`; - document.body.appendChild(overlay); - document.getElementById('cancelExport').onclick = () => overlay.remove(); - document.getElementById('confirmExport').onclick = async () => { - const pwd = document.getElementById('exportPassword').value; - if (!pwd) { toast('Enter your master password', 'error'); return; } - try { - const r = await fetch(API + '/reauth', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ masterPassword: pwd }) - }); - if (r.ok) { - overlay.remove(); - const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(b); - a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; - a.click(); - URL.revokeObjectURL(a.href); - toast('Exported!'); - playSound('success'); - } else { - toast('❌ Invalid password', 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); -} -function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } -function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function highlightText(text, query) { - if (!query || !query.trim()) return esc(text); - const re = new RegExp('(' + escRegex(query.trim()) + ')', 'gi'); - return esc(text).replace(re, '$1'); -} -function showShortcutsHelp() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

⌨️ Keyboard Shortcuts

Alt+NNew entryCtrl+ASelect allCtrl+FSearchAlt+TToggle trashCtrl+LLock vaultCtrl+SSave entryDelDelete selectedEscClose modal / deselect◀ ▶Detail view nav?Show this help

💡 Click any entry to select, Shift+click for range, Ctrl+click to toggle

`; - document.body.appendChild(overlay); - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); -} -function clearSearch() { - const input = document.getElementById('searchInput'); - input.value = ''; - searchQuery = ''; - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = 'none'; - loadEntries(); - input.focus(); -} -// ==================== STARTUP – session persistence ==================== -init(); -applyTheme(); - -if (token && curUser) { - (async () => { - if (await restoreCryptoKey()) { - await loadFolders(); - showVault(); - loadEntries(); - } else { - sessionStorage.clear(); - token = null; - curUser = null; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; - } - })(); -} - -['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); })); -// Trash button as drop target (set up once, outside setupDrag to avoid duplicates) -(function() { - const trashBtn = document.getElementById('trashBtn'); - if (trashBtn) { - trashBtn.addEventListener('dragover', e => { if (!showTrash) { e.preventDefault(); trashBtn.classList.add('drag-over'); } }); - trashBtn.addEventListener('dragleave', () => trashBtn.classList.remove('drag-over')); - trashBtn.addEventListener('drop', async function(e) { - e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - for (const sid of ids) await delEntry(sid, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + ids.length + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const sid of ids) await restoreEntry(sid, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); } - }); - playSound('delete'); - }); - } -})(); - -// Prevent native drag on non-card elements (table headers, text, etc.) -document.getElementById('entriesContainer').addEventListener('dragstart', function(e) { - if (!e.target?.closest?.('[draggable="true"]')) e.preventDefault(); -}); - -// Document mousedown: rect selection on vault background, clear outside vault -document.addEventListener('mousedown', function(e) { - if (e.button !== 0 || rectState.active) return; - if (e.target?.closest?.('.entry-card,.entry-row,.entry-compact,.table-row-drag,.detail-card,.detail-nav,#batchBar,.custom-modal-overlay.show,.edit-modal.show,.modal-overlay.show,#genModal,#settingsMenu,.batch-confirm-overlay')) return; - if (e.target?.closest?.('button,input,select,.folders-bar,.toolbar,#trashActions,.settings-dropdown,.fab,.auth-section')) { - if (selectedIds.size > 0) clearSelection(); - return; - } - if (e.target?.closest?.('.vault')) { - rectState.active = true; - rectState.startX = e.clientX; - rectState.startY = e.clientY; - rectState.started = false; - rectState.el = null; - selectedIds.clear(); - lastSelectedId = null; - hideBatchBar(); - document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); - } else if (selectedIds.size > 0) { - clearSelection(); - } -}); -document.addEventListener('mousemove', function(e) { - if (!rectState.active) return; - const dx = e.clientX - rectState.startX; - const dy = e.clientY - rectState.startY; - if (!rectState.started && (dx > 5 || dx < -5 || dy > 5 || dy < -5)) { - rectState.started = true; - rectState.el = document.createElement('div'); - rectState.el.id = 'rectSelect'; - document.body.appendChild(rectState.el); - } - if (rectState.el) { - const x = Math.min(rectState.startX, e.clientX); - const y = Math.min(rectState.startY, e.clientY); - const w = Math.abs(dx); - const h = Math.abs(dy); - rectState.el.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;display:block;position:fixed;pointer-events:none;z-index:999;border:1px solid var(--accent);background:rgba(59,130,246,0.1);`; - } -}); -document.addEventListener('mouseup', function(e) { - if (!rectState.active) return; - rectState.active = false; - if (rectState.el) { - rectState.el.remove(); - rectState.el = null; - if (rectState.started) { - const r = { - left: Math.min(rectState.startX, e.clientX), - top: Math.min(rectState.startY, e.clientY), - right: Math.max(rectState.startX, e.clientX), - bottom: Math.max(rectState.startY, e.clientY) - }; - const container = document.getElementById('entriesContainer'); - if (container) { - container.querySelectorAll('.entry-card,.entry-row,.entry-compact,.table-row-drag').forEach(el => { - const er = el.getBoundingClientRect(); - if (er.left < r.right && er.right > r.left && er.top < r.bottom && er.bottom > r.top) { - const id = parseInt(el.dataset.id); - if (id) selectedIds.add(id); - } - }); - } - if (selectedIds.size > 0) { updateBatchBar(); render(); } - } - } -}); -document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { const a = document.activeElement; if (!a || a.tagName === 'BUTTON') return; e.preventDefault(); if (document.getElementById('editModal').classList.contains('show') && a.closest('.edit-box')) saveEdit(); else if (document.getElementById('addModal').classList.contains('show') && a.closest('.modal-box')) addEntry(); else if (!document.getElementById('authSection').classList.contains('hidden')) { if (a.id === 'loginUsername' || a.id === 'loginPassword') login(); else if (a.id === 'regPassword') register(); } } }); -document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); }); -document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); }); -// ==================== KEYBOARD SHORTCUTS ==================== -document.addEventListener('keydown', function(e) { - const tag = document.activeElement?.tagName; - const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - - // Escape – close any open modal or settings - if (e.key === 'Escape') { - if (rectState.active || rectState.el) { rectState.active = false; if (rectState.el) { rectState.el.remove(); rectState.el = null; } clearSelection(); return; } - if (selectedIds.size > 0) { clearSelection(); return; } - if (!document.getElementById('settingsMenu').classList.contains('hidden')) { - document.getElementById('settingsMenu').classList.add('hidden'); - return; - } - if (document.getElementById('addModal').classList.contains('show')) { closeAdd(); return; } - if (document.getElementById('editModal').classList.contains('show')) { closeEdit(); return; } - if (document.getElementById('genModal').style.display === 'flex') { closeGen(); return; } - const confirm = document.querySelector('.custom-confirm.show'); - if (confirm) confirm.remove(); - return; - } - - // Arrow keys for detail view navigation - if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && view === 'detail' && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - goDetail(e.key === 'ArrowLeft' ? -1 : 1); - return; - } - - // Arrow keys — navigate entries in vault - if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && document.getElementById('authSection').classList.contains('hidden') && view !== 'detail' && !document.getElementById('addModal').classList.contains('show') && !document.getElementById('editModal').classList.contains('show')) { - e.preventDefault(); - const filtered = getFilteredEntries(); - if (!filtered.length) return; - const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight'; - let idx = arrowFocus >= 0 ? arrowFocus : -1; - if (idx < 0 && selectedIds.size > 0) { - const firstId = [...selectedIds][0]; - idx = filtered.findIndex(e => e.id == firstId); - } - if (view === 'grid' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - if (idx < 0) idx = 0; - else { const cols = getGridCols(); if (e.key === 'ArrowDown') { const next = idx + cols; idx = next < filtered.length ? next : idx; } else { const prev = idx - cols; idx = prev >= 0 ? prev : idx; } } - } else { - if (isNext) idx = idx < filtered.length - 1 ? idx + 1 : 0; - else idx = idx > 0 ? idx - 1 : filtered.length - 1; - } - if (e.shiftKey) { - if (arrowAnchor < 0) arrowAnchor = idx; - arrowFocus = idx; - const start = Math.min(arrowAnchor, arrowFocus), end = Math.max(arrowAnchor, arrowFocus); - selectedIds.clear(); - for (let i = start; i <= end; i++) selectedIds.add(filtered[i].id); - } else { - selectedIds.clear(); - selectedIds.add(filtered[idx].id); - arrowAnchor = idx; - arrowFocus = idx; - } - updateBatchBar(); render(); playSound('click'); - return; - } - - // Enter — open edit for single selected entry - if (e.key === 'Enter' && !isInput && selectedIds.size === 1 && document.getElementById('authSection').classList.contains('hidden') && !document.getElementById('editModal').classList.contains('show') && !document.getElementById('addModal').classList.contains('show')) { - e.preventDefault(); - openEdit([...selectedIds][0]); - return; - } - - // ? or / to show shortcuts help (only in vault) - if ((e.key === '?' || e.key === '/') && !isInput) { - if (!document.getElementById('authSection').classList.contains('hidden')) return; - e.preventDefault(); - showShortcutsHelp(); - return; - } - - // Delete key — move to trash or permanently delete selected entries - if (e.key === 'Delete' && !isInput && selectedIds.size > 0 && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - if (showTrash) batchPermanentDelete(); - else batchDelete(); - return; - } - - // Alt+N — New entry (Ctrl+N intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'n' || e.key === 'N') && !isInput && !document.getElementById('addModal').classList.contains('show') && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - openAdd(); - return; - } - - // Alt+T — Toggle trash (Ctrl+T intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - toggleTrash(); - return; - } - - // Only handle Ctrl+[key], no Shift/Alt/Meta - if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; - - // Prevent browser defaults for ALL our shortcuts BEFORE dispatching - const code = e.code; - if (code === 'KeyF' || code === 'KeyL' || code === 'KeyS') { - e.preventDefault(); - } - - if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === 'a' || e.key === 'A') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id)); - updateBatchBar(); - render(); - playSound('click'); - return; - } - - if (code === 'KeyF') { - const el = document.getElementById('searchInput'); - if (el) { el.focus(); el.select(); } - } else if (code === 'KeyL') { - if (!isInput) doLogout(); - } else if (code === 'KeyS') { - if (document.getElementById('addModal').classList.contains('show')) addEntry(); - else if (document.getElementById('editModal').classList.contains('show')) saveEdit(); - } -}); -document.getElementById('loginUsername').focus(); -document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); \ No newline at end of file diff --git a/js/app.import.js b/js/app.import.js index cfbe880..7fe8958 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -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 */ } } diff --git a/js/app.js b/js/app.js index 7ad20a9..bc6a5fa 100644 --- a/js/app.js +++ b/js/app.js @@ -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 _enc/_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); diff --git a/js/app.overlays.js b/js/app.overlays.js index f35cb4d..f95bfe7 100644 --- a/js/app.overlays.js +++ b/js/app.overlays.js @@ -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 = + '
' + step.title + '
' + + '
' + step.body + '
' + + '
' + + '' + (tourIdx + 1) + ' / ' + TOUR_STEPS.length + '' + + '' + + '' + + '' + + '
'; + 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'); diff --git a/js/tests/crypto.test.js b/js/tests/crypto.test.js index 1693075..d4422dd 100644 --- a/js/tests/crypto.test.js +++ b/js/tests/crypto.test.js @@ -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'); +}); diff --git a/js/tests/harness.js b/js/tests/harness.js index 1250248..72786b9 100644 --- a/js/tests/harness.js +++ b/js/tests/harness.js @@ -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, }; `;