diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8aaa0f3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,440 @@ +# Password Manager — Architecture éclair + +Desktop password vault Windows. Exe Delphi FMX embarquant WebView2 qui +charge un frontend HTML/JS via Indy HTTP loopback (127.0.0.1:8765). +100 % offline, multi-utilisateur, chiffrement client-side (AES-GCM, +clé dérivée PBKDF2-SHA256 600 k iter). + +## Stack + +- **Backend** : Delphi 12, FMX, TMS FNC WebBrowser (Edge WebView2) +- **HTTP** : Indy `TIdHTTPServer` bindé 127.0.0.1 only +- **DB** : SQLite via FireDAC (`vault.db`, gitignored) +- **Frontend** : vanilla JS + CSS, pas de framework (`js/app.js` monofile ~4000 lignes) + +## Build pipeline (CRITIQUE) + +Toute modif `index.html` / `js/` / `css/` nécessite : + +1. `delphi-backend\assets\BuildAssets.cmd` → régénère `assets.res` +2. Build Delphi (F9 ou MSBuild `PMServer.dproj`) + +Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le +plus courant après modif frontend. + +## Carte des fichiers + +| Rôle | Path | +|---|---| +| Form principale, wire bridge, dispatch cmd:// | `delphi-backend/UMainForm.pas` | +| Bridge JS↔Delphi (clipboard, tray, hotkeys, autofill, focus, debug) | `delphi-backend/Source/PM.Bridge.pas` | +| HTTP server (ordre dispatch : router → embedded → static → 404) | `delphi-backend/Source/PM.HTTPServer.pas` | +| Routes API (regex match) | `delphi-backend/Source/PM.Router.pas` | +| Auth + sessions + CSRF | `delphi-backend/Source/PM.Session.pas` | +| Migrations DB idempotentes (`AddColumnIfMissing`) | `delphi-backend/Source/PM.Database.pas` | +| Static files disque (mort en prod, embedded gagne) | `delphi-backend/Source/PM.StaticFiles.pas` | +| Assets embedded dans .res | `delphi-backend/Source/PM.EmbeddedAssets.pas` | +| Quick unlock DPAPI (device-bound) | `delphi-backend/Source/PM.QuickUnlock.pas` | +| Prefs key/value DPAPI (`prefs.bin`) | `delphi-backend/Source/PM.UserPrefs.pas` | +| Single-instance mutex + broadcast | `delphi-backend/Source/PM.SingleInstance.pas` | +| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` | +| Frontend complet | `js/app.js` | +| HTML racine | `index.html` | +| Styles | `css/style.css` | + +## Bridge cmd:// (JS → Delphi) + +JS navigue vers `cmd://action/sub?params` → intercepté par +`WebBrowserBeforeNavigate` → `HandleBridgeCommand`. + +Commandes connues : +- `clipboard/copy`, `clipboard/clear`, `clipboard/read` (Paste custom menu) +- `quickunlock/{store,get,clear,status}` +- `prefs/{get,set}?key=...` (device-bound DPAPI key/value, voir plus bas) +- `autofill/{configure,hotkeys,execute,cancel}` +- `app/focus` (ramène la fenêtre au premier plan, pour le picker) +- `app/ready` (page chargée → SetFocus WebBrowser + DOM focus auth input) +- `app/theme?mode=dark|light` (sync title bar + popup menus via `SetPreferredAppMode`) +- `audit` (POST log d'action user-visible) +- `entry/new-from-title` (Ctrl+Shift+A → modal pré-rempli) + +Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`. + +## Hotkeys globaux Win32 + +- `Ctrl+Shift+L` : autofill complet (user + Tab + password) +- `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens) +- `Ctrl+Shift+A` : new entry pré-rempli avec le titre de la fenêtre foreground +- `Ctrl+Shift+D` : toggle debug panel (uniquement si `config.txt` présent) + +Combos autofill configurables depuis Settings (synced en DB +via `settings_json`). + +## Debug mode + +Fichier `config.txt` à côté de l'exe (tous champs optionnels) : +``` +port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535) +debug=true # affiche PanelTop (Start/Stop, log memo) +require_token=false # désactive le token validation (curl/Postman direct) +require_process_check=false # désactive le PID-on-socket check +``` +Sans `config.txt`, Ctrl+Shift+D ne fait rien (sécurité, panel jamais +exposé en prod). + +## HTTP server lockdown (anti-browser-direct) + +Le serveur Indy n'accepte une requête que si elle a : +- Query param `?pmt=` (sur la 1ʳᵉ navigation) → Set-Cookie +- OU cookie `pm_token=; HttpOnly; SameSite=Strict; Path=/` + +Au start : +- Port aléatoire dans `[49152, 65535]` si pas configuré +- Token random 32-chars hex généré +- WebView2 navigate vers `http://127.0.0.1:{port}/index.html?pmt={token}` +- JS strip le `?pmt=...` du URL bar via `history.replaceState` + +Requête sans token valide → `404` (pas 403 pour ne pas révéler que le +serveur existe). Override via `require_token=false`. + +### Couche 2 : PID-on-socket process check + +Sur chaque request, `PM.ProcessLockdown` : +- `GetExtendedTcpTable` (winapi `iphlpapi.dll`) lookup le PID qui possède + la connexion entrante (match sur Chrome's `localPort` + `remotePort = our port`) +- `IsDescendantOfCurrentProcess` walk parent tree via `Toolhelp32` → + notre exe est-il ancêtre ? +- Si non (curl externe, autre browser, etc.) → 404 + +Defense in depth : si quelqu'un connaît le token (leaked logs, mémoire), +il faut AUSSI exécuter le code dans notre lignée de process pour passer. +Override via `require_process_check=false`. Notre WebView2 (children +`msedgewebview2.exe`) passent naturellement. + +### Token masqué dans les logs + +`MaskAccessToken(url)` dans UMainForm remplace `?pmt=<32 chars>` par +`?pmt=***` avant `LogLine`. Évite le leak via copie/upload du log. + +## Title bar + popup menus dark mode + +`PM.Bridge.ApplyTitleBarTheme(ADark: Boolean)` fait 2 choses : + +1. `DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE=20)` → title bar. + No-op sur Win < 19044. +2. `uxtheme!SetPreferredAppMode` (ordinal #135) + `FlushMenuThemes` (#136) + — API privée stable depuis Win10 1809 utilisée par Explorer/Edge/Office. + Themea automatiquement les popup menus (tray context menu inclus), + scrollbars, tooltips. Valeurs : `ForceDark=2`, `ForceLight=3`. + +Appelé au FormCreate + via `cmd://app/theme?mode=dark|light` quand JS toggle. + +## Single instance + +Au démarrage, `PM.SingleInstance.AcquireOrSignal` : +- Crée un mutex nommé `Local\PMServer.SingleInstance.Mutex` (namespace + `Local\` = per-session, donc deux users Windows distincts peuvent + chacun lancer le leur) +- Si `ERROR_ALREADY_EXISTS` : `PostMessage(HWND_BROADCAST, WM_PMSHOW, 0, 0)` + où `WM_PMSHOW = RegisterWindowMessage('PMServer_ShowExisting')` est un + ID system-unique. Puis exit immédiat avant `Application.Initialize`. + +L'instance existante reçoit `WM_PMSHOW` dans son message-only window +(`PM.Bridge.MsgWindowHandler`) → déclenche `FOnTrayRestore` → +`RestoreFromTray`. `AllocateHWnd` crée une fenêtre top-level (pas +`HWND_MESSAGE`) donc elle reçoit bien le broadcast. + +## Tray icon + +`Shell_NotifyIcon(NIM_ADD)` au **constructor** de `TPMBridge`, pas +au premier minimize → ic ône visible dès le démarrage, même si la +fenêtre est ouverte. `NIM_DELETE` uniquement au destructor. + +`MinimizeToTray` ne touche plus à l'icône, juste à la visibilité de +la fenêtre + clipboard clear + balloon first-time. + +Menu : Open / Lock vault / Quit (via `TrackPopupMenu`, themé par +`SetPreferredAppMode` ci-dessus). + +## Device-bound prefs (`PM.UserPrefs`) + +**Problème résolu** : le serveur HTTP bind un port éphémère aléatoire +(49152-65535) sur chaque start. `localStorage` est keyed par origin +(scheme+host+**port**) → reboot = nouvelle origine = `localStorage` +wipé. Pour les prefs qui doivent survivre (`rememberedUsername`, +etc.), on persiste via DPAPI. + +- `%LOCALAPPDATA%\PMServer\prefs.bin` — DPAPI-encrypted UTF-8 JSON + `{"key":"value",...}` +- `PM.UserPrefs.GetPref(key) / SetPref(key, value)` Delphi-side +- Bridge JS : `await Bridge.getPref(key)` (Promise) / `Bridge.setPref(key, value)` +- Callback : `Bridge.onPrefResult(key, value)` posé par Delphi via + `ExecuteJavaScript`. Resolvers stockés dans `prefResolvers[key]` + avec timeout 2 s. + +`LoadAll` retourne toujours un `TJSONObject` valide (jamais nil) — +défault = objet vide, remplacé seulement sur parse success. + +## Quick unlock — interactions + +- Au boot, `init()` call `bridgeQuickUnlockStatus()` et synchronise + `state.quickUnlockEnabled` + `localStorage` depuis la source of truth + DPAPI (corrige le décalage localStorage quand le port change). +- Sur Windows lock / sleep (`BridgeSystemLock` → `Bridge.onSystemLock`) : + - Si `state.quickUnlockEnabled` → **pas de lockVault**. Le blob DPAPI + gate déjà l'accès via le compte Windows, re-locker est redondant. + Toast informatif affiché. + - Sinon → comportement d'origine (`lockVault()`). +- L'auto-lock par inactivité (`autoLockTimer`) reste indépendant — il + ignore le flag QU (opt-in utilisateur explicite via Settings). + +## Authenticator + TOTP tool + +Sidebar Tools expose 2 items MFA : + +- **Authenticator** (`state.view = 'authenticator'`) : grille de cards + (chaque entry avec TOTP secret) — code 6 chiffres en gros + barre + countdown. `renderAuthenticatorGrid()` décrypte tous les secrets + upfront puis tick 1 s. `authTickTimer` clear sur lock / view change. + Snap instantané à 100% sur reset de période (sinon CSS transition + anime le saut backward → effet "freeze"). +- **TOTP generator** (`openTotpTool()`) : modal standalone. Paste + base32 ou `otpauth://` URI → code live. Bouton "Generate" génère un + secret base32 aléatoire 20 bytes (`randomBase32Secret()`). Rien + n'est sauvegardé. + +Code TOTP retourne `{ code, period, secondsLeft }` — attention au nom, +**pas `remaining`** (utiliser `t.secondsLeft`). + +## Sidebar sections collapsibles + +Sections `Folders`, `Tags`, `Tools` ont chacune un `.section-toggle` +button (chevron + label + badge count) qui toggle `.is-collapsed` sur +la section parent. Body masqué via `display: none` sous `.is-collapsed`. + +État persisté dans `state.sidebarCollapsed = { folders, tags, tools }`, +synced via `SYNCED_SETTING_KEYS` (settings_json). Badges (`#countFolders`, +`#countTags`) restent visibles quand replié. + +## Hotkey verrouillé (UX) + +`autofillHandleRequest` : si `state.locked` ou pas de cryptoKey → +`Bridge.cancelAutofill() + Bridge.focusApp()` puis focus le master +password input via DOM. Remplace l'ancien no-op silencieux qui laissait +l'utilisateur perplexe. + +## WebView2 / TMS settings — pièges majeurs + +`TTMSFNCWebBrowser` expose des props qui semblent settables au FormCreate +mais ne sont effectivement appliquées qu'après l'init async du WebView2 +sous-jacent. **Set ces props dans `WebBrowser.OnInitialized`, pas FormCreate** : + +- `EnableContextMenu := False` → kill le menu Edge natif (Inspecter, Importer mots de passe…) +- `EnableShowDebugConsole := False` → kill DevTools +- `EnableAcceleratorKeys := False` ← **NE PAS METTRE** : casse aussi Ctrl+C/V/X/Z/A/K dans les inputs + +Le blocking F12 / Ctrl+Shift+I/J / Ctrl+U se fait côté JS (keydown +preventDefault) en complément. + +## Custom right-click menu + +Le menu natif est désactivé via TMS. On gère un mini-menu JS dédié sur +right-click dans les inputs : `installCustomContextMenu()` → Cut / Copy / +Paste / Select All. Paste utilise `Bridge.readClipboard()` (sans prompt +WebView2 contrairement à `navigator.clipboard.readText()`). `stopPropagation` +sur les click items sinon le slideover se ferme (chip retiré du DOM +avant que le doc click handler check `.closest('.slideover')`). + +## Browser shortcuts bloqués (keydown JS) + +Bloqués via `preventDefault` : +- `F12`, `Ctrl+Shift+I/J`, `Ctrl+U` — DevTools / View source +- `Ctrl+J` — Downloads overlay (le moins évident, déclenche un popup Edge) +- `Ctrl+H/S/P/T/N/R`, `F5` — History / Save / Print / New tab+win / Reload +- `Ctrl+Shift+N/W/Delete` — Incognito / Close window / Clear data + +**Conservés** : `Ctrl+C/V/X/A/Z` (édition), `Ctrl+F` (find in page), `Ctrl+0/+/-` +(zoom accessibility), `Ctrl+K` (notre command palette), `Ctrl+Shift+L/P/A/D` +(nos hotkeys). + +## Pagination + +State : `pageSize` (10/25/50/100, default 25, **synced via settings_json**) ++ `currentPage` runtime-only. Active sur les 3 view modes (cards/list/ +table) si > 10 items. Position **top** (au-dessus du grid/table). + +CSS : `.pagination { grid-column: 1 / -1 }` pour span full width en cards +view (sinon ça occupe une slot de card). + +Reset `currentPage = 1` sur : search, sort, view (folder/fav/tag), +viewMode change, pageSize change. Render via `renderPagination(total, totalPages)` ++ `computePageList(current, total)` avec ellipsis intelligente +(`1 … 4 5 6 … 12`). + +## Flash animation (nouvelle entry) + +`flashEntry(id)` appelée après `saveEntry` success + `duplicateEntry` : +`setTimeout(0)` → `querySelector('.entry-card[data-id],.entry-row[data-id]')` +→ `scrollIntoView({block:'center'})` + classe `is-flash` 2.5s +(`@keyframes entryFlash` pulse cyan). + +Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" +— scroll + pulse trouvent la nouvelle entry dans la grille triée. + +## Settings sync + +Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT +/settings`. Synced : theme, autoLock, viewMode, sortBy/sortDir, hibp, +hotkeys autofill, etc. **Device-only** (localStorage seulement) : +`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` +(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). + +## Quick Unlock + +DPAPI blob à `%LOCALAPPDATA%\PMServer\quickunlock.bin` (tied to Windows +user, **pas** à l'emplacement de l'exe — survit au déplacement). + +**Cold-start restore** : `tryQuickUnlock` ne check pas `localStorage` +comme source of truth (WebView2 user-data folder est relatif à l'exe → +move = localStorage perdu). Le fichier DPAPI seul gouverne. Si présent : +- Décrypte blob → restore key +- **Re-login fresh** via `/login` avec `verifier = bytesToHex(rawKey)` + (le token stocké dans le blob peut être expiré ; on en récupère un + nouveau à chaque cold-start) +- Resync `localStorage` à `'1'` pour que Settings affiche "Disable" + +## Recovery code (refactor 2026-05) + +- **5 uses par code** (compteur `remaining_uses` dans `recovery_keys`) +- Décrémenté à chaque redeem. Si tombe à 0 → row deletée +- Row deletée aussi sur `successful change-master-password` +- Workflow attendu : recover → set new master pw (consomme le code) → + regenerate un nouveau code + +**State `justRecovered`** : flag JS posé après redeem, cleared sur +- successful master pw change (le code est consumed) +- `lockVault` (lock = perte de contexte recovery) +- `doLogin` success (login normal avec pw = sortie du mode recovery) + +En mode recovery, le modal "Change master password" masque le champ +"Current master password" ET retire son `required` (sinon HTML5 form +validation bloque silencieusement le submit). Le verifier "current" est +dérivé du `state.cryptoKey` en mémoire (qu'on vient de recover). + +## Autofill pipeline + +### Workflow utilisateur (important) + +**L'utilisateur doit cliquer le champ cible AVANT de presser le hotkey.** +Le code ne fait plus de "click au centre de la fenêtre" pour deviner le +champ — ça détruisait le focus sur les forms qui n'étaient pas centrés +(Gitea login, etc.). + +- `Ctrl+Shift+L` (full) : click sur **username field** → hotkey → user+Tab+pwd +- `Ctrl+Shift+P` (password only) : click sur **password field** → hotkey → pwd seul + +### Pipeline Delphi + +1. `RegisterHotKey` Ctrl+Shift+L/P sur message-only window +2. `WM_HOTKEY` → capture `GetForegroundWindow` + titre, fire callback +3. JS reçoit `Bridge.onAutofillRequest(title, kind)` → strip browser + suffix (`- Google Chrome`, etc.) → matching `state.entries.site` +4. Single match → `Bridge.executeAutofill(user, pwd)` direct + Multi-match → `Bridge.focusApp()` + picker modal → user pick → execute + Note : `closeAutofillPicker(false)` quand user pick (skip cancel + cmd qui clearrait `FAutofillTargetHWND`) +5. Delphi `ExecuteAutofill` : + - Si app foreground → `SW_MINIMIZE` (laisse la place au target) + - `ForceForegroundWindow(target)` avec `AttachThreadInput` + poll + jusqu'à `GetForegroundWindow == target` (max 600ms) + - `WaitForModifierRelease` (sinon Ctrl+Shift restent down → toutes + les frappes deviennent Ctrl+Shift+X) + - `Sleep(120)` settle + - **Pas de click parasite** — le user a déjà focusé le bon champ + - `Ctrl+A + Del` avant chaque champ (clear contenu existant) + - SendInput : `Ctrl+A+Del → username → 200ms → Tab → 200ms → Ctrl+A+Del → password` + +### Matching titre → entry + +- Strip suffixe browser via `BROWSER_SUFFIX_RE` (Chrome, Firefox, Edge, + Brave, Opera, Vivaldi, Safari, Tor, Arc) avant toute comparaison +- Score = max sur 3 champs : `entry.title` (display name, score 1500+), + `entry.site` hostname full (1000+), SLD (500+) +- Pour SLD courts (`x`, `qq`), match avec word boundaries +- `entry.title` priorisé → si utilisateur stocke `site = "git.example.com"` + (hostname), mettre aussi `title = "Gitea"` (brand) couvre le browser + title sans ambiguïté + +### Contraintes connues autofill + +- **UIPI** : SendInput vers process élevé bloqué. Notepad++ admin, + regedit → autofill silencieusement no-op. Tester avec apps non-élevées. +- **Chrome password manager** : si Chrome a des credentials sauvegardés + pour le domaine, il peut racer avec notre SendInput et écraser notre + username pendant le Tab. Workaround : user désactive Chrome PM par + site (clic-droit password field → "Never save passwords for this site") +- **Gitea-like forms** : tabindex explicite + `autocomplete="current-password"` + → Chrome PM s'active. Désactiver pour ce domaine ou utiliser Ctrl+Shift+P +- **Title-based matching** : si l'utilisateur stocke `site = "git.ai-agents4you.com"` + (hostname) mais le titre browser n'a que la marque ("Gitea"), pas de + match. Convention : préférer `site = "Gitea"` (brand) pour les sites + brandés. URL hostname-only fonctionne pour les sites dont le titre + contient le hostname (rare). + +## Contraintes / pièges connus + +- **UIPI** : process non-élevé ne peut pas SendInput vers process élevé + (Notepad++ admin, regedit, …). Solution future : manifest + `uiAccess=true` + exe signé EV + installé dans `C:\Program Files\`. +- **Assets embedded > static** dans le dispatch HTTP : modifier JS sans + rebuild `assets.res` n'a aucun effet sur l'exe. +- **WebView2 mange tous les keydown** : ne pas utiliser `FormKeyDown` + pour des raccourcis globaux → toujours `RegisterHotKey`. +- **FMX `FormCloseQuery`** : minimize-to-tray sauf si `FQuitting=True` + (set par le tray menu "Quit" uniquement). +- **`SetForegroundWindow` Win10/11** : règles strictes anti-stealing → + nécessite `AttachThreadInput` pour cross-process. +- **`updated_at`** est bumpé à chaque PUT entry → sort par défaut = + `name` asc pour que modifier une entry ne change pas sa position. +- **TMS render-time vs DOM-attachment** : pattern courant — fonctions + qui rendent un widget (`soTagsField`, `soTotpField`) appellent un + helper (`renderSoChips`, `startTotpTick`) qui fait `$('#id')` sur un + élément pas encore dans le DOM. Solution : render inline OU + `setTimeout(fn, 0)` pour deferrer jusqu'au tick suivant. +- **HTML5 `required` + `display:none`** : un input `required` caché + bloque silencieusement le form submit. Toggle aussi `.required = false` + quand on cache (cf. modal recovery). +- **WebView2 user-data folder est relatif à l'exe** : déplacer l'exe = + nouveau folder = `localStorage`/`sessionStorage` perdu. Pour ce qui + doit survivre au move, stocker via Delphi (DPAPI, fichiers user). +- **Port éphémère = nouvelle origin = localStorage wipé chaque launch** : + cause distincte du point précédent (l'exe ne bouge pas, mais le port + change). Mêmes conséquences : pour tout pref qui doit survivre au + reboot, passer par `PM.UserPrefs` via `Bridge.getPref/setPref`. Le + fix port-fixe est possible (`port=N` dans `config.txt`) mais on perd + l'aléatoire qui complique le scan externe. +- **Modifier les `Enable*` props TMS en `OnInitialized`**, pas FormCreate. + +## Conventions code (héritage sessions précédentes) + +- **Pas** de commentaires multi-lignes pour chaque fix : préférer noms + de variables/fonctions descriptifs (`OwnFormHwnd`, + `EnsureTargetThreadHasKeyboardFocus`, `FocusSettleDelayMs`) +- Migrations DB : toujours via `AddColumnIfMissing` (idempotent) +- Nouveau setting UI : ajouter à `SYNCED_SETTING_KEYS` + handler dans + `loadServerSettings` switch case + listener change qui appelle + `saveServerSettings()` +- Tout new bridge cmd : handler dans `UMainForm.HandleBridgeCommand`, + log via `LogLine` pour traçabilité + +## Audit / docs + +- `security-issues.md` à la racine : audit sécu legacy (api.php) +- Ce CLAUDE.md = source de vérité pour l'architecture courante + +## Git + +- Pas de remote configuré (dev local) +- `vault.db`, `*.db-shm`, `*.db-wal`, `delphi-backend/Win32/`, `*.dcu`, + `assets.res` gitignored +- Messages commit : `type(scope): description` style conventional diff --git a/css/style-legacy.css b/css/style-legacy.css new file mode 100644 index 0000000..f15b100 --- /dev/null +++ b/css/style-legacy.css @@ -0,0 +1,343 @@ +: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 7d87d38..2d02b9c 100644 --- a/css/style.css +++ b/css/style.css @@ -490,6 +490,41 @@ input[type="range"]::-webkit-slider-thumb { letter-spacing: 0.5px; color: var(--text-faint); } +.section-toggle { + flex: 1; + display: flex; align-items: center; gap: 6px; + background: none; border: none; padding: 2px 0; + font: inherit; color: inherit; text-transform: inherit; + letter-spacing: inherit; + cursor: pointer; + text-align: left; +} +.section-toggle:hover { color: var(--text-dim); } +.section-count { + min-width: 16px; + padding: 1px 6px; + border-radius: 9px; + background: var(--bg); + color: var(--text-dim); + font-size: 10px; + font-weight: 600; + text-align: center; + letter-spacing: 0; +} +/* Push everything after the count toward the right of the toggle button + so the +/etc. action button stays at the row's edge. */ +.section-toggle { gap: 6px; } +.section-chevron { + width: 12px; height: 12px; + transition: transform 0.18s ease; + flex-shrink: 0; +} +.sidebar-section.is-collapsed .section-chevron { + transform: rotate(-90deg); +} +.sidebar-section.is-collapsed .section-body { + display: none; +} .sidebar-bottom { margin-top: auto; padding-top: 12px; @@ -513,6 +548,13 @@ input[type="range"]::-webkit-slider-thumb { background: var(--bg-glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); + position: relative; + /* backdrop-filter creates a stacking context, so child z-indexes (e.g. + .user-dropdown : 50) are confined here. Without an explicit z-index + the topbar lands behind .slideover (z-index 30), clipping the + dropdown when an entry detail panel is open. 40 puts the topbar + (and its children) above the slideover; modals at 200+ still win. */ + z-index: 40; } .search { flex: 1; max-width: 480px; position: relative; display: flex; align-items: center; } .search svg { @@ -615,14 +657,6 @@ input[type="range"]::-webkit-slider-thumb { } /* Marquee rectangle for rubber-band selection */ -.marquee { - position: fixed; - z-index: 5; - background: var(--accent-soft); - border: 1px solid var(--accent); - border-radius: 2px; - pointer-events: none; -} .entry-card { position: relative; @@ -714,26 +748,55 @@ input[type="range"]::-webkit-slider-thumb { restore + purge (trash view), or kebab (compact mode). Same order slot. */ .entry-grid.is-list .entry-fav, .entry-grid.is-list .entry-del, +.entry-grid.is-list .entry-dup, .entry-grid.is-list .entry-kebab-wrap, .entry-grid.is-list .entry-head .icon-btn { order: 6; flex-shrink: 0; } -.entry-avatar.is-checkable { - position: relative; - cursor: pointer; -} -.entry-avatar.is-checkable:hover::after { - content: ''; - position: absolute; inset: 0; +/* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar + is position:relative) so it covers the avatar 1:1, no overlap with the + avatar's footprint. Hidden by default; appears on card hover OR when + the entry is already checked. The avatar's initials stay visible + underneath through the checkbox's transparent-then-solid transition. */ +.entry-check { + position: absolute; + inset: 0; + display: grid; place-items: center; + background: var(--bg); + border: 1.5px solid var(--accent); border-radius: var(--radius-sm); - background: var(--accent-soft); - border: 2px solid var(--accent); + color: var(--text); + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0; + transition: opacity var(--t-fast), + background var(--t-fast), border-color var(--t-fast); + z-index: 2; } -.entry-card.is-checked .entry-avatar { +.entry-card:hover .entry-check, +.entry-check.is-checked { + opacity: 1; +} +.entry-check.is-checked { background: var(--accent); + border-color: var(--accent); color: white; } +/* Standalone variant used in the table view's check column. The default + rule above is absolute+inset:0 so the box sizes itself from its avatar + parent (card view). When the checkbox lives on its own in a , no + parent provides a size and the box collapses to a thin line. This + modifier restores explicit dimensions and always-on visibility. */ +.entry-check.entry-check-static { + position: static; + width: 18px; height: 18px; + border-radius: 4px; + border-width: 1.5px; + opacity: 1; + font-size: 11px; +} /* Batch action bar (appears when selection is non-empty) */ .batch-bar { @@ -766,6 +829,7 @@ input[type="range"]::-webkit-slider-thumb { .entry-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .entry-avatar { + position: relative; /* anchor for .entry-check overlay */ width: 32px; height: 32px; display: grid; place-items: center; background: var(--bg-elev-2); @@ -784,7 +848,7 @@ input[type="range"]::-webkit-slider-thumb { font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.entry-fav, .entry-del { +.entry-fav, .entry-del, .entry-dup { color: var(--text-faint); background: transparent; border: none; padding: 4px; @@ -793,6 +857,18 @@ input[type="range"]::-webkit-slider-thumb { } .entry-fav.is-on { color: var(--warning); } .entry-fav:hover { color: var(--warning); transform: scale(1.1); } +.entry-dup { + color: var(--text-dim); + opacity: 0; +} +.entry-card:hover .entry-dup { opacity: 0.8; } +.entry-dup:hover { + opacity: 1; + color: var(--accent); + background: var(--accent-soft); + border-radius: 6px; + transform: scale(1.1); +} /* Delete button — always visible, brightens on hover */ .entry-del { @@ -862,6 +938,10 @@ input[type="range"]::-webkit-slider-thumb { animation: modalIn var(--t-fast); } .entry-kebab-menu.is-open { display: block; } +/* Lift the card containing an open kebab menu above its siblings so the + dropdown isn't clipped by the next card in DOM order. */ +.entry-card:has(.entry-kebab-menu.is-open) { z-index: 100; } +.entry-row:has(.entry-kebab-menu.is-open) { position: relative; z-index: 100; } .kebab-item { display: flex; align-items: center; gap: 10px; @@ -966,6 +1046,62 @@ input[type="range"]::-webkit-slider-thumb { transition: width 0.8s linear, background 0.2s; } +/* ---- Authenticator view (TOTP grid) ---------------------- */ +.entry-grid.is-auth { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.auth-card { + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; + cursor: pointer; + transition: border-color 0.15s, transform 0.05s; +} +.auth-card:hover { border-color: var(--accent); } +.auth-card:active { transform: scale(0.99); } +.auth-card-head { margin-bottom: 8px; } +.auth-card-title { + font-weight: 600; + color: var(--text); + font-size: 14px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.auth-card-sub { + font-size: 12px; + color: var(--text-dim); + margin-top: 2px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.auth-card-code-row { + display: flex; align-items: center; gap: 8px; +} +.auth-card-code { + flex: 1; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 26px; + letter-spacing: 3px; + color: var(--accent); + font-weight: 600; + user-select: all; +} +.auth-card-bar-wrap { + margin-top: 8px; + height: 3px; + background: var(--bg); + border-radius: 2px; + overflow: hidden; +} +.auth-card-bar { + height: 100%; + background: var(--accent); + width: 100%; + transition: width 0.8s linear, background 0.2s; +} +.auth-card-bar.is-warning { background: #f59e0b; } + /* ---- 10. SLIDE-OVER -------------------------------------- */ .slideover { @@ -1276,3 +1412,276 @@ input[type="range"]::-webkit-slider-thumb { .content { padding: 16px; } .slideover { width: 100%; } } + +/* ---- 17. AUTOFILL PICKER -------------------------------- */ + +.autofill-pick-btn { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 10px 16px; + background: none; + border: none; + text-align: left; + cursor: pointer; + transition: background var(--t-fast); +} +.autofill-pick-btn:hover { background: var(--bg-elev-2); } +.autofill-pick-site { + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.autofill-pick-user { + font-size: 11px; + color: var(--text-dim); +} + +/* ---- Hotkey capture buttons (Settings → Autofill) ---- */ +.hotkey-capture-btn { + font-family: ui-monospace, "Cascadia Code", Menlo, monospace; + font-size: 11px; + min-width: 140px; + text-align: center; +} +.hotkey-capture-btn.is-capturing { + background: var(--accent); + color: var(--accent-fg, #fff); + animation: pulse-capture 1.2s ease-in-out infinite; +} +@keyframes pulse-capture { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +/* ---- Card display title + subtitle (when entry.title set) ---- */ +.entry-subtitle { + display: block; + font-size: 11px; + color: var(--text-faint); + margin-top: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ---- Sidebar "(no folder)" pseudo-entry ---- */ +.nav-item.is-uncategorized { + color: var(--text-faint); + font-style: italic; +} +.nav-item.is-uncategorized:hover { color: var(--text-dim); } +.nav-item.is-uncategorized.is-active { color: var(--text); font-style: normal; } +/* Spacer occupies the same footprint as the SVG folder icon so labels + stay vertically aligned across real-folder rows and "(no folder)". */ +.nav-icon-spacer { + display: inline-block; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* ============================================================ + Table view + ============================================================ */ +.entry-grid.is-table { display: block; padding: 0; } +.entry-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.entry-table thead th { + text-align: left; + font-weight: 500; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-elev); + user-select: none; + position: sticky; top: 0; + z-index: 1; +} +.entry-table th.is-sortable { cursor: pointer; } +.entry-table th.is-sortable:hover { color: var(--text-dim); } +.entry-table th.is-active { color: var(--accent); } +.entry-table th .sort-arrow { + margin-left: 4px; + font-size: 10px; + vertical-align: middle; +} +.entry-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid var(--border-soft); + color: var(--text); + vertical-align: middle; +} +.entry-row { cursor: pointer; transition: background var(--t-fast); } +.entry-row:hover { background: var(--bg-elev); } +.entry-row.is-selected { background: var(--accent-soft); } +.entry-row.is-checked { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); } +.col-check { width: 36px; } +.col-name { min-width: 180px; } +.col-name .entry-avatar-sm { + width: 22px; height: 22px; + display: inline-grid; place-items: center; + font-size: 10px; + margin-right: 8px; + vertical-align: middle; +} +.col-name .cell-name-wrap { display: inline-flex; align-items: center; gap: 6px; } +.col-name .fav-dot { color: var(--warning); font-size: 12px; } +.col-site, .col-user { color: var(--text-dim); } +.col-site { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.col-user { max-width: 200px; } +.col-user .icon-btn { margin-left: 4px; opacity: 0; transition: opacity var(--t-fast); } +.entry-row:hover .col-user .icon-btn { opacity: 0.7; } +.col-folder { color: var(--text-dim); white-space: nowrap; } +.col-updated { color: var(--text-faint); white-space: nowrap; font-variant-numeric: tabular-nums; } +.col-actions { width: 80px; text-align: right; white-space: nowrap; } +.col-actions .icon-btn { opacity: 0; transition: opacity var(--t-fast); } +.entry-row:hover .col-actions .icon-btn { opacity: 0.7; } +.col-actions .entry-kebab-wrap { display: inline-block; vertical-align: middle; } + +/* Hide Edge/IE native password reveal eye (we ship our own toggle). */ +input[type="password"]::-ms-reveal, +input[type="password"]::-ms-clear { + display: none !important; + width: 0; + height: 0; +} + +/* Login screen pw toggle — only one action button, override the + 2-buttons layout from .input-with-action. */ +.input-with-action:has(> .icon-btn:only-of-type) input { + padding-right: 40px; +} +.input-with-action:has(> .icon-btn:only-of-type) .icon-btn:only-of-type { + right: 4px; +} + +/* Custom right-click menu (replaces native Edge contextmenu in inputs). */ +.custom-ctxmenu { + position: fixed; + z-index: 9999; + min-width: 160px; + padding: 4px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow); +} +.custom-ctxmenu-item { + display: block; + width: 100%; + text-align: left; + padding: 7px 12px; + font-size: 13px; + background: transparent; + border: none; + border-radius: 4px; + color: var(--text); + cursor: pointer; +} +.custom-ctxmenu-item:hover:not(.is-disabled) { background: var(--bg-elev-2); } +.custom-ctxmenu-item.is-disabled { color: var(--text-faint); cursor: default; } + +/* Folder delete X button — visible on row hover only. */ +.nav-item .folder-delete { + margin-left: auto; + width: 20px; height: 20px; + padding: 0; + line-height: 0; + display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: none; + color: var(--text-faint); + border-radius: 4px; + opacity: 0; + transition: opacity var(--t-fast), color var(--t-fast), background var(--t-fast); + cursor: pointer; +} +.nav-item .folder-delete svg { width: 14px; height: 14px; display: block; } +.nav-item:hover .folder-delete { opacity: 0.7; } +.nav-item .folder-delete:hover { + opacity: 1; + color: var(--danger); + background: var(--danger-soft); +} +.nav-item:hover .nav-count { display: none; } + +/* Remember username checkbox row on auth screen. */ +.remember-row { + display: flex; align-items: center; gap: 8px; + margin: -4px 0 12px; + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + user-select: none; +} +.remember-row input[type="checkbox"] { + width: 14px; height: 14px; + accent-color: var(--accent); + cursor: pointer; +} + +/* Just-added entry feedback — pulse background fades out over 2.5s. */ +@keyframes entryFlash { + 0% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent), 0 8px 24px rgba(6,182,212,0.3); } + 40% { background: var(--accent-soft); box-shadow: 0 0 0 2px var(--accent), 0 8px 24px rgba(6,182,212,0.2); } + 100% { background: var(--bg-elev); box-shadow: 0 0 0 0 transparent; } +} +.entry-card.is-flash { animation: entryFlash 2.5s ease-out; } +.entry-row.is-flash { animation: entryFlash 2.5s ease-out; } + +/* Pagination footer (list + table views). */ +.pagination { + display: flex; align-items: center; gap: 6px; + padding: 14px 12px 4px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-dim); + grid-column: 1 / -1; +} +.pagination-info { + margin-right: auto; + color: var(--text-faint); + font-size: 12px; +} +.pagination-btn { + min-width: 32px; + padding: 5px 9px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); + cursor: pointer; + transition: all var(--t-fast); +} +.pagination-btn:hover:not(.is-active):not(.is-disabled) { + border-color: var(--accent); + color: var(--text); +} +.pagination-btn.is-active { + background: var(--accent); + border-color: var(--accent); + color: white; + cursor: default; +} +.pagination-btn.is-disabled { + opacity: 0.4; + cursor: not-allowed; +} +.pagination-ellipsis { padding: 0 4px; color: var(--text-faint); } +.pagination-size { + margin-left: 8px; + padding: 5px 8px; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-dim); + font-size: 12px; + cursor: pointer; +} diff --git a/delphi-backend/Handlers/PM.Handler.Audit.pas b/delphi-backend/Handlers/PM.Handler.Audit.pas new file mode 100644 index 0000000..89d416d --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Audit.pas @@ -0,0 +1,65 @@ +unit PM.Handler.Audit; + +(* + POST /audit body {action, site} -> {ok:true} + + Light-weight endpoint that lets the JS layer append an entry to audit_log + without going through the full entries pipeline. Used by the autofill + feature to record which site was filled (action = "autofill:"). + The bearer token identifies the user — no data beyond the action string + is stored. +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Session, PM.Audit; + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +begin + Result := ARequest.RemoteIP; + if Result = '' then Result := '127.0.0.1'; +end; + +procedure HandlePostAudit(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LAction, LSite: string; +begin + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + + LBody := TJSONHelper.ReadBody(ARequest); + try + LAction := LBody.GetValue('action', ''); + LSite := LBody.GetValue('site', ''); + finally + LBody.Free; + end; + + if LAction = '' then + begin + TJSONHelper.SendError(AResponse, 400, 'action required'); + Exit; + end; + + // Keep the log compact: "autofill:github.com" rather than repeating + // structured columns we don't have in the current schema. + if LSite <> '' then + LAction := LAction + ':' + LSite; + + LogAudit(LUserId, LAction, GetClientIP(ARequest)); + + TJSONHelper.SendOK(AResponse); +end; + +initialization + Router.Register('POST', '/audit', HandlePostAudit); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index d8e8afa..449503a 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -19,9 +19,9 @@ interface implementation uses - System.SysUtils, System.JSON, System.Classes, - FireDAC.Comp.Client, - IdCustomHTTPServer,Data.DB, + System.SysUtils, System.JSON, System.Classes, System.Generics.Collections, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.RateLimit, PM.Audit; @@ -961,8 +961,21 @@ begin DB.Unlock; end; - // Step 5: invalidate every other session for this user. The CURRENT - // session token is still valid — caller stays logged in. + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + DeleteAllUserSessions(LUserId); finally LBody.Free; diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 87cb520..1ba6218 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -16,6 +16,7 @@ implementation uses System.SysUtils, System.JSON, System.StrUtils, System.NetEncoding, + System.Generics.Collections, Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, IdGlobalProtocols, IdURI, PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; @@ -91,6 +92,7 @@ begin LObj := TJSONObject.Create; LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); LObj.AddPair('site', LQ.FieldByName('site').AsString); + LObj.AddPair('title', LQ.FieldByName('title').AsString); LObj.AddPair('username', LQ.FieldByName('username').AsString); LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString); LObj.AddPair('iv', LQ.FieldByName('iv').AsString); @@ -135,7 +137,7 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LNewId: Integer; LBody, LObj: TJSONObject; - LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; + LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; LQ: TFDQuery; begin try @@ -148,6 +150,7 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try LSite := Trim(LBody.GetValue('site', '')); + LTitle := Trim(LBody.GetValue('title', '')); LUser := Trim(LBody.GetValue('username', '')); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); @@ -175,11 +178,12 @@ begin LQ.Connection := DB.Connection; LQ.SQL.Text := 'INSERT INTO vault_entries ' + - '(user_id, site, username, encrypted_password, iv, encryption_method, ' + + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' + - 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; @@ -216,6 +220,7 @@ begin LObj := TJSONObject.Create; LObj.AddPair('id', TJSONNumber.Create(LNewId)); LObj.AddPair('site', LSite); + LObj.AddPair('title', LTitle); LObj.AddPair('username', LUser); LObj.AddPair('folder', LFolder); LObj.AddPair('tags', LTags); @@ -229,7 +234,7 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo; var LUserId, LId: Integer; LBody: TJSONObject; - LSite, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; + LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string; LQ: TFDQuery; begin try @@ -249,6 +254,7 @@ begin LBody := TJSONHelper.ReadBody(ARequest); try LSite := Trim(LBody.GetValue('site', '')); + LTitle := Trim(LBody.GetValue('title', '')); LUser := Trim(LBody.GetValue('username', '')); LFolder := Trim(LBody.GetValue('folder', 'All')); LEnc := LBody.GetValue('encrypted_password', ''); @@ -274,11 +280,12 @@ begin LQ.Connection := DB.Connection; LQ.SQL.Text := 'UPDATE vault_entries ' + - 'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' + + 'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' + ' updated_at=:c ' + 'WHERE id=:id AND user_id=:uid'; LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; @@ -501,7 +508,7 @@ var LUserId, I, LImported: Integer; LBody, LObj, LEntry: TJSONObject; LArr: TJSONArray; - LSite, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string; + LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string; LQ: TFDQuery; begin try @@ -540,9 +547,9 @@ begin LQ.Connection := DB.Connection; LQ.SQL.Text := 'INSERT INTO vault_entries ' + - '(user_id, site, username, encrypted_password, iv, encryption_method, ' + + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, created_at, updated_at) ' + - 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; // Declare optional TOTP param types ONCE — the prepared statement // is reused across every imported entry, and FireDAC needs the // type set before the first .Clear call would otherwise fail @@ -554,6 +561,7 @@ begin begin LEntry := LArr.Items[I] as TJSONObject; LSite := Trim(LEntry.GetValue('site', '')); + LTitle := Trim(LEntry.GetValue('title', '')); LUser := Trim(LEntry.GetValue('username', '')); LFolder := Trim(LEntry.GetValue('folder', 'All')); LEnc := LEntry.GetValue('encrypted_password', ''); @@ -569,6 +577,7 @@ begin LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('e').AsString := LEnc; LQ.ParamByName('i').AsString := LIV; @@ -604,6 +613,52 @@ begin TJSONHelper.SendJSON(AResponse, LObj); end; +procedure HandleEntriesCount(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LActive, LTrashed: Integer; + LQ: TFDQuery; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LActive := 0; + LTrashed := 0; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT deleted, COUNT(*) AS cnt FROM vault_entries ' + + 'WHERE user_id = :uid GROUP BY deleted'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + while not LQ.Eof do + begin + if LQ.FieldByName('deleted').AsInteger = 0 then + LActive := LQ.FieldByName('cnt').AsInteger + else + LTrashed := LQ.FieldByName('cnt').AsInteger; + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LObj := TJSONObject.Create; + LObj.AddPair('active', TJSONNumber.Create(LActive)); + LObj.AddPair('trashed', TJSONNumber.Create(LTrashed)); + TJSONHelper.SendJSON(AResponse, LObj); +end; + initialization // /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match. // Same logic for /entries/bulk-import — register before the catch-all /entries/{id}. @@ -611,6 +666,7 @@ initialization Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); + Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries', HandleGetEntries); Router.Register('POST', '/entries', HandleCreateEntry); Router.Register('PUT', '/entries/(\d+)', HandleUpdateEntry); diff --git a/delphi-backend/Handlers/PM.Handler.Recovery.pas b/delphi-backend/Handlers/PM.Handler.Recovery.pas index 572e3e5..193e77d 100644 --- a/delphi-backend/Handlers/PM.Handler.Recovery.pas +++ b/delphi-backend/Handlers/PM.Handler.Recovery.pas @@ -33,7 +33,7 @@ implementation uses System.SysUtils, System.JSON, - FireDAC.Comp.Client, FireDAC.Stan.Param, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit; @@ -122,19 +122,21 @@ begin LConfigured := False; LCreatedAt := ''; + var RemainingUses: Integer := 0; DB.Lock; try LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; LQ.SQL.Text := - 'SELECT created_at FROM recovery_keys WHERE user_id = :uid'; + 'SELECT created_at, remaining_uses FROM recovery_keys WHERE user_id = :uid'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.Open; if not LQ.IsEmpty then begin LConfigured := True; LCreatedAt := LQ.FieldByName('created_at').AsString; + RemainingUses := LQ.FieldByName('remaining_uses').AsInteger; end; finally LQ.Free; @@ -146,6 +148,7 @@ begin LObj := TJSONObject.Create; LObj.AddPair('configured', TJSONBool.Create(LConfigured)); if LConfigured then LObj.AddPair('created_at', LCreatedAt); + if LConfigured then LObj.AddPair('remaining_uses', TJSONNumber.Create(RemainingUses)); TJSONHelper.SendJSON(AResponse, LObj); end; @@ -208,8 +211,8 @@ begin LQ.SQL.Text := 'INSERT INTO recovery_keys ' + - ' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv) ' + - 'VALUES (:uid, :ch, :ks, :wk, :wi)'; + ' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv, remaining_uses) ' + + 'VALUES (:uid, :ch, :ks, :wk, :wi, 5)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('ch').AsString := LCodeHash; LQ.ParamByName('ks').AsString := LKdfSalt; @@ -271,7 +274,7 @@ var LBody, LObj: TJSONObject; LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv, LSalt, LToken, LCSRF: string; - LUserId, LKdfIters: Integer; + LUserId, LKdfIters, LCurrentUses, LNewUses: Integer; LQ: TFDQuery; begin LIP := GetClientIP(ARequest); @@ -307,7 +310,7 @@ begin // Join to users to look up by username + verify the code in one shot. LQ.SQL.Text := 'SELECT u.id, u.salt, u.kdf_iterations, ' + - ' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv ' + + ' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' + 'FROM users u ' + 'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' + 'WHERE u.username = :u'; @@ -315,20 +318,19 @@ begin LQ.Open; if LQ.IsEmpty then begin - // User doesn't exist OR has no recovery key configured. Same error - // either way to avoid leaking which. RecordAttempt(LIP); RecordFailedAccountAttempt(LUser, LIP); TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code'); Exit; end; - LUserId := LQ.FieldByName('id').AsInteger; - LSalt := LQ.FieldByName('salt').AsString; - LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; - LStoredHash := LQ.FieldByName('code_hash').AsString; - LKdfSalt := LQ.FieldByName('kdf_salt').AsString; - LWrappedKey := LQ.FieldByName('wrapped_key').AsString; - LWrappedIv := LQ.FieldByName('wrapped_iv').AsString; + LUserId := LQ.FieldByName('id').AsInteger; + LSalt := LQ.FieldByName('salt').AsString; + LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; + LStoredHash := LQ.FieldByName('code_hash').AsString; + LKdfSalt := LQ.FieldByName('kdf_salt').AsString; + LWrappedKey := LQ.FieldByName('wrapped_key').AsString; + LWrappedIv := LQ.FieldByName('wrapped_iv').AsString; + LCurrentUses := LQ.FieldByName('remaining_uses').AsInteger; finally LQ.Free; end; @@ -361,13 +363,24 @@ begin Exit; end; - // Code matches. Consume (delete the row) inside the same lock so the - // single-use guarantee holds even under concurrent requests. + // Code matches. Decrement remaining_uses ; if it drops to 0, delete + // the row (last use). The row is also deleted when the user + // successfully changes their master password (in PM.Handler.Auth). + LNewUses := LCurrentUses - 1; LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; - LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid'; - LQ.ParamByName('uid').AsInteger := LUserId; + if LNewUses <= 0 then + begin + LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + end + else + begin + LQ.SQL.Text := 'UPDATE recovery_keys SET remaining_uses = :u WHERE user_id = :uid'; + LQ.ParamByName('u').AsInteger := LNewUses; + LQ.ParamByName('uid').AsInteger := LUserId; + end; LQ.ExecSQL; finally LQ.Free; @@ -391,6 +404,7 @@ begin LObj.AddPair('wrappedKey', LWrappedKey); LObj.AddPair('wrappedIv', LWrappedIv); LObj.AddPair('kdfSalt', LKdfSalt); + LObj.AddPair('remainingUses', TJSONNumber.Create(LNewUses)); TJSONHelper.SendJSON(AResponse, LObj); end; diff --git a/delphi-backend/Handlers/PM.Handler.Settings.pas b/delphi-backend/Handlers/PM.Handler.Settings.pas new file mode 100644 index 0000000..e1d7262 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Settings.pas @@ -0,0 +1,113 @@ +unit PM.Handler.Settings; + +(* + GET /settings -> {} + PUT /settings body: {} -> {message:"OK"} + + Persists a per-user preferences blob (users.settings_json). The server + treats the body as opaque JSON — schema lives in the JS layer. Any client + reading it should tolerate unknown keys for forward compatibility. + + Device-specific toggles (quick-unlock DPAPI, autofill hotkey) deliberately + stay in localStorage on the client and are NOT included in this blob. +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.Classes, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Session, PM.Database; + +procedure HandleGetSettings(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LRaw: string; + LObj: TJSONValue; +begin + LUserId := Authenticate(ARequest, AResponse); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT settings_json FROM users WHERE id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + if LQ.IsEmpty then + LRaw := '{}' + else + LRaw := LQ.FieldByName('settings_json').AsString; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + if Trim(LRaw) = '' then LRaw := '{}'; + + // Validate so a corrupt row doesn't return malformed JSON to the client. + LObj := TJSONObject.ParseJSONValue(LRaw); + if LObj = nil then LObj := TJSONObject.Create; + TJSONHelper.SendJSON(AResponse, LObj); // SendJSON frees the object +end; + +procedure HandlePutSettings(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LSerialized: string; + LQ: TFDQuery; +begin + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + + LBody := TJSONHelper.ReadBody(ARequest); + try + // Re-serialize to a canonical compact form (strips comments / extra + // whitespace, and guarantees what we store is valid JSON). + LSerialized := LBody.ToJSON; + finally + LBody.Free; + end; + + // Soft cap to protect the row from a runaway client (typical settings + // blob is a few hundred bytes; 16 KB leaves room for future flags). + if Length(LSerialized) > 16384 then + begin + TJSONHelper.SendError(AResponse, 413, 'Settings payload too large'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'UPDATE users SET settings_json = :s WHERE id = :uid'; + LQ.ParamByName('s').AsString := LSerialized; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + TJSONHelper.SendOK(AResponse); +end; + +initialization + Router.Register('GET', '/settings', HandleGetSettings); + Router.Register('PUT', '/settings', HandlePutSettings); + +end. diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index 572e081..53fef5f 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -3,6 +3,7 @@ program PMServer; uses System.StartUpCopy, FMX.Forms, + PM.SingleInstance in 'Source\PM.SingleInstance.pas', UMainForm in 'UMainForm.pas' {MainForm}, PM.JSON in 'Source\PM.JSON.pas', PM.Database in 'Source\PM.Database.pas', @@ -16,17 +17,27 @@ uses PM.HTTPServer in 'Source\PM.HTTPServer.pas', PM.Bridge in 'Source\PM.Bridge.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', + PM.UserPrefs in 'Source\PM.UserPrefs.pas', + PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas', PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas', PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas', PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas', PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas', - PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas'; + PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas', + PM.Handler.Audit in 'Handlers\PM.Handler.Audit.pas', + PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas'; {$R *.res} {$R assets\assets.res} begin + // Single-instance: if another PMServer is running, bring it to front + // (it'll handle WM_PMSHOW on its message-only window) and exit. Avoids + // two icons in the tray and two HTTP servers fighting for the port. + if not PM.SingleInstance.AcquireOrSignal then + Exit; + Application.Initialize; Application.CreateForm(TMainForm, MainForm); Application.Run; diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj index 4dff459..3b37da9 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -1,272 +1,1250 @@ - - - {59A8733F-111A-41EC-80BE-9848275FC80D} - PMServer.dpr - True - Debug - 693249 - Application - FMX - 20.1 - Win32 - - - true - - - true - Base - true - - - true - Base - true - - - true - Base - true - - - true - Base - true - - - true - Base - true - - - true - Base - true - - - true - Cfg_1 - true - true - - - true - Base - true - - - true - Cfg_2 - true - true - - - true - Cfg_2 - true - true - - - true - Cfg_2 - true - true - - - true - Cfg_2 - true - true - - - true - Cfg_2 - true - true - - - false - false - false - false - false - 00400000 - PMServer - 1036 - CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= - System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) - $(BDS)\bin\delphi_PROJECTICON.ico - $(BDS)\bin\delphi_PROJECTICNS.icns - - - package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey= - Debug - true - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png - $(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png - $(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png - $(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png - $(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png - true - true - true - true - true - true - true - true - true - true - $(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png - $(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png - $(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png - $(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png - $(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png - activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar - - - $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png - activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar - - - $(BDS)\bin\Artwork\iOS\iPhone\FM_SettingIcon_87x87.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_180x180.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_SpotlightSearchIcon_120x120.png - $(BDS)\bin\Artwork\iOS\iPad\FM_ApplicationIcon_167x167.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_2x.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_2x.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_3x.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_3x.png - $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImage_2x.png - $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImageDark_2x.png - $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_1024x1024.png - - - Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) - Debug - true - CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) - 1033 - $(BDS)\bin\default_app.manifest - $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png - $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png - - - $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png - $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png - - - RELEASE;$(DCC_Define) - 0 - false - 0 - - - PerMonitorV2 - - - DEBUG;$(DCC_Define) - false - true - true - true - - - Debug - - - Debug - - - Debug - - - Debug - - - PerMonitorV2 - true - 1033 - CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) - - - - - MainSource - - -
MainForm
-
- - - - - - - - - - - - - - - - - - - - Base - - - Cfg_1 - Base - - - Cfg_2 - Base - -
- - Delphi.Personality.12 - - - - - PMServer.dpr - - - Microsoft Office 2000 Sample Automation Server Wrapper Components - Microsoft Office XP Sample Automation Server Wrapper Components - - - - False - True - True - True - True - True - True - False - - - 12 - - - - - "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" - False - - False - - False - -
+ + + {59A8733F-111A-41EC-80BE-9848275FC80D} + PMServer.dpr + True + Debug + 693249 + Application + FMX + 20.1 + Win32 + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_1 + true + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + false + false + false + false + false + 00400000 + PMServer + 1036 + CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= + System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) + $(BDS)\bin\delphi_PROJECTICON.ico + $(BDS)\bin\delphi_PROJECTICNS.icns + + + package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey= + Debug + true + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png + true + true + true + true + true + true + true + true + true + true + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\iOS\iPhone\FM_SettingIcon_87x87.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_180x180.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_SpotlightSearchIcon_120x120.png + $(BDS)\bin\Artwork\iOS\iPad\FM_ApplicationIcon_167x167.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_3x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_3x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_1024x1024.png + + + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + Debug + true + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + 1033 + $(BDS)\bin\default_app.manifest + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + RELEASE;$(DCC_Define) + 0 + false + 0 + + + PerMonitorV2 + + + DEBUG;$(DCC_Define) + false + true + true + true + + + Debug + + + Debug + + + Debug + + + Debug + + + PerMonitorV2 + true + 1033 + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + + PMServer_Icon1.ico + app.png + app.png + + + + MainSource + + +
MainForm
+
+ + + + + + + + + + + + + + + + + + + + + + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + +
+ + Delphi.Personality.12 + + + + + PMServer.dpr + + + Microsoft Office 2000 Sample Automation Server Wrapper Components + Microsoft Office XP Sample Automation Server Wrapper Components + + + + False + True + True + True + True + True + True + False + + + + + true + + + + + true + + + + + true + + + + + Assets\ + Logo44x44.png + true + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + classes + 64 + + + classes + 64 + + + + + res\xml + 1 + + + res\xml + 1 + + + + + library\lib\armeabi + 1 + + + library\lib\armeabi + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + library\lib\mips + 1 + + + library\lib\mips + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + + + library\lib\armeabi-v7a + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-anydpi-v21 + 1 + + + res\drawable-anydpi-v21 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-v21 + 1 + + + res\values-v21 + 1 + + + + + res\values-v31 + 1 + + + res\values-v31 + 1 + + + + + res\drawable-anydpi-v26 + 1 + + + res\drawable-anydpi-v26 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-anydpi-v33 + 1 + + + res\drawable-anydpi-v33 + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\values-night-v21 + 1 + + + res\values-night-v21 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-ldpi + 1 + + + res\drawable-ldpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-mdpi + 1 + + + res\drawable-mdpi + 1 + + + + + res\drawable-hdpi + 1 + + + res\drawable-hdpi + 1 + + + + + res\drawable-xhdpi + 1 + + + res\drawable-xhdpi + 1 + + + + + res\drawable-xxhdpi + 1 + + + res\drawable-xxhdpi + 1 + + + + + res\drawable-xxxhdpi + 1 + + + res\drawable-xxxhdpi + 1 + + + + + res\drawable-small + 1 + + + res\drawable-small + 1 + + + + + res\drawable-normal + 1 + + + res\drawable-normal + 1 + + + + + res\drawable-large + 1 + + + res\drawable-large + 1 + + + + + res\drawable-xlarge + 1 + + + res\drawable-xlarge + 1 + + + + + res\values + 1 + + + res\values + 1 + + + + + res\drawable-anydpi-v24 + 1 + + + res\drawable-anydpi-v24 + 1 + + + + + res\drawable + 1 + + + res\drawable + 1 + + + + + res\drawable-night-anydpi-v21 + 1 + + + res\drawable-night-anydpi-v21 + 1 + + + + + res\drawable-anydpi-v31 + 1 + + + res\drawable-anydpi-v31 + 1 + + + + + res\drawable-night-anydpi-v31 + 1 + + + res\drawable-night-anydpi-v31 + 1 + + + + + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + Contents\MacOS + 1 + .framework + + + 0 + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .dll;.bpl + + + + + 1 + .dylib + + + 1 + .dylib + + + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + Contents\MacOS + 1 + .dylib + + + 0 + .bpl + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + Contents\Resources\StartUp\ + 0 + + + 0 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + ..\ + 1 + + + + + Contents + 1 + + + Contents + 1 + + + Contents + 1 + + + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + Contents\Resources + 1 + + + + + library\lib\armeabi-v7a + 1 + + + library\lib\arm64-v8a + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + Contents\MacOS + 1 + + + 0 + + + + + library\lib\armeabi-v7a + 1 + + + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + ..\$(PROJECTNAME).app.dSYM\Contents\Resources\DWARF + 1 + + + + + ..\ + 1 + + + ..\ + 1 + + + ..\ + 1 + + + + + 1 + + + 1 + + + 1 + + + + + ..\$(PROJECTNAME).launchscreen + 64 + + + ..\$(PROJECTNAME).launchscreen + 64 + + + + + 1 + + + 1 + + + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + Assets + 1 + + + Assets + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\LaunchScreenImage.imageset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + ..\$(PROJECTNAME).launchscreen\Assets\AppIcon.appiconset + 1 + + + + + + + + + + + + + + + + 12 + + + + + + "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + False + + False + + False + +
diff --git a/delphi-backend/PMServer.res b/delphi-backend/PMServer.res index 2ac980a..b317cb7 100644 Binary files a/delphi-backend/PMServer.res and b/delphi-backend/PMServer.res differ diff --git a/delphi-backend/PMServer_Icon.ico b/delphi-backend/PMServer_Icon.ico new file mode 100644 index 0000000..1aa4676 Binary files /dev/null and b/delphi-backend/PMServer_Icon.ico differ diff --git a/delphi-backend/PMServer_Icon1.ico b/delphi-backend/PMServer_Icon1.ico new file mode 100644 index 0000000..1aa4676 Binary files /dev/null and b/delphi-backend/PMServer_Icon1.ico differ diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 7faedbd..5647385 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -31,7 +31,8 @@ interface uses System.SysUtils, System.Classes, System.Math, FMX.Types, FMX.Forms, - Winapi.Windows, Winapi.ShellAPI, Winapi.Messages; + Winapi.Windows, Winapi.ShellAPI, Winapi.Messages, + PM.SingleInstance; type // ------------------------------------------------------------------------- @@ -44,12 +45,29 @@ type public constructor Create; destructor Destroy; override; + function ReadText: string; // Copy AText to the clipboard, excluding it from Win+V history. // AClearAfterMs = 0 disables auto-clear; default is 30 seconds. procedure SetText(const AText: string; AClearAfterMs: Integer = 30000); procedure Clear; end; + // Distinguishes the "fill everything" hotkey (Ctrl+Shift+L) from the + // "password only" hotkey (Ctrl+Shift+P). The host decides what to type + // based on this kind. + TAutofillKind = (akFull, akPasswordOnly); + + // Fired when an autofill hotkey is pressed. Args are the foreground + // window HWND and its title (captured before any focus change), plus + // the kind of fill requested. + // Declared as a method pointer (not TProc<>) because Delphi has no implicit + // conversion from "procedure of object" to "reference to procedure" — the + // host wires this with a regular form method (BridgeAutofillRequest). + TAutofillRequestEvent = procedure(AKind: TAutofillKind; + ATargetHWND: HWND; const ATitle: string) of object; + + TNewEntryHotkeyEvent = procedure(const AWindowTitle: string) of object; + // ------------------------------------------------------------------------- // TPMBridge // ------------------------------------------------------------------------- @@ -64,10 +82,28 @@ type FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification FSecureClipboard: TSecureClipboard; FBalloonShown: Boolean; + // Window placement captured at MinimizeToTray time. Replayed on + // RestoreFromTray so the window comes back in the same state + // (maximised / normal + position + size) as before hiding. + FSavedPlacement: TWindowPlacement; + FHasSavedPlacement: Boolean; FOnSystemLock: TProc; FOnTrayRestore: TProc; FOnLockRequest: TProc; FOnQuit: TProc; + // Autofill: global hotkeys → inject credentials into browser. Combos + // are user-configurable from Settings; defaults are Ctrl+Shift+L / + // Ctrl+Shift+P. We track which IDs are actually live so unregister + // doesn't blindly call UnregisterHotKey on unregistered IDs (which + // would set GetLastError noise during shutdown). + FAutofillRegistered: Boolean; + FAutofillFullActive: Boolean; + FAutofillPwdActive: Boolean; + FOnAutofillRequest: TAutofillRequestEvent; + FDebugHotkeyRegistered: Boolean; + FOnDebugHotkey: TProc; + FNewEntryHotkeyRegistered: Boolean; + FOnNewEntryHotkey: TNewEntryHotkeyEvent; procedure MsgWindowHandler(var AMsg: TMessage); procedure PrepareNid; procedure ShowTrayMenu; @@ -80,8 +116,35 @@ type procedure MinimizeToTray; // Restore main window and remove tray icon. procedure RestoreFromTray; + // Apply Windows dark-mode title bar to the main form. Win10 19044+ + // / Win11 only — no-op on older builds. Safe to call repeatedly. + procedure ApplyTitleBarTheme(ADark: Boolean); + + // Register the two autofill global hotkeys (full + password-only) with + // the given Win32 modifier flags (MOD_CONTROL/MOD_SHIFT/MOD_ALT/MOD_WIN + // bitmask) and virtual-key codes. Replaces any prior registration — + // safe to call repeatedly to swap combos at runtime. + // Returns True if both hotkeys registered successfully. If one or both + // failed (clash with another app), best-effort: whichever succeeded + // stays active. + function SetAutofillHotkeys(AFullMods, AFullVk, + APwdMods, APwdVk: Word): Boolean; + // Convenience wrapper: register the historical defaults (Ctrl+Shift+L + // and Ctrl+Shift+P). Used by the host on first start; runtime changes + // go through SetAutofillHotkeys. + procedure RegisterAutofillHotkey; + // Unregister both autofill hotkeys. + procedure UnregisterAutofillHotkey; + // Simulate username + Tab + password keystrokes into ATargetHWND. + // ATargetHWND = 0 → type into whatever window has focus. + // If AUsername is empty, only the password is typed (no Tab) — matches + // the password-only hotkey path AND avoids spurious Tab on entries + // without a stored username. + procedure ExecuteAutofill(ATargetHWND: HWND; + const AUsername, APassword: string); property SecureClipboard: TSecureClipboard read FSecureClipboard; property TrayAdded: Boolean read FTrayAdded; + property AutofillRegistered: Boolean read FAutofillRegistered; // Fired on main thread when Windows locks the session (WTS_SESSION_LOCK). property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock; // Fired on main thread when the user clicks the tray icon. @@ -94,6 +157,17 @@ type // bridge does not call it itself, so the host stays in control of // shutdown order (server stop, save state, etc.). property OnQuit: TProc read FOnQuit write FOnQuit; + // Fired on main thread when the autofill hotkey fires. + // Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript + // to let JS match the title against vault entries. + property OnAutofillRequest: TAutofillRequestEvent + read FOnAutofillRequest write FOnAutofillRequest; + property OnDebugHotkey: TProc + read FOnDebugHotkey write FOnDebugHotkey; + // Fires on Ctrl+Shift+A. Arg = foreground window title (stripped of + // browser suffix by the JS layer before pre-fill). + property OnNewEntryHotkey: TNewEntryHotkeyEvent + read FOnNewEntryHotkey write FOnNewEntryHotkey; end; implementation @@ -130,6 +204,17 @@ const PBT_APMRESUMEAUTOMATIC = $0012; PBT_APMRESUMESUSPEND = $0007; +// Autofill hotkeys — Ctrl+Shift+L (full) and Ctrl+Shift+P (password only). +// IDs must not clash with other RegisterHotKey calls in this process; +// 42-43 are arbitrary and well outside the range used by FMX internals. +const + AUTOFILL_HOTKEY_ID_FULL = 42; // Ctrl+Shift+L → user + Tab + password + AUTOFILL_HOTKEY_ID_PWDONLY = 43; // Ctrl+Shift+P → password only + DEBUG_HOTKEY_ID = 44; // Ctrl+Shift+D → toggle debug panel + NEW_ENTRY_HOTKEY_ID = 45; // Ctrl+Shift+A → quick-add from window title + AF_MOD_CONTROL = $0002; // same value as MOD_CONTROL + AF_MOD_SHIFT = $0004; // same value as MOD_SHIFT + // Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all // Windows SKUs (e.g. minimal Server Core without Session Services), so // we load it at runtime and tolerate absence gracefully. @@ -256,6 +341,28 @@ begin end; end; +function TSecureClipboard.ReadText: string; +var + H: THandle; + P: PChar; +begin + Result := ''; + if not OpenClipboard(0) then Exit; + try + H := GetClipboardData(CF_UNICODETEXT); + if H = 0 then Exit; + P := PChar(GlobalLock(H)); + if P <> nil then + try + Result := P; + finally + GlobalUnlock(H); + end; + finally + CloseClipboard; + end; +end; + // ============================================================================= // TPMBridge // ============================================================================= @@ -273,6 +380,14 @@ begin PrepareNid; + // Add the tray icon eagerly so it's visible from app startup, regardless + // of whether the window is shown or hidden. Without this, the tray icon + // only appears the first time the user minimizes — meaning fresh-launch + // users can't lock/quit from the tray and discover the feature only by + // accident. NIM_DELETE is now only called at shutdown. + if Shell_NotifyIcon(NIM_ADD, @FNid) then + FTrayAdded := True; + // Session-lock detection (fails silently if wtsapi32.dll is absent). LoadWtsApi; if Assigned(_WTSRegister) then @@ -286,10 +401,21 @@ begin LoadPowerApi; if Assigned(_PowerRegister) then _PowerRegister(DEVICE_NOTIFY_WINDOW_HANDLE, FMsgWindow, FPowerNotify); + + FDebugHotkeyRegistered := RegisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID, + AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('D')); + FNewEntryHotkeyRegistered := RegisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID, + AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('A')); end; destructor TPMBridge.Destroy; begin + if FDebugHotkeyRegistered then + UnregisterHotKey(FMsgWindow, DEBUG_HOTKEY_ID); + + if FNewEntryHotkeyRegistered then + UnregisterHotKey(FMsgWindow, NEW_ENTRY_HOTKEY_ID); + if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then _PowerUnregister(FPowerNotify); @@ -379,11 +505,8 @@ procedure TPMBridge.MinimizeToTray; var LFormHwnd, LAppHwnd: HWND; begin - if not FTrayAdded then - begin - if Shell_NotifyIcon(NIM_ADD, @FNid) then - FTrayAdded := True; - end; + // Tray icon is added at construction time and persists for the app's + // lifetime — no NIM_ADD here. // Extra safety: clear the clipboard immediately when the user minimizes, // rather than waiting for the 30s auto-clear timer to fire. A password @@ -394,6 +517,15 @@ begin LFormHwnd := MainFormHWND(FMainForm); LAppHwnd := FindFMXAppWindow; + // 0. Snapshot the window placement BEFORE hiding so RestoreFromTray can + // replay the exact same state (maximised / normal + size + position). + // Without this, ShowWindow(SW_RESTORE) below always returns to the + // "normal" state — a window that was maximised before hiding comes + // back un-maximised. + FillChar(FSavedPlacement, SizeOf(FSavedPlacement), 0); + FSavedPlacement.length := SizeOf(FSavedPlacement); + FHasSavedPlacement := GetWindowPlacement(LFormHwnd, @FSavedPlacement); + // 1. Hide the visible form via both FMX state and Win32 ShowWindow. // Keeps the form invisible to the user. FMainForm.Hide; @@ -442,12 +574,7 @@ procedure TPMBridge.RestoreFromTray; var LFormHwnd, LAppHwnd: HWND; begin - if FTrayAdded then - begin - Shell_NotifyIcon(NIM_DELETE, @FNid); - FTrayAdded := False; - end; - + // Tray icon stays in the tray — we only show the window again. LFormHwnd := MainFormHWND(FMainForm); LAppHwnd := FindFMXAppWindow; @@ -457,8 +584,23 @@ begin ShowWindow(LAppHwnd, SW_SHOW); FMainForm.Show; - ShowWindow(LFormHwnd, SW_SHOW); - ShowWindow(LFormHwnd, SW_RESTORE); + + // Restore to the exact pre-tray state (maximised/normal + size + pos). + // Falls back to SW_RESTORE if we never captured a placement (e.g. tray + // restore was triggered without a prior MinimizeToTray call). + if FHasSavedPlacement then + begin + // showCmd governs whether the window comes back maximised or normal; + // it's what SW_RESTORE clobbers. We force it ourselves. + if FSavedPlacement.showCmd = SW_SHOWMINIMIZED then + FSavedPlacement.showCmd := SW_SHOWNORMAL; // never restore as minimised + SetWindowPlacement(LFormHwnd, @FSavedPlacement); + end + else + begin + ShowWindow(LFormHwnd, SW_SHOW); + ShowWindow(LFormHwnd, SW_RESTORE); + end; SetForegroundWindow(LFormHwnd); end; @@ -543,9 +685,372 @@ begin // suspends — fast handler required (no UI prompts, no network). if AMsg.WParam = PBT_APMSUSPEND then if Assigned(FOnSystemLock) then FOnSystemLock(); + end + + else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then + begin + // A second instance was launched and PostMessage'd HWND_BROADCAST. + // Bring our window back to the front instead of letting that second + // process spawn its own UI. + if Assigned(FOnTrayRestore) then FOnTrayRestore(); + end + + else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = DEBUG_HOTKEY_ID) then + begin + if Assigned(FOnDebugHotkey) then FOnDebugHotkey(); + end + + else if (AMsg.Msg = WM_HOTKEY) and (AMsg.WParam = NEW_ENTRY_HOTKEY_ID) then + begin + if Assigned(FOnNewEntryHotkey) then + begin + var LTarget := GetForegroundWindow; + var LTitle: string; + SetLength(LTitle, 512); + var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); + SetLength(LTitle, LLen); + FOnNewEntryHotkey(LTitle); + end; + end + + else if (AMsg.Msg = WM_HOTKEY) and Assigned(FOnAutofillRequest) and + ((AMsg.WParam = AUTOFILL_HOTKEY_ID_FULL) or + (AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY)) then + begin + // Capture the foreground window BEFORE any focus change, then fire the + // callback so the host can match the title against vault entries. + var LKind: TAutofillKind; + if AMsg.WParam = AUTOFILL_HOTKEY_ID_PWDONLY then + LKind := akPasswordOnly + else + LKind := akFull; + var LTarget := GetForegroundWindow; + var LTitle: string; + SetLength(LTitle, 512); + var LLen := GetWindowTextW(LTarget, PChar(LTitle), 512); + SetLength(LTitle, LLen); + FOnAutofillRequest(LKind, LTarget, LTitle); end; AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam); end; +// ============================================================================= +// TPMBridge — Autofill hotkey + SendInput +// ============================================================================= + +function TPMBridge.SetAutofillHotkeys(AFullMods, AFullVk, + APwdMods, APwdVk: Word): Boolean; +begin + // Tear down whatever is currently registered before installing the new + // combos. RegisterHotKey would fail if the same ID is already taken. + if FAutofillFullActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL); + FAutofillFullActive := False; + end; + if FAutofillPwdActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY); + FAutofillPwdActive := False; + end; + + // Best-effort registration. A failure (typically MOD_x clash with another + // app's global hotkey) is silent: the other slot can still be live. + if (AFullVk <> 0) and (AFullMods <> 0) then + FAutofillFullActive := RegisterHotKey(FMsgWindow, + AUTOFILL_HOTKEY_ID_FULL, AFullMods, AFullVk); + if (APwdVk <> 0) and (APwdMods <> 0) then + FAutofillPwdActive := RegisterHotKey(FMsgWindow, + AUTOFILL_HOTKEY_ID_PWDONLY, APwdMods, APwdVk); + + FAutofillRegistered := FAutofillFullActive or FAutofillPwdActive; + Result := FAutofillFullActive and FAutofillPwdActive; +end; + +procedure TPMBridge.ApplyTitleBarTheme(ADark: Boolean); +const + DWMWA_USE_IMMERSIVE_DARK_MODE = 20; + // uxtheme.dll private API, stable since Win10 1809. File Explorer, Edge + // and Office use this to opt their UI (including popup menus, scrollbars, + // tooltips) into dark mode. Signature changed in 1903 to take an enum: + // 0=Default 1=AllowDark 2=ForceDark 3=ForceLight 4=Max + // We use ForceDark / ForceLight for unambiguous behaviour. + APPMODE_DEFAULT = 0; + APPMODE_FORCE_DARK = 2; + APPMODE_FORCE_LIGHT = 3; +type + TDwmSetWindowAttribute = function(hwnd: HWND; dwAttribute: DWORD; + pvAttribute: Pointer; cbAttribute: DWORD): HRESULT; stdcall; + TSetPreferredAppMode = function(AppMode: Integer): Integer; stdcall; + TFlushMenuThemes = procedure; stdcall; +var + DwmLib, UxLib: HMODULE; + DwmSetWindowAttribute: TDwmSetWindowAttribute; + SetPreferredAppMode: TSetPreferredAppMode; + FlushMenuThemes: TFlushMenuThemes; + DarkFlag: BOOL; + FormHwnd: HWND; +begin + if FMainForm = nil then Exit; + FormHwnd := MainFormHWND(FMainForm); + if FormHwnd = 0 then Exit; + + // 1. Title bar (DWM immersive dark mode). + DwmLib := LoadLibrary('dwmapi.dll'); + if DwmLib <> 0 then + try + @DwmSetWindowAttribute := GetProcAddress(DwmLib, 'DwmSetWindowAttribute'); + if Assigned(DwmSetWindowAttribute) then + begin + DarkFlag := ADark; + DwmSetWindowAttribute(FormHwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + @DarkFlag, SizeOf(DarkFlag)); + end; + finally + FreeLibrary(DwmLib); + end; + + // 2. App-wide preferred mode (themes popup menus, scrollbars, tooltips). + // Loaded by ordinal because the functions are not exported by name. + UxLib := LoadLibrary('uxtheme.dll'); + if UxLib <> 0 then + try + @SetPreferredAppMode := GetProcAddress(UxLib, MAKEINTRESOURCE(135)); + @FlushMenuThemes := GetProcAddress(UxLib, MAKEINTRESOURCE(136)); + if Assigned(SetPreferredAppMode) then + begin + if ADark then SetPreferredAppMode(APPMODE_FORCE_DARK) + else SetPreferredAppMode(APPMODE_FORCE_LIGHT); + if Assigned(FlushMenuThemes) then FlushMenuThemes; + end; + finally + FreeLibrary(UxLib); + end; +end; + +procedure TPMBridge.RegisterAutofillHotkey; +begin + // Convenience default — Ctrl+Shift+L (full) + Ctrl+Shift+P (password). + // Idempotent: calling twice with the same combos is harmless. + SetAutofillHotkeys(AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('L'), + AF_MOD_CONTROL or AF_MOD_SHIFT, Ord('P')); +end; + +procedure TPMBridge.UnregisterAutofillHotkey; +begin + if FAutofillFullActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_FULL); + FAutofillFullActive := False; + end; + if FAutofillPwdActive then + begin + UnregisterHotKey(FMsgWindow, AUTOFILL_HOTKEY_ID_PWDONLY); + FAutofillPwdActive := False; + end; + FAutofillRegistered := False; +end; + +// Block until the user releases Ctrl, Shift, Alt, and Win, or until ATimeoutMs +// elapses. Without this, an autofill triggered by Ctrl+Shift+L injects +// keystrokes WHILE Ctrl+Shift are physically held — turning our Tab into +// Ctrl+Tab (next tab in Chrome), our 's' into Ctrl+S, etc. 1000 ms is a +// generous bound; typical release happens within 50-150 ms. +procedure WaitForModifierRelease(ATimeoutMs: Cardinal); +var + LStart: Cardinal; +begin + LStart := GetTickCount; + while ((GetAsyncKeyState(VK_CONTROL) and $8000) <> 0) + or ((GetAsyncKeyState(VK_SHIFT) and $8000) <> 0) + or ((GetAsyncKeyState(VK_MENU) and $8000) <> 0) // Alt + or ((GetAsyncKeyState(VK_LWIN) and $8000) <> 0) + or ((GetAsyncKeyState(VK_RWIN) and $8000) <> 0) do + begin + Sleep(15); + if GetTickCount - LStart > ATimeoutMs then Break; + end; +end; + +// Build (and immediately send) a key-down+up pair for each char in AText +// using KEYEVENTF_UNICODE. Returns nothing — best-effort. +procedure SendUnicodeString(const AText: string); +var + LInputs: TArray; + LCount, I: Integer; +begin + if AText = '' then Exit; + SetLength(LInputs, Length(AText) * 2); + LCount := 0; + for I := 1 to Length(AText) do + begin + FillChar(LInputs[LCount], SizeOf(TInput), 0); + FillChar(LInputs[LCount + 1], SizeOf(TInput), 0); + LInputs[LCount].Itype := INPUT_KEYBOARD; + LInputs[LCount].ki.wScan := Ord(AText[I]); + LInputs[LCount].ki.dwFlags := KEYEVENTF_UNICODE; + LInputs[LCount + 1] := LInputs[LCount]; + LInputs[LCount + 1].ki.dwFlags := KEYEVENTF_UNICODE or KEYEVENTF_KEYUP; + Inc(LCount, 2); + end; + SendInput(LCount, @LInputs[0], SizeOf(TInput)); +end; + +// Send one virtual-key press (down+up). +procedure SendVKey(AVk: Word); +var + LInputs: array[0..1] of TInput; +begin + FillChar(LInputs, SizeOf(LInputs), 0); + LInputs[0].Itype := INPUT_KEYBOARD; + LInputs[0].ki.wVk := AVk; + LInputs[1] := LInputs[0]; + LInputs[1].ki.dwFlags := KEYEVENTF_KEYUP; + SendInput(2, @LInputs[0], SizeOf(TInput)); +end; + +procedure SendSelectAllAndDelete; +var + LInputs: array[0..5] of TInput; +begin + FillChar(LInputs, SizeOf(LInputs), 0); + LInputs[0].Itype := INPUT_KEYBOARD; + LInputs[0].ki.wVk := VK_CONTROL; + LInputs[1].Itype := INPUT_KEYBOARD; + LInputs[1].ki.wVk := Ord('A'); + LInputs[2].Itype := INPUT_KEYBOARD; + LInputs[2].ki.wVk := Ord('A'); + LInputs[2].ki.dwFlags := KEYEVENTF_KEYUP; + LInputs[3].Itype := INPUT_KEYBOARD; + LInputs[3].ki.wVk := VK_CONTROL; + LInputs[3].ki.dwFlags := KEYEVENTF_KEYUP; + LInputs[4].Itype := INPUT_KEYBOARD; + LInputs[4].ki.wVk := VK_DELETE; + LInputs[5] := LInputs[4]; + LInputs[5].ki.dwFlags := KEYEVENTF_KEYUP; + SendInput(6, @LInputs[0], SizeOf(TInput)); +end; + +// Always-attach foreground switch. The early SetForegroundWindow shortcut +// was unreliable after the picker click — Win10/11 still refused the focus +// hand-off even when our process was foreground. Always doing the attach +// dance is slightly slower but actually works. +function ForceForegroundWindow(ATargetHwnd: HWND): Boolean; +const + ForegroundPollIntervalMs = 20; + ForegroundPollTimeoutMs = 600; +var + CallerThread, TargetThread, TargetPid: DWORD; + ThreadsAttached: Boolean; + WaitStart: Cardinal; +begin + Result := False; + if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit; + + TargetPid := 0; + TargetThread := GetWindowThreadProcessId(ATargetHwnd, TargetPid); + CallerThread := GetCurrentThreadId; + if TargetThread = 0 then Exit; + + ThreadsAttached := (TargetThread <> CallerThread) and + AttachThreadInput(CallerThread, TargetThread, True); + try + if IsIconic(ATargetHwnd) then + ShowWindow(ATargetHwnd, SW_RESTORE); + BringWindowToTop(ATargetHwnd); + SetWindowPos(ATargetHwnd, HWND_TOP, 0, 0, 0, 0, + SWP_NOMOVE or SWP_NOSIZE or SWP_NOACTIVATE); + SetForegroundWindow(ATargetHwnd); + + WaitStart := GetTickCount; + while GetForegroundWindow <> ATargetHwnd do + begin + if GetTickCount - WaitStart > ForegroundPollTimeoutMs then Break; + Sleep(ForegroundPollIntervalMs); + SetForegroundWindow(ATargetHwnd); + end; + Result := GetForegroundWindow = ATargetHwnd; + finally + if ThreadsAttached then + AttachThreadInput(CallerThread, TargetThread, False); + end; +end; + +procedure ClickTargetCenterToGrabFocus(ATargetHwnd: HWND); +const + PostClickSettleMs = 40; +var + WindowRect: TRect; + CenterAbsX, CenterAbsY, ScreenW, ScreenH: Integer; + SavedCursor: TPoint; + MouseInputs: array[0..2] of TInput; +begin + if (ATargetHwnd = 0) or not IsWindow(ATargetHwnd) then Exit; + if not GetWindowRect(ATargetHwnd, WindowRect) then Exit; + + CenterAbsX := (WindowRect.Left + WindowRect.Right) div 2; + CenterAbsY := (WindowRect.Top + WindowRect.Bottom) div 2; + ScreenW := GetSystemMetrics(SM_CXSCREEN); + ScreenH := GetSystemMetrics(SM_CYSCREEN); + if (ScreenW <= 0) or (ScreenH <= 0) then Exit; + + GetCursorPos(SavedCursor); + + FillChar(MouseInputs, SizeOf(MouseInputs), 0); + MouseInputs[0].Itype := INPUT_MOUSE; + MouseInputs[0].mi.dx := (CenterAbsX * 65535) div ScreenW; + MouseInputs[0].mi.dy := (CenterAbsY * 65535) div ScreenH; + MouseInputs[0].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE or MOUSEEVENTF_LEFTDOWN; + MouseInputs[1] := MouseInputs[0]; + MouseInputs[1].mi.dwFlags:= MOUSEEVENTF_LEFTUP; + MouseInputs[2].Itype := INPUT_MOUSE; + MouseInputs[2].mi.dx := (SavedCursor.X * 65535) div ScreenW; + MouseInputs[2].mi.dy := (SavedCursor.Y * 65535) div ScreenH; + MouseInputs[2].mi.dwFlags:= MOUSEEVENTF_ABSOLUTE or MOUSEEVENTF_MOVE; + SendInput(3, @MouseInputs[0], SizeOf(TInput)); + + Sleep(PostClickSettleMs); +end; + +procedure TPMBridge.ExecuteAutofill(ATargetHWND: HWND; + const AUsername, APassword: string); +const + MinimizeSettleMs = 80; + FocusSettleDelayMs = 120; +var + OwnFormHwnd: HWND; +begin + OwnFormHwnd := MainFormHWND(FMainForm); + if GetForegroundWindow = OwnFormHwnd then + begin + ShowWindow(OwnFormHwnd, SW_MINIMIZE); + Sleep(MinimizeSettleMs); + end; + + if ATargetHWND <> 0 then + ForceForegroundWindow(ATargetHWND); + + WaitForModifierRelease(1000); + Sleep(FocusSettleDelayMs); + + if AUsername = '' then + begin + SendSelectAllAndDelete; + Sleep(60); + SendUnicodeString(APassword); + Exit; + end; + + SendSelectAllAndDelete; + Sleep(60); + SendUnicodeString(AUsername); + Sleep(200); + SendVKey(VK_TAB); + Sleep(200); + SendSelectAllAndDelete; + Sleep(60); + SendUnicodeString(APassword); +end; + end. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index da4d8a6..94f8d5d 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -231,6 +231,10 @@ begin // UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa"). // Simple format, search via LIKE %tag%. Frontend handles parsing/joining. AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT '''''); + // Optional human-friendly display name. When empty, the UI falls back + // to `site`. Lets the user store the raw URL/host (used for autofill + // domain matching) while showing something nicer on cards/slideovers. + AddColumnIfMissing('vault_entries', 'title', 'TEXT DEFAULT '''''); // TOTP (2FA) — RFC 6238. Secret + IV are AES-GCM ciphertext / IV pair // encrypted client-side with the user's master-derived key, exactly like // encrypted_password. The server treats them as opaque blobs and never @@ -244,6 +248,12 @@ begin // (600 000 as of 2026). Login flow transparently re-hashes legacy users // and re-encrypts their entries on the client side. AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000'); + AddColumnIfMissing('recovery_keys', 'remaining_uses', 'INTEGER DEFAULT 5'); + // Server-side preferences blob (JSON). Synced across devices on login, + // saved on every change from the JS settings panel. Device-specific + // toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay + // in localStorage and are NOT included here. + AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}'''); AddColumnIfMissing('sessions', 'csrf_token', 'TEXT'); end; diff --git a/delphi-backend/Source/PM.HTTPServer.pas b/delphi-backend/Source/PM.HTTPServer.pas index 4b2949a..b119f19 100644 --- a/delphi-backend/Source/PM.HTTPServer.pas +++ b/delphi-backend/Source/PM.HTTPServer.pas @@ -1,4 +1,4 @@ -unit PM.HTTPServer; +unit PM.HTTPServer; { Indy TIdHTTPServer wrapper. @@ -12,8 +12,10 @@ interface uses System.SysUtils, System.Classes, System.IOUtils, - IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, - PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets; + Winapi.Windows, + IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, IdTCPConnection, + PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets, + PM.Crypto, PM.ProcessLockdown; type TLogProc = reference to procedure(const AMsg: string); @@ -22,6 +24,10 @@ type private FServer: TIdHTTPServer; FOnLog: TLogProc; + FAccessToken: string; + FRequireAccessToken: Boolean; + FRequireProcessCheck: Boolean; + FBoundPort: Integer; procedure HandleCommand(AContext: TIdContext; ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); procedure HandleCommandOther(AContext: TIdContext; @@ -34,13 +40,23 @@ type AResponse: TIdHTTPResponseInfo); procedure Log(const AMsg: string); function GetActive: Boolean; + function ValidateAccessToken(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + function ValidateConnectingProcess(AContext: TIdContext; + AResponse: TIdHTTPResponseInfo): Boolean; public constructor Create; destructor Destroy; override; - procedure Start(APort: Integer); + procedure Start(APort: Integer; SameFolder: Boolean; + ARequireAccessToken: Boolean = True; + ARequireProcessCheck: Boolean = True); procedure Stop; property Active: Boolean read GetActive; property OnLog: TLogProc read FOnLog write FOnLog; + property AccessToken: string read FAccessToken; + property RequireAccessToken: Boolean read FRequireAccessToken; + property RequireProcessCheck: Boolean read FRequireProcessCheck; + property BoundPort: Integer read FBoundPort; end; implementation @@ -96,16 +112,34 @@ begin if Assigned(FOnLog) then FOnLog(AMsg); end; -procedure TPMHTTPServer.Start(APort: Integer); +procedure TPMHTTPServer.Start(APort: Integer; SameFolder: Boolean; + ARequireAccessToken: Boolean; ARequireProcessCheck: Boolean); +const + EphemeralPortMin = 49152; + EphemeralPortMax = 65535; var LBinding: TIdSocketHandle; - LDBPath, LWebRoot: string; + LDBPath, LWebRoot, LResolvedPort: string; + LRequestedPort: Integer; begin if FServer.Active then Exit; - // Resolve vault.db AND the web root (parent of the exe = Z:\password-manager\) - LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\vault.db')); - LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\')); + FRequireAccessToken := ARequireAccessToken; + FRequireProcessCheck := ARequireProcessCheck; + if FRequireAccessToken then + FAccessToken := PM.Crypto.RandomHex(32) + else + FAccessToken := ''; + + LRequestedPort := APort; + if LRequestedPort = 0 then + LRequestedPort := EphemeralPortMin + Random(EphemeralPortMax - EphemeralPortMin); + + var pathParent := '..\'; + if SameFolder then + pathParent := ''; + LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent+'vault.db')); + LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), pathParent)); Log('Opening database: ' + LDBPath); InitDatabase(LDBPath); Log('Database ready.'); @@ -115,10 +149,15 @@ begin FServer.Bindings.Clear; LBinding := FServer.Bindings.Add; LBinding.IP := '127.0.0.1'; - LBinding.Port := APort; + LBinding.Port := LRequestedPort; FServer.Active := True; - Log('Server started on http://127.0.0.1:' + IntToStr(APort)); + FBoundPort := LRequestedPort; + LResolvedPort := IntToStr(FBoundPort); + Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)', + [LResolvedPort, + BoolToStr(FRequireAccessToken, True), + BoolToStr(FRequireProcessCheck, True)])); end; procedure TPMHTTPServer.Stop; @@ -176,12 +215,70 @@ begin end; end; +function TPMHTTPServer.ValidateConnectingProcess(AContext: TIdContext; + AResponse: TIdHTTPResponseInfo): Boolean; +var + Binding: TIdSocketHandle; + ConnectingPid: DWORD; +begin + if not FRequireProcessCheck then Exit(True); + Result := False; + + Binding := AContext.Binding; + if Binding = nil then + begin + AResponse.ResponseNo := 404; + AResponse.ContentText := ''; + Exit; + end; + + ConnectingPid := GetPidOfTcpConnection(Word(Binding.PeerPort), Word(Binding.Port)); + if (ConnectingPid <> 0) and IsDescendantOfCurrentProcess(ConnectingPid) then + Exit(True); + + Log(Format('Rejected request from foreign PID %d (%s %s)', + [ConnectingPid, AContext.Connection.Socket.Binding.PeerIP, ''])); + AResponse.ResponseNo := 404; + AResponse.ContentText := ''; +end; + +function TPMHTTPServer.ValidateAccessToken(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +const + CookieName = 'pm_token'; + QueryParamName = 'pmt'; + SetCookieHeader = 'Set-Cookie'; +var + CookieHeader, QueryToken: string; +begin + if not FRequireAccessToken then Exit(True); + + CookieHeader := ARequest.RawHeaders.Values['Cookie']; + if (CookieHeader <> '') and + (Pos(CookieName + '=' + FAccessToken, CookieHeader) > 0) then + Exit(True); + + QueryToken := ARequest.Params.Values[QueryParamName]; + if QueryToken = FAccessToken then + begin + AResponse.CustomHeaders.AddValue(SetCookieHeader, + CookieName + '=' + FAccessToken + + '; Path=/; HttpOnly; SameSite=Strict'); + Exit(True); + end; + + AResponse.ResponseNo := 404; + AResponse.ContentText := ''; + Result := False; +end; + procedure TPMHTTPServer.HandleCommand(AContext: TIdContext; ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); begin ApplySecurityHeaders(ARequest, AResponse); + if not ValidateConnectingProcess(AContext, AResponse) then Exit; + if not ValidateAccessToken(ARequest, AResponse) then Exit; try - // Order: API route → embedded resource (production) → disk static (dev) → 404 if Router.DispatchRequest(ARequest, AResponse) then Exit; if TryServeEmbedded(ARequest, AResponse) then Exit; if Assigned(StaticServer) and StaticServer.TryServe(ARequest, AResponse) then Exit; @@ -199,14 +296,14 @@ procedure TPMHTTPServer.HandleCommandOther(AContext: TIdContext; ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); begin ApplySecurityHeaders(ARequest, AResponse); - // OPTIONS preflight if SameText(ARequest.Command, 'OPTIONS') then begin AResponse.ResponseNo := 204; AResponse.ContentText := ''; Exit; end; - // Routes for PUT / DELETE go through here in Indy + if not ValidateConnectingProcess(AContext, AResponse) then Exit; + if not ValidateAccessToken(ARequest, AResponse) then Exit; try if not Router.DispatchRequest(ARequest, AResponse) then TJSONHelper.SendError(AResponse, 404, 'Not found'); diff --git a/delphi-backend/Source/PM.JSON.pas b/delphi-backend/Source/PM.JSON.pas index ae9d757..a3f294a 100644 --- a/delphi-backend/Source/PM.JSON.pas +++ b/delphi-backend/Source/PM.JSON.pas @@ -24,7 +24,6 @@ var LSS: TStringStream; LValue: TJSONValue; begin - Result := nil; if ARequest.PostStream = nil then Exit(TJSONObject.Create); LSS := TStringStream.Create('', TEncoding.UTF8); try diff --git a/delphi-backend/Source/PM.ProcessLockdown.pas b/delphi-backend/Source/PM.ProcessLockdown.pas new file mode 100644 index 0000000..729e261 --- /dev/null +++ b/delphi-backend/Source/PM.ProcessLockdown.pas @@ -0,0 +1,116 @@ +unit PM.ProcessLockdown; + +interface + +uses + Winapi.Windows; + +function GetPidOfTcpConnection(ALocalPort, ARemotePort: Word): DWORD; +function IsDescendantOfCurrentProcess(APid: DWORD): Boolean; + +implementation + +uses + System.SysUtils, System.Generics.Collections, Winapi.WinSock, + Winapi.TlHelp32; + +const + IPHLPAPI = 'iphlpapi.dll'; + AF_INET_LOCAL = 2; + TCP_TABLE_OWNER_PID_CONNECTIONS = 4; + NO_ERROR = 0; + +type + MIB_TCPROW_OWNER_PID = record + dwState: DWORD; + dwLocalAddr: DWORD; + dwLocalPort: DWORD; + dwRemoteAddr: DWORD; + dwRemotePort: DWORD; + dwOwningPid: DWORD; + end; + + MIB_TCPTABLE_OWNER_PID = record + dwNumEntries: DWORD; + table: array[0..0] of MIB_TCPROW_OWNER_PID; + end; + PMIB_TCPTABLE_OWNER_PID = ^MIB_TCPTABLE_OWNER_PID; + +function GetExtendedTcpTable(pTcpTable: Pointer; pdwSize: PDWORD; + bOrder: BOOL; ulAf: ULONG; TableClass: DWORD; Reserved: ULONG): DWORD; + stdcall; external IPHLPAPI; + +function GetPidOfTcpConnection(ALocalPort, ARemotePort: Word): DWORD; +var + Size: DWORD; + Buffer: PMIB_TCPTABLE_OWNER_PID; + i: Integer; + Row: ^MIB_TCPROW_OWNER_PID; + WantedLocal, WantedRemote: Word; +begin + Result := 0; + Size := 0; + GetExtendedTcpTable(nil, @Size, False, AF_INET_LOCAL, + TCP_TABLE_OWNER_PID_CONNECTIONS, 0); + if Size = 0 then Exit; + + GetMem(Buffer, Size); + try + if GetExtendedTcpTable(Buffer, @Size, False, AF_INET_LOCAL, + TCP_TABLE_OWNER_PID_CONNECTIONS, 0) <> NO_ERROR then Exit; + + WantedLocal := ntohs(ALocalPort); + WantedRemote := ntohs(ARemotePort); + Row := @Buffer.table[0]; + for i := 0 to Buffer.dwNumEntries - 1 do + begin + if (Word(Row.dwLocalPort) = WantedLocal) and + (Word(Row.dwRemotePort) = WantedRemote) then + Exit(Row.dwOwningPid); + Inc(Row); + end; + finally + FreeMem(Buffer); + end; +end; + +function IsDescendantOfCurrentProcess(APid: DWORD): Boolean; +const + MaxDepth = 32; +var + Snap: THandle; + Entry: TProcessEntry32W; + ParentMap: TDictionary; + Current, RootPid: DWORD; + Depth: Integer; +begin + Result := False; + if APid = 0 then Exit; + RootPid := GetCurrentProcessId; + if APid = RootPid then Exit(True); + + Snap := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if Snap = INVALID_HANDLE_VALUE then Exit; + + ParentMap := TDictionary.Create; + try + Entry.dwSize := SizeOf(Entry); + if Process32FirstW(Snap, Entry) then + repeat + ParentMap.AddOrSetValue(Entry.th32ProcessID, Entry.th32ParentProcessID); + until not Process32NextW(Snap, Entry); + + Current := APid; + for Depth := 0 to MaxDepth do + begin + if Current = RootPid then Exit(True); + if not ParentMap.TryGetValue(Current, Current) then Exit; + if (Current = 0) or (Current = 4) then Exit; + end; + finally + ParentMap.Free; + CloseHandle(Snap); + end; +end; + +end. diff --git a/delphi-backend/Source/PM.RateLimit.pas b/delphi-backend/Source/PM.RateLimit.pas index 351f036..e4b532e 100644 --- a/delphi-backend/Source/PM.RateLimit.pas +++ b/delphi-backend/Source/PM.RateLimit.pas @@ -23,6 +23,7 @@ interface uses System.SysUtils, System.JSON, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Database; @@ -67,7 +68,6 @@ function CheckRateLimit(const AIP: string): Integer; var LQ: TFDQuery; begin - Result := 0; DB.Lock; try LQ := TFDQuery.Create(nil); diff --git a/delphi-backend/Source/PM.Session.pas b/delphi-backend/Source/PM.Session.pas index 6bfee85..8ea2f7c 100644 --- a/delphi-backend/Source/PM.Session.pas +++ b/delphi-backend/Source/PM.Session.pas @@ -16,6 +16,7 @@ interface uses System.SysUtils, System.Classes, System.StrUtils, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, PM.Database, PM.Crypto, PM.JSON; @@ -55,7 +56,6 @@ var LQ: TFDQuery; LExpires: TDateTime; begin - Result := 0; LToken := ExtractBearerToken(ARequest); if LToken = '' then begin diff --git a/delphi-backend/Source/PM.SingleInstance.pas b/delphi-backend/Source/PM.SingleInstance.pas new file mode 100644 index 0000000..d372450 --- /dev/null +++ b/delphi-backend/Source/PM.SingleInstance.pas @@ -0,0 +1,76 @@ +unit PM.SingleInstance; + +{ + Single-instance guard. + + AcquireOrSignal: + - First instance: creates a named mutex and returns True. Caller proceeds. + - Subsequent instance: detects the mutex, broadcasts WM_PMSHOW so the + running instance restores from tray, returns False. Caller exits. + + WM_PMSHOW is a RegisterWindowMessage('PMServer_ShowExisting') — system- + unique, all processes that register the same string get the same ID. + PM.Bridge listens for it on its message-only window. +} + +interface + +uses + Winapi.Windows, Winapi.Messages; + +const + // Mutex name lives in the Local\ namespace → per-user-session, so a + // second user on the same machine (RDP, Switch User) can still launch + // their own instance. The Global\ namespace would block them. + PMSERVER_MUTEX_NAME = 'Local\PMServer.SingleInstance.Mutex'; + +// System-wide unique message ID, computed once. Bridge + .dpr both call +// this to get the same UINT. +function WM_PMShowMessage: UINT; + +// Try to become the single instance. True = we are first; False = another +// instance was already running (we have signalled it and the caller must +// exit immediately). +function AcquireOrSignal: Boolean; + +implementation + +var + _Mutex: THandle = 0; + _WmShow: UINT = 0; + +function WM_PMShowMessage: UINT; +begin + if _WmShow = 0 then + _WmShow := RegisterWindowMessage('PMServer_ShowExisting'); + Result := _WmShow; +end; + +function AcquireOrSignal: Boolean; +var + LErr: DWORD; +begin + _Mutex := CreateMutex(nil, True, PMSERVER_MUTEX_NAME); + LErr := GetLastError; + + if (_Mutex <> 0) and (LErr <> ERROR_ALREADY_EXISTS) then + begin + // We are the first instance. Keep the mutex alive for the process + // lifetime — Windows releases it automatically on exit. + Result := True; + Exit; + end; + + // Another instance is already running. Close our handle (it isn't ours) + // and broadcast the show-message to all top-level windows. The running + // bridge picks it up on its message-only window. + if _Mutex <> 0 then + begin + CloseHandle(_Mutex); + _Mutex := 0; + end; + PostMessage(HWND_BROADCAST, WM_PMShowMessage, 0, 0); + Result := False; +end; + +end. diff --git a/delphi-backend/Source/PM.UserPrefs.pas b/delphi-backend/Source/PM.UserPrefs.pas new file mode 100644 index 0000000..d0ab4c9 --- /dev/null +++ b/delphi-backend/Source/PM.UserPrefs.pas @@ -0,0 +1,180 @@ +unit PM.UserPrefs; + +{ + Device-bound key/value prefs persisted across launches. + + Problem solved: the HTTP server binds an ephemeral port that changes on + every start (49152-65535). localStorage is keyed by origin (scheme+host + +port) so a different port = a fresh localStorage = anything persisted + there is lost between launches. For prefs that must survive a reboot + (remembered username, etc.) we persist them via this unit instead. + + Storage: %LOCALAPPDATA%\PMServer\prefs.bin + Format: DPAPI-encrypted UTF-8 JSON object {"key":"value",.... + Scope: current Windows user (same threat model as PM.QuickUnlock). +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, System.JSON, + Winapi.Windows; + +function GetPref(const AKey: string): string; +procedure SetPref(const AKey, AValue: string); + +implementation + +type + TDataBlob = record + cbData: DWORD; + pbData: PByte; + end; + PDataBlob = ^TDataBlob; + +function CryptProtectData(pDataIn: PDataBlob; szDataDescr: PWideChar; + pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer; + dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall; + external 'crypt32.dll' name 'CryptProtectData'; + +function CryptUnprotectData(pDataIn: PDataBlob; ppszDataDescr: PPWideChar; + pOptionalEntropy: PDataBlob; pvReserved: Pointer; pPromptStruct: Pointer; + dwFlags: DWORD; pDataOut: PDataBlob): BOOL; stdcall; + external 'crypt32.dll' name 'CryptUnprotectData'; + +function LocalFree(hMem: HLOCAL): HLOCAL; stdcall; + external 'kernel32.dll' name 'LocalFree'; + +function StorageDir: string; +begin + Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'), 'PMServer'); +end; + +function StorageFile: string; +begin + Result := TPath.Combine(StorageDir, 'prefs.bin'); +end; + +procedure EnsureStorageDir; +begin + if not TDirectory.Exists(StorageDir) then + TDirectory.CreateDirectory(StorageDir); +end; + +function LoadAll: TJSONObject; +var + LEncrypted: TBytes; + LIn, LOut: TDataBlob; + LStream: TFileStream; + LPlain: string; + LValue: TJSONValue; +begin + // Default to an empty object; every error path just Exits with this. + // Only the success path replaces it with the parsed JSON. + Result := TJSONObject.Create; + + if not TFile.Exists(StorageFile) then Exit; + + try + LStream := TFileStream.Create(StorageFile, fmOpenRead or fmShareDenyWrite); + try + SetLength(LEncrypted, LStream.Size); + if Length(LEncrypted) > 0 then + LStream.ReadBuffer(LEncrypted[0], LStream.Size); + finally + LStream.Free; + end; + except + Exit; + end; + + if Length(LEncrypted) = 0 then Exit; + + LIn.cbData := Length(LEncrypted); + LIn.pbData := @LEncrypted[0]; + LOut.pbData := nil; + LOut.cbData := 0; + + if not CryptUnprotectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit; + try + SetString(LPlain, PAnsiChar(LOut.pbData), LOut.cbData); + LValue := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetBytes(LPlain), 0); + if LValue is TJSONObject then + begin + // Replace the default empty object with the parsed one. + Result.Free; + Result := TJSONObject(LValue); + end + else if LValue <> nil then + LValue.Free; + finally + if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData)); + end; +end; + +procedure SaveAll(AObj: TJSONObject); +var + LBytes: TBytes; + LIn, LOut: TDataBlob; + LStream: TFileStream; + LJsonStr: string; +begin + LJsonStr := AObj.ToJSON; + LBytes := TEncoding.UTF8.GetBytes(LJsonStr); + if Length(LBytes) = 0 then Exit; + + LIn.cbData := Length(LBytes); + LIn.pbData := @LBytes[0]; + LOut.pbData := nil; + LOut.cbData := 0; + + if not CryptProtectData(@LIn, nil, nil, nil, nil, 0, @LOut) then Exit; + try + EnsureStorageDir; + LStream := TFileStream.Create(StorageFile, fmCreate); + try + LStream.WriteBuffer(LOut.pbData^, LOut.cbData); + finally + LStream.Free; + end; + finally + if LOut.pbData <> nil then LocalFree(HLOCAL(LOut.pbData)); + end; +end; + +function GetPref(const AKey: string): string; +var + LObj: TJSONObject; + LValue: TJSONValue; +begin + Result := ''; + LObj := LoadAll; + try + if LObj = nil then Exit; + LValue := LObj.GetValue(AKey); + if LValue <> nil then + Result := LValue.Value; + finally + LObj.Free; + end; +end; + +procedure SetPref(const AKey, AValue: string); +var + LObj: TJSONObject; + LExisting: TJSONValue; +begin + LObj := LoadAll; + try + if LObj = nil then LObj := TJSONObject.Create; + LExisting := LObj.GetValue(AKey); + if LExisting <> nil then + LObj.RemovePair(AKey).Free; + LObj.AddPair(AKey, AValue); + SaveAll(LObj); + finally + LObj.Free; + end; +end; + +end. diff --git a/delphi-backend/UMainForm.fmx b/delphi-backend/UMainForm.fmx index 5ddd177..24903f3 100644 --- a/delphi-backend/UMainForm.fmx +++ b/delphi-backend/UMainForm.fmx @@ -1,15 +1,15 @@ object MainForm: TMainForm Left = 0 Top = 0 - Caption = 'Password Manager - Delphi Backend' + Caption = 'Password Manager' ClientHeight = 720 ClientWidth = 1100 FormFactor.Width = 320 FormFactor.Height = 480 FormFactor.Devices = [Desktop] OnCreate = FormCreate - OnDestroy = FormDestroy OnCloseQuery = FormCloseQuery + OnDestroy = FormDestroy DesignerMasterStyle = 0 object PanelTop: TPanel Align = Top diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index d25a492..7da371d 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -4,12 +4,14 @@ interface uses System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, + System.StrUtils, + Winapi.Windows, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, - FMX.Dialogs, + FMX.Dialogs, FMX.DialogService, FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, - PM.HTTPServer, PM.Bridge, PM.QuickUnlock; + PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs; type TMainForm = class(TForm) @@ -37,9 +39,17 @@ type FBridge: TPMBridge; FPendingURL: string; FNavTimer: TTimer; - FNavAttempts: Integer; + FRequireAccessToken: Boolean; + FRequireProcessCheck: Boolean; FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses // FormCloseQuery's minimize-to-tray intercept. + FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute + // Pending payload for the 60ms delay timer (focus settle before SendInput). + // Cleared inside AutofillTimerTick. + FAutofillPendingHWND: HWND; + FAutofillPendingUser: string; + FAutofillPendingPass: string; + procedure AutofillTimerTick(Sender: TObject); procedure LogLine(const AMsg: string); procedure UpdateButtons; procedure NavigateToVault; @@ -52,6 +62,11 @@ type procedure BridgeTrayRestore; procedure BridgeLockRequest; procedure BridgeQuit; + procedure BridgeAutofillRequest(AKind: TAutofillKind; + ATargetHWND: HWND; const ATitle: string); + procedure BridgeDebugHotkey; + procedure BridgeNewEntryHotkey(const AWindowTitle: string); + procedure WebBrowserInitialized(Sender: TObject); end; var @@ -67,13 +82,19 @@ begin FServer.OnLog := LogLine; FBridge := TPMBridge.Create(Self); - FBridge.OnSystemLock := BridgeSystemLock; - FBridge.OnTrayRestore := BridgeTrayRestore; - FBridge.OnLockRequest := BridgeLockRequest; - FBridge.OnQuit := BridgeQuit; + FBridge.OnSystemLock := BridgeSystemLock; + FBridge.OnTrayRestore := BridgeTrayRestore; + FBridge.OnLockRequest := BridgeLockRequest; + FBridge.OnQuit := BridgeQuit; + FBridge.OnAutofillRequest := BridgeAutofillRequest; + FBridge.OnDebugHotkey := BridgeDebugHotkey; + FBridge.OnNewEntryHotkey := BridgeNewEntryHotkey; + FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup + FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later + FAutofillTargetHWND := 0; - // Wire the cmd:// bridge before any navigation happens. WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate; + WebBrowser.OnInitialized := WebBrowserInitialized; // Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores // Navigate() calls until Edge Chromium finishes its async init (~1-2s). @@ -88,6 +109,28 @@ begin UpdateButtons; LogLine('Password Manager - Delphi backend ready.'); LogLine('Click Start to launch server + embedded web vault.'); + PanelTop.Visible := False; + FRequireAccessToken := True; + FRequireProcessCheck := True; + edtPort.Text := '0'; + if FileExists('config.txt') then + begin + var configList := TStringList.Create; + configList.LoadFromFile('config.txt'); + try + var defPort := StrToIntDef(configList.Values['port'], 0); + edtPort.Text := defPort.ToString; + PanelTop.Visible := configList.Values['debug'].ToLower.Equals('true'); + if configList.Values['require_token'].ToLower.Equals('false') then + FRequireAccessToken := False; + if configList.Values['require_process_check'].ToLower.Equals('false') then + FRequireProcessCheck := False; + finally + FreeAndNil(configList); + end; + end; + btnStartClick(Nil); + btnToggleLogClick(nil); end; procedure TMainForm.FormDestroy(Sender: TObject); @@ -96,7 +139,36 @@ begin FServer.Free; end; -procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean); +procedure TMainForm.WebBrowserInitialized(Sender: TObject); +begin + WebBrowser.EnableContextMenu := False; + WebBrowser.EnableShowDebugConsole := False; +end; + +procedure TMainForm.BridgeNewEntryHotkey(const AWindowTitle: string); +var + EscapedTitle: string; +begin + if not FServer.Active then Exit; + FBridge.RestoreFromTray; + EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]); + EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.onNewEntryFromTitle==="function")' + + 'Bridge.onNewEntryFromTitle("' + EscapedTitle + '")'); + LogLine('New entry hotkey — title: "' + AWindowTitle + '"'); +end; + +procedure TMainForm.BridgeDebugHotkey; +begin + if not FileExists('config.txt') then + Exit; + PanelTop.Visible := not PanelTop.Visible; + LogLine('Debug panel ' + IfThen(PanelTop.Visible, 'shown', 'hidden') + + ' via Ctrl+Shift+D'); +end; + +Procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean); begin // The tray-menu "Quit" handler sets FQuitting before triggering close, // so we bypass the minimize-to-tray intercept in that case. @@ -138,20 +210,31 @@ begin lblStatus.Text := 'Stopped'; end; +function MaskAccessToken(const AUrl: string): string; +var + TokenPos: Integer; +begin + Result := AUrl; + TokenPos := Pos('?pmt=', Result); + if TokenPos > 0 then + Result := Copy(Result, 1, TokenPos + 4) + '***'; +end; + procedure TMainForm.NavigateToVault; begin - FPendingURL := 'http://127.0.0.1:' + edtPort.Text + '/index.html'; - LogLine('Will navigate embedded browser in ~1.5s to: ' + FPendingURL); - // Schedule a single Navigate after Edge has had time to initialize. - FNavTimer.Enabled := False; // restart timer if already running + FPendingURL := 'http://127.0.0.1:' + FServer.BoundPort.ToString + '/index.html'; + if FServer.RequireAccessToken then + FPendingURL := FPendingURL + '?pmt=' + FServer.AccessToken; + LogLine('Will navigate embedded browser in ~1.5s to: ' + MaskAccessToken(FPendingURL)); + FNavTimer.Enabled := False; FNavTimer.Enabled := True; end; procedure TMainForm.NavTimerTick(Sender: TObject); begin - FNavTimer.Enabled := False; // one-shot + FNavTimer.Enabled := False; if FPendingURL = '' then Exit; - LogLine('Navigating to: ' + FPendingURL); + LogLine('Navigating to: ' + MaskAccessToken(FPendingURL)); WebBrowser.Navigate(FPendingURL); FPendingURL := ''; end; @@ -162,15 +245,16 @@ var begin LPort := StrToIntDef(edtPort.Text, 8765); try - FServer.Start(LPort); + FServer.Start(LPort, True, FRequireAccessToken, FRequireProcessCheck); + edtPort.Text := FServer.BoundPort.ToString; UpdateButtons; NavigateToVault; except on E: Exception do begin LogLine('ERROR starting server: ' + E.Message); - MessageDlg('Failed to start: ' + E.Message, - TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0); + TDialogService.MessageDialog('Failed to start: ' + E.Message, + TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], TMsgDlgBtn.mbOK, 0, nil); end; end; end; @@ -279,6 +363,17 @@ begin LogLine('Clipboard cleared by JS request'); end + else if ACmd = 'clipboard/read' then + begin + var ClipText := FBridge.SecureClipboard.ReadText; + var Escaped := StringReplace(ClipText, '\', '\\', [rfReplaceAll]); + Escaped := StringReplace(Escaped, '"', '\"', [rfReplaceAll]); + Escaped := StringReplace(Escaped, #13, '\r', [rfReplaceAll]); + Escaped := StringReplace(Escaped, #10, '\n', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onClipboardRead)Bridge.onClipboardRead("' + Escaped + '")'); + end + // ---- Quick unlock (DPAPI persistence of the vault key) ---- // store: client provides a base64-encoded blob (UTF-8 JSON, content // opaque to us). We DPAPI-encrypt and stash on disk. @@ -332,15 +427,145 @@ begin BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')'); end + // ---- Autofill -------------------------------------------------------- + // configure: JS calls this on page load / settings change to sync the + // hotkey registration state with the user's localStorage preference. + // Uses the historical defaults (Ctrl+Shift+L / Ctrl+Shift+P) — for + // custom combos, JS sends cmd://autofill/hotkeys instead. + else if ACmd = 'autofill/configure' then + begin + if GetParam('enabled') = '1' then + begin + FBridge.RegisterAutofillHotkey; + LogLine('Autofill hotkeys registered (defaults)'); + end + else + begin + FBridge.UnregisterAutofillHotkey; + LogLine('Autofill hotkeys unregistered'); + end; + end + + // hotkeys: JS pushes the user-configured combos. Params: + // enabled = '1' | '0' + // full_mods = MOD_x bitmask (decimal), full_vk = VK code (decimal) + // pwd_mods, pwd_vk = same for the password-only hotkey + // If enabled=0, we just unregister and ignore the rest. If enabled=1, + // we register both with the supplied combos (replacing any prior). + else if ACmd = 'autofill/hotkeys' then + begin + if GetParam('enabled') <> '1' then + begin + FBridge.UnregisterAutofillHotkey; + LogLine('Autofill hotkeys unregistered (custom)'); + end + else + begin + var LFullMods := Word(StrToIntDef(GetParam('full_mods'), 6)); // Ctrl+Shift + var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L'))); + var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6)); + var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P'))); + var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk, + LPwdMods, LPwdVk); + LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d (all_ok=%s)', + [LFullMods, LFullVk, LPwdMods, LPwdVk, BoolToStr(LAllOk, True)])); + // Notify JS of the result so the UI can flag a failed-to-register combo + // (typically a clash with another app's global hotkey). + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' + + 'Bridge.onAutofillHotkeysResult(' + BoolToStr(LAllOk, True).ToLower + ')'); + end; + end + + // execute: JS has matched an entry, decrypted the password, and is + // telling Delphi to type username + Tab + password into the saved HWND. + else if ACmd = 'autofill/execute' then + begin + FAutofillPendingUser := GetParam('username'); + FAutofillPendingPass := GetParam('password'); + FAutofillPendingHWND := FAutofillTargetHWND; + FAutofillTargetHWND := 0; + + // Small timer so SetForegroundWindow has time to take effect before + // SendInput fires — avoids the first keystrokes going to our window. + // TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a + // dedicated method on the form and stash the payload in fields. + var LTimer := TTimer.Create(Self); + LTimer.Interval := 60; + LTimer.OnTimer := AutofillTimerTick; + LTimer.Enabled := True; + end + + // cancel: JS found no match or user dismissed the picker — nothing to type. + else if ACmd = 'autofill/cancel' then + begin + FAutofillTargetHWND := 0; + LogLine('Autofill cancelled (no match or dismissed)'); + end + + // focus: JS asks us to bring the main window to front (e.g. when the + // autofill picker opens — without this the picker is shown in the + // WebView but the user might not notice if our window was minimised + // or behind other apps). The Target HWND stays saved; ExecuteAutofill + // restores it later via ForceForegroundWindow. + else if ACmd = 'app/focus' then + begin + FBridge.RestoreFromTray; + LogLine('App brought to front (autofill picker)'); + end + + else if ACmd = 'app/ready' then + begin + WebBrowser.SetFocus; + WebBrowser.ExecuteJavaScript( + 'setTimeout(()=>{var u=document.getElementById("loginUsername"),' + + 'p=document.getElementById("loginPassword");' + + 'if(u&&u.value){p&&p.focus();}else{u&&u.focus();}},0)'); + end + + else if ACmd = 'app/theme' then + FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark') + + // ---- Device-bound prefs (DPAPI key/value) ---------------------------- + // Used for prefs that must survive the ephemeral-port reset of the + // WebView2 localStorage (rememberedUsername, etc.). + else if ACmd = 'prefs/get' then + begin + var LKey := GetParam('key'); + if LKey = '' then Exit; + var LVal := PM.UserPrefs.GetPref(LKey); + var LEscapedKey := StringReplace(LKey, '\', '\\', [rfReplaceAll]); + LEscapedKey := StringReplace(LEscapedKey, '"', '\"', [rfReplaceAll]); + var LEscapedVal := StringReplace(LVal, '\', '\\', [rfReplaceAll]); + LEscapedVal := StringReplace(LEscapedVal, '"', '\"', [rfReplaceAll]); + LEscapedVal := StringReplace(LEscapedVal, #13, '\r', [rfReplaceAll]); + LEscapedVal := StringReplace(LEscapedVal, #10, '\n', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onPrefResult)' + + 'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")'); + end + + else if ACmd = 'prefs/set' then + begin + var LKey := GetParam('key'); + if LKey = '' then Exit; + PM.UserPrefs.SetPref(LKey, GetParam('value')); + end + else LogLine('Bridge: unknown command "' + ACmd + '"'); end; procedure TMainForm.BridgeSystemLock; begin - // Windows session locked — lock the vault in the JS layer immediately. - LogLine('Windows session locked — locking vault'); - WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()'); + // Windows session locked or system suspending — delegate to Bridge.onSystemLock + // in JS which honours the "quick unlock" opt-out (DPAPI already gates + // access via the Windows account, so re-locking on top of Windows lock + // is redundant for users who enabled it). + LogLine('Windows session locked / suspend — notifying JS'); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.onSystemLock==="function")Bridge.onSystemLock();' + + 'else if(typeof lockVault==="function")lockVault()'); end; procedure TMainForm.BridgeTrayRestore; @@ -375,4 +600,48 @@ begin Application.Terminate; end; +procedure TMainForm.AutofillTimerTick(Sender: TObject); +var + TargetHwnd: HWND; + PendingUser, PendingPass: string; + ForegroundAfter: HWND; +begin + TargetHwnd := FAutofillPendingHWND; + PendingUser := FAutofillPendingUser; + PendingPass := FAutofillPendingPass; + FAutofillPendingHWND := 0; + FAutofillPendingUser := ''; + FAutofillPendingPass := ''; + + TTimer(Sender).Enabled := False; + TTimer(Sender).Free; + + FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass); + ForegroundAfter := GetForegroundWindow; + LogLine(Format('Autofill executed — target=%s, foreground_after=%s, match=%s', + [IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8), + BoolToStr(ForegroundAfter = TargetHwnd, True)])); +end; + +procedure TMainForm.BridgeAutofillRequest(AKind: TAutofillKind; + ATargetHWND: HWND; const ATitle: string); +var + LTitle, LKind: string; +begin + if not FServer.Active then Exit; + FAutofillTargetHWND := ATargetHWND; + + // Escape the title for safe injection into a JS string literal. + LTitle := ATitle; + LTitle := LTitle.Replace('\', '\\'); + LTitle := LTitle.Replace('"', '\"'); + + if AKind = akPasswordOnly then LKind := 'password' else LKind := 'full'; + + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' + + 'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")'); + LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"'); +end; + end. diff --git a/delphi-backend/app.ico b/delphi-backend/app.ico new file mode 100644 index 0000000..1aa4676 Binary files /dev/null and b/delphi-backend/app.ico differ diff --git a/delphi-backend/app.png b/delphi-backend/app.png new file mode 100644 index 0000000..77177ff Binary files /dev/null and b/delphi-backend/app.png differ diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 0c78b52..cc02664 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ diff --git a/icon-hex-16.svg b/icon-hex-16.svg new file mode 100644 index 0000000..200b953 --- /dev/null +++ b/icon-hex-16.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-hex.svg b/icon-hex.svg new file mode 100644 index 0000000..e8652f3 --- /dev/null +++ b/icon-hex.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-key.svg b/icon-key.svg new file mode 100644 index 0000000..2bc80b2 --- /dev/null +++ b/icon-key.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-shield.svg b/icon-shield.svg new file mode 100644 index 0000000..e8f103a --- /dev/null +++ b/icon-shield.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon-vault.svg b/icon-vault.svg new file mode 100644 index 0000000..6960957 --- /dev/null +++ b/icon-vault.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..b7abfc8 --- /dev/null +++ b/icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/index-legacy.html b/index-legacy.html new file mode 100644 index 0000000..f3e900f --- /dev/null +++ b/index-legacy.html @@ -0,0 +1,218 @@ + + + + + + 🔐 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 caf3f49..38b7121 100644 --- a/index.html +++ b/index.html @@ -38,7 +38,11 @@ + + + + @@ -99,7 +103,16 @@ + - + + + + + diff --git a/js/app-legacy.js b/js/app-legacy.js new file mode 100644 index 0000000..82093a6 --- /dev/null +++ b/js/app-legacy.js @@ -0,0 +1,1550 @@ +// 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.js b/js/app.js index a77f394..fba7b17 100644 --- a/js/app.js +++ b/js/app.js @@ -13,6 +13,7 @@ const API = (location.pathname.indexOf('/password-manager/') === 0) // ---- Delphi native bridge ---------------------------------- // Active only when running inside the Delphi-hosted WebView2 (API === ''). // Falls back to navigator.clipboard for the standalone PHP frontend. +const prefResolvers = {}; const Bridge = (() => { const active = (API === ''); @@ -35,10 +36,17 @@ const Bridge = (() => { return true; }, - // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK. - // Exposed as window.Bridge.onSystemLock so the Delphi side can call it, - // but the actual lock is triggered directly via lockVault() in Delphi. + // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK and + // PBT_APMSUSPEND. When quick-unlock is enabled on this device the + // DPAPI blob already gates access via the Windows user account, so + // re-locking is redundant — we just stay unlocked and the user is + // back where they left off when they return. onSystemLock() { + if (state.quickUnlockEnabled) { + if (typeof toast === 'function') + toast('System lock — vault kept unlocked (quick unlock active)'); + return; + } if (typeof lockVault === 'function') lockVault(); }, @@ -55,6 +63,131 @@ const Bridge = (() => { if (typeof toast === 'function') toast('Welcome back'); } }, + + // ---- Autofill (Ctrl+Shift+L / Ctrl+Shift+P global hotkeys) ---------- + + // Called by Delphi when a hotkey fires. windowTitle = foreground + // window title at hotkey time. kind = "full" (user+Tab+pwd) or + // "password" (password only — for step-2 forms, unlock screens). + onAutofillRequest(windowTitle, kind) { + autofillHandleRequest(windowTitle, kind || 'full'); + }, + + // Called by Delphi on Ctrl+Shift+A. Opens the new-entry modal with + // the foreground window title pre-filled (browser suffix stripped). + onNewEntryFromTitle(windowTitle) { + if (!state.cryptoKey || state.locked || !state.token) { + toast('Unlock the vault first', 'warning'); + return; + } + const cleaned = autofillStripBrowserSuffix(windowTitle); + openEntryModal(); + setTimeout(() => { + const titleField = $('#entryTitle'); + if (titleField) { + titleField.value = cleaned; + $('#entrySite').focus(); + } + }, 0); + }, + + // Tell Delphi to simulate keystrokes. Empty username = password only + // (no Tab is sent). + executeAutofill(username, password) { + if (!active) return; + cmd('cmd://autofill/execute?username=' + encodeURIComponent(username) + + '&password=' + encodeURIComponent(password)); + }, + + // Ask Delphi to bring the main window to front (used when the + // multi-match picker opens, so the user definitely sees it even + // if the app was minimised to tray or hidden behind other apps). + focusApp() { + if (!active) return; + cmd('cmd://app/focus'); + }, + + // Tell Delphi the page is rendered and waiting for input — Delphi + // calls WebBrowser.SetFocus (the FMX control needs OS-level focus + // before any input.focus() inside the DOM can work) then injects + // a focus script targeting the visible auth field. + appReady() { + if (!active) return; + cmd('cmd://app/ready'); + }, + + // Sync the Windows title bar with the app theme (dark vs light). + // Calls DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE on the + // form's HWND. No-op on Windows < 10 build 19044. + syncTitleBarTheme(mode) { + if (!active) return; + cmd('cmd://app/theme?mode=' + (mode === 'light' ? 'light' : 'dark')); + }, + + // Tell Delphi we couldn't find a match / user cancelled. + cancelAutofill() { + if (!active) return; + cmd('cmd://autofill/cancel'); + }, + + // Sync the hotkey registration state with this device's preference. + configureAutofill(enabled) { + if (!active) return; + cmd('cmd://autofill/configure?enabled=' + (enabled ? '1' : '0')); + }, + + // Send the full hotkey configuration to Delphi (enabled + combos). + // combos = { full: {ctrl,shift,alt,win,key}, password: {…} }. + setAutofillHotkeys(enabled, combos) { + if (!active) return; + const f = autofillComboToWin32(combos.full); + const p = autofillComboToWin32(combos.password); + cmd('cmd://autofill/hotkeys?enabled=' + (enabled ? '1' : '0') + + '&full_mods=' + f.mods + '&full_vk=' + f.vk + + '&pwd_mods=' + p.mods + '&pwd_vk=' + p.vk); + }, + + // Called by Delphi after a setAutofillHotkeys request, with true if + // BOTH combos registered successfully, false otherwise (clash with + // another app holding a global hotkey). Surfaces a toast. + onAutofillHotkeysResult(allOk) { + if (allOk) { + toast('Autofill hotkeys updated'); + } else { + toast('Autofill: one or both hotkeys are already used by another app', 'warning'); + } + }, + + // ---- Device-bound prefs (DPAPI-backed) ---------------------------- + // localStorage is keyed by origin, and our HTTP port is random on + // every launch — so anything we put there is wiped at reboot. For + // prefs that need to survive a reboot (remembered username, etc.), + // round-trip through Delphi which persists via DPAPI. + getPref(key) { + if (!active) return Promise.resolve(''); + return new Promise(resolve => { + prefResolvers[key] = resolve; + cmd('cmd://prefs/get?key=' + encodeURIComponent(key)); + setTimeout(() => { + if (prefResolvers[key] === resolve) { + delete prefResolvers[key]; + resolve(''); + } + }, 2000); + }); + }, + setPref(key, value) { + if (!active) return; + cmd('cmd://prefs/set?key=' + encodeURIComponent(key) + + '&value=' + encodeURIComponent(value || '')); + }, + onPrefResult(key, value) { + const r = prefResolvers[key]; + if (r) { + delete prefResolvers[key]; + r(value || ''); + } + }, }; })(); @@ -74,6 +207,7 @@ const state = { cryptoKey: null, entries: [], trashed: [], + trashedCount: 0, // server-side count, updated separately from state.trashed folders: ['All'], view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' search: '', @@ -83,14 +217,39 @@ const state = { autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false + // Show the raw site/URL under the display name on cards. Default OFF + // because the display name is meant to be the user-friendly label; + // most users don't want the hostname cluttering the card layout. + showSiteOnCards: localStorage.getItem('showSiteOnCards') === '1', // default false compactActions: localStorage.getItem('compactActions') === '1', // default false - viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' + viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' | 'table' + // Sort criterion + direction. Defaults: alphabetical by display name — + // the most common pattern for a password manager (predictable lookup). + // Other values: 'updated' (last modified), 'created' (creation order), + // 'site' (raw site/URL, distinct from name when user set a title). + sortBy: localStorage.getItem('sortBy') || 'name', + sortDir: localStorage.getItem('sortDir') || 'asc', + pageSize: parseInt(localStorage.getItem('pageSize') || '25') || 25, + currentPage: 1, checked: new Set(), // entry IDs checked for batch operations hibpEnabled: localStorage.getItem('hibpEnabled') === '1', // default OFF // entry.id → count from HIBP (0 = clean, >0 = pwned, undefined = unchecked) hibpResults: new Map(), quickUnlockEnabled: localStorage.getItem('quickUnlockEnabled') === '1', recoveryConfigured: false, // refreshed by refreshRecoveryStatus on Settings open + autofillEnabled: localStorage.getItem('autofillEnabled') !== '0', // default ON + // Hotkey combos. Each combo = { ctrl, shift, alt, win, key }. + // key is the uppercase character or VK label ('A'..'Z', '0'..'9', + // 'F1'..'F12'). Default: Ctrl+Shift+L / Ctrl+Shift+P. Combos are + // local-by-default but ALSO synced via settings_json so they travel + // with the user (still Windows-only at runtime — non-Windows clients + // ignore the value). + autofillHotkeyFull: JSON.parse(localStorage.getItem('autofillHotkeyFull') || + '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"L"}'), + autofillHotkeyPwd: JSON.parse(localStorage.getItem('autofillHotkeyPwd') || + '{"ctrl":true,"shift":true,"alt":false,"win":false,"key":"P"}'), + sidebarCollapsed: JSON.parse(localStorage.getItem('sidebarCollapsed') || + '{"folders":false,"tags":false,"tools":false}'), }; // ============================================================ @@ -317,6 +476,105 @@ async function decryptTotpSecret(encB64, ivB64) { return await decryptPwd(encB64, ivB64); } +// Generate a cryptographically random RFC 4648 base32 secret. 20 bytes = +// 160 bits → 32 base32 chars, RFC 6238 §5.1 recommended TOTP key size. +function randomBase32Secret(numBytes) { + numBytes = numBytes || 20; + const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const bytes = crypto.getRandomValues(new Uint8Array(numBytes)); + let bits = 0, buffer = 0, out = ''; + for (let i = 0; i < bytes.length; i++) { + buffer = (buffer << 8) | bytes[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPH[(buffer >> bits) & 0x1F]; + } + } + if (bits > 0) out += ALPH[(buffer << (5 - bits)) & 0x1F]; + return out; +} + +// ---- Standalone TOTP generator modal (paste secret → live code) ----- +let totpToolTimer = null; +function openTotpTool() { + const modal = document.getElementById('totpToolModal'); + const input = document.getElementById('totpToolSecret'); + const codeEl = document.getElementById('totpToolCode'); + const barEl = document.getElementById('totpToolBar'); + const errEl = document.getElementById('totpToolError'); + modal.classList.remove('is-hidden'); + input.value = ''; + codeEl.textContent = '— — — — — —'; + barEl.style.width = '100%'; + errEl.style.display = 'none'; + setTimeout(() => input.focus(), 0); + + async function tick() { + let secret = input.value.trim(); + if (!secret) { + codeEl.textContent = '— — — — — —'; + barEl.style.width = '100%'; + errEl.style.display = 'none'; + return; + } + if (secret.toLowerCase().startsWith('otpauth://')) { + const fromUri = parseOtpAuthUri(secret); + if (fromUri) { secret = fromUri; input.value = fromUri; } + } + try { + const t = await generateTOTP(secret); + codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2'); + const newPct = (t.secondsLeft / 30) * 100; + if (newPct > (tick._lastPct || 0) + 5) { + barEl.style.transition = 'none'; + barEl.style.width = newPct.toFixed(1) + '%'; + void barEl.offsetWidth; + barEl.style.transition = ''; + } else { + barEl.style.width = newPct.toFixed(1) + '%'; + } + tick._lastPct = newPct; + errEl.style.display = 'none'; + } catch (e) { + codeEl.textContent = '— — — — — —'; + barEl.style.width = '0%'; + errEl.textContent = 'Invalid base32 secret'; + errEl.style.display = ''; + } + } + input.addEventListener('input', tick); + if (totpToolTimer) clearInterval(totpToolTimer); + totpToolTimer = setInterval(tick, 1000); + + document.getElementById('totpToolCopy').onclick = async () => { + const code = codeEl.textContent.replace(/\s/g, ''); + if (!/^\d{6}$/.test(code)) return; + if (Bridge.active) Bridge.copySecure(code, 30000); + else { try { await navigator.clipboard.writeText(code); } catch (e) {} } + toast('TOTP code copied'); + }; + document.getElementById('totpToolGen').onclick = () => { + input.value = randomBase32Secret(20); + tick(); + toast('Random secret generated'); + }; + document.getElementById('totpToolCopySecret').onclick = async () => { + const s = input.value.trim(); + if (!s) return; + if (Bridge.active) Bridge.copySecure(s, 30000); + else { try { await navigator.clipboard.writeText(s); } catch (e) {} } + toast('Secret copied'); + }; + modal.querySelectorAll('[data-close]').forEach(b => { + b.onclick = () => closeTotpTool(); + }); +} +function closeTotpTool() { + document.getElementById('totpToolModal').classList.add('is-hidden'); + if (totpToolTimer) { clearInterval(totpToolTimer); totpToolTimer = null; } +} + // ============================================================ // HIBP — Have I Been Pwned breach check (k-anonymity) // ============================================================ @@ -633,8 +891,20 @@ async function doLogin(e) { sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + // Persist via DPAPI when running inside the Delphi host (localStorage + // is wiped on each restart because the HTTP port — and therefore the + // origin — changes every launch). Fall back to localStorage for the + // web/PHP frontend where the origin is stable. + const remember = $('#loginRememberUser').checked; + if (Bridge.active) { + Bridge.setPref('rememberedUsername', remember ? u : ''); + } else { + if (remember) localStorage.setItem('rememberedUsername', u); + else localStorage.removeItem('rememberedUsername'); + } // cryptoKey is already derived — no second PBKDF2 pass. state.cryptoKey = derived.cryptoKey; + state.justRecovered = false; await persistCryptoKey(); toast('Welcome back, ' + u); await enterApp(); @@ -739,6 +1009,13 @@ function lockVault() { state.entries = []; state.trashed = []; state.locked = true; + state.justRecovered = false; + if (typeof authTickTimer !== 'undefined' && authTickTimer) { + clearInterval(authTickTimer); authTickTimer = null; + } + if (typeof totpToolTimer !== 'undefined' && totpToolTimer) { + clearInterval(totpToolTimer); totpToolTimer = null; + } showAuth(); // Two UI variants for the auth screen: @@ -838,9 +1115,17 @@ async function loadTrash() { try { const r = await api('/entries?deleted=1', { headers: authHeaders() }); state.trashed = Array.isArray(r) ? r : []; + state.trashedCount = state.trashed.length; } catch (e) { state.trashed = []; } } +async function loadEntryCounts() { + try { + const r = await api('/entries/count', { headers: authHeaders() }); + if (r && typeof r.trashed === 'number') state.trashedCount = r.trashed; + } catch (e) { /* silent — sidebar count just stays 0 */ } +} + // ============================================================ // FILTERS / DERIVED // ============================================================ @@ -855,7 +1140,10 @@ function filteredEntries() { if (state.view === 'favorites') list = list.filter(e => e.favorite); else if (state.view.startsWith('folder:')) { const f = state.view.slice(7); - if (f !== 'All') list = list.filter(e => e.folder === f); + // 'folder:All' is now the "(no folder)" pseudo-entry → filter + // to entries that are uncategorized (folder is 'All', empty, + // or absent). All other folder names are exact-matched. + list = list.filter(e => (e.folder || 'All') === f); } else if (state.view.startsWith('tag:')) { const t = state.view.slice(4); list = list.filter(e => parseTags(e.tags).includes(t)); @@ -865,10 +1153,16 @@ function filteredEntries() { const q = state.search.toLowerCase(); list = list.filter(e => (e.site || '').toLowerCase().includes(q) || + (e.title || '').toLowerCase().includes(q) || (e.username || '').toLowerCase().includes(q) || (e.tags || '').toLowerCase().includes(q) ); } + // Trash view keeps the deletion order (newest first) — re-sorting feels + // wrong for a recoverable archive. All other views honour user choice. + if (state.view !== 'trash') { + list = sortEntries(list, state.sortBy, state.sortDir); + } return list; } @@ -887,6 +1181,7 @@ function viewTitle() { if (state.view === 'all') return 'All items'; if (state.view === 'favorites') return 'Favorites'; if (state.view === 'trash') return 'Trash'; + if (state.view === 'authenticator') return 'Authenticator'; if (state.view.startsWith('folder:')) return state.view.slice(7); if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); return 'Items'; @@ -905,17 +1200,32 @@ function renderSidebar() { // counts $('#countAll').textContent = state.entries.length; $('#countFav').textContent = state.entries.filter(e => e.favorite).length; - $('#countTrash').textContent = state.trashed.length || ''; + // Prefer the server-side count (always up-to-date even if user never + // navigated to Trash this session) ; fall back to local array length. + const trashN = state.trashedCount || state.trashed.length || 0; + $('#countTrash').textContent = trashN || ''; + + // Section totals — shown next to the section header so the user still + // sees the count when the section is collapsed. + const folderCount = state.folders.filter(n => n !== 'All').length; + const tagCount = allTags().length; + const fc = document.getElementById('countFolders'); + const tc = document.getElementById('countTags'); + if (fc) fc.textContent = String(folderCount); + if (tc) tc.textContent = String(tagCount); // active state for top-level items $$('#appShell .nav-item[data-view]').forEach(n => { n.classList.toggle('is-active', n.dataset.view === state.view); }); - // folders + // folders — hide the special "All" container (it doubles up with the + // "All items" view in the top nav and confuses users with two "All" + // entries that count different sets). Entries with folder='All' are + // still reachable via "All items". const fList = $('#foldersList'); fList.innerHTML = ''; - state.folders.forEach(name => { + state.folders.filter(n => n !== 'All').forEach(name => { const count = state.entries.filter(e => e.folder === name).length; const key = 'folder:' + name; const item = el('button', { @@ -937,9 +1247,54 @@ function renderSidebar() { if (id) await moveEntryToFolder(parseInt(id), name); }); + const delBtn = el('button', { + class: 'folder-delete', + type: 'button', + title: 'Delete folder', + on: { click: ev => { ev.stopPropagation(); deleteFolder(name, count); } }, + }); + delBtn.appendChild(icon('i-x')); + item.appendChild(delBtn); + fList.appendChild(item); }); + // "(no folder)" pseudo-entry: filters to entries with no real folder + // (folder is empty or the default "All"). Skipped when there are zero + // such entries so the sidebar stays clean for organised users. + // No folder icon — visually communicates "this is the absence of a + // folder, not a folder". Drag target so users can quickly uncategorise. + const uncatCount = state.entries.filter(e => !e.folder || e.folder === 'All').length; + if (uncatCount > 0) { + const key = 'folder:All'; + const item = el('button', { + class: 'nav-item is-uncategorized' + (state.view === key ? ' is-active' : ''), + 'data-folder': 'All', + on: { click: () => setView(key) }, + }); + // Spacer instead of the folder icon — keeps alignment with real + // folders without implying "this is a folder". MUST NOT be a + // because .nav-item > span:first-of-type { flex: 1 } would target + // the spacer instead of the label and push the count off to the + // right. Other nav-items have as their first child, so the + // label span naturally wins :first-of-type; we mimic that by + // making the spacer a non-span element. + item.appendChild(el('i', { class: 'nav-icon-spacer' })); + item.appendChild(el('span', null, '(no folder)')); + item.appendChild(el('span', { class: 'nav-count' }, String(uncatCount))); + + item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); + item.addEventListener('dragleave', () => item.classList.remove('drag-over')); + item.addEventListener('drop', async e => { + e.preventDefault(); + item.classList.remove('drag-over'); + const id = e.dataTransfer.getData('text/plain'); + if (id) await moveEntryToFolder(parseInt(id), 'All'); + }); + + fList.appendChild(item); + } + // tags const tList = $('#tagsList'); tList.innerHTML = ''; @@ -987,7 +1342,7 @@ async function addTagToEntry(id, tag) { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site: e.site, username: e.username, + site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), @@ -1000,6 +1355,34 @@ async function addTagToEntry(id, tag) { function renderGrid() { $('#contentTitle').textContent = viewTitle(); + + // Authenticator view: bypass the standard pipeline — render a dedicated + // grid of TOTP cards (only entries that have a TOTP secret configured). + if (state.view === 'authenticator') { + if (authTickTimer) { clearInterval(authTickTimer); authTickTimer = null; } + const oldBtn = $('#emptyTrashBtn'); if (oldBtn) oldBtn.remove(); + renderBatchBar(); + const totpEntries = state.entries.filter(e => e.totp_secret && e.totp_iv); + $('#contentMeta').textContent = totpEntries.length + + (totpEntries.length === 1 ? ' code' : ' codes'); + const grid = $('#entryGrid'); + grid.className = 'entry-grid is-auth'; + grid.innerHTML = ''; + if (totpEntries.length === 0) { + $('#emptyState').classList.remove('is-hidden'); + const illu = $('#emptyIllustration use'); + illu.setAttribute('href', '#i-empty-vault'); + $('#emptyTitle').textContent = 'No TOTP codes yet'; + $('#emptyMessage').innerHTML = 'Add a TOTP secret to any entry to see its live code here.'; + return; + } + $('#emptyState').classList.add('is-hidden'); + renderAuthenticatorGrid(grid, totpEntries); + return; + } else if (authTickTimer) { + clearInterval(authTickTimer); authTickTimer = null; + } + const list = filteredEntries(); $('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items'); @@ -1019,7 +1402,9 @@ function renderGrid() { renderBatchBar(); const grid = $('#entryGrid'); - grid.className = 'entry-grid' + (state.viewMode === 'list' ? ' is-list' : ''); + grid.className = 'entry-grid' + + (state.viewMode === 'list' ? ' is-list' : '') + + (state.viewMode === 'table' ? ' is-table' : ''); grid.innerHTML = ''; if (list.length === 0) { showEmptyState(); @@ -1027,7 +1412,108 @@ function renderGrid() { } $('#emptyState').classList.add('is-hidden'); - list.forEach(e => grid.appendChild(renderCard(e))); + const total = list.length; + const totalPages = Math.max(1, Math.ceil(total / state.pageSize)); + if (state.currentPage > totalPages) state.currentPage = totalPages; + if (state.currentPage < 1) state.currentPage = 1; + const start = (state.currentPage - 1) * state.pageSize; + const pageList = list.slice(start, start + state.pageSize); + + if (total > 10) { + grid.appendChild(renderPagination(total, totalPages)); + } + + if (state.viewMode === 'table') { + grid.appendChild(renderTable(pageList)); + } else { + pageList.forEach(e => grid.appendChild(renderCard(e))); + } +} + +// Authenticator view tick timer — recomputes every code once per second. +let authTickTimer = null; + +function renderAuthenticatorGrid(grid, entries) { + // Decrypt all secrets once up-front (slow); render cards immediately + // with a placeholder, then patch in the codes as they decrypt. + const cards = entries.map(e => { + const wrap = el('div', { class: 'auth-card', 'data-id': String(e.id) }); + const head = el('div', { class: 'auth-card-head' }); + head.appendChild(el('div', { class: 'auth-card-title' }, + e.title || e.site || '(no name)')); + if (e.username) head.appendChild(el('div', { class: 'auth-card-sub' }, e.username)); + wrap.appendChild(head); + + const codeRow = el('div', { class: 'auth-card-code-row' }); + const codeEl = el('div', { class: 'auth-card-code' }, '— — — — — —'); + const copyBtn = el('button', { class: 'icon-btn', title: 'Copy code', type: 'button' }); + copyBtn.appendChild(icon('i-copy')); + copyBtn.addEventListener('click', async ev => { + ev.stopPropagation(); + const code = codeEl.textContent.replace(/\s/g, ''); + if (!/^\d{6}$/.test(code)) return; + if (Bridge.active) Bridge.copySecure(code, 30000); + else { try { await navigator.clipboard.writeText(code); } catch (_) {} } + toast('Code copied'); + }); + codeRow.appendChild(codeEl); + codeRow.appendChild(copyBtn); + wrap.appendChild(codeRow); + + const barWrap = el('div', { class: 'auth-card-bar-wrap' }); + const bar = el('div', { class: 'auth-card-bar' }); + barWrap.appendChild(bar); + wrap.appendChild(barWrap); + + // Click anywhere on card (outside copy) opens the entry detail. + wrap.addEventListener('click', () => openSlideover(e.id)); + + return { entry: e, wrap, codeEl, bar, secret: null }; + }); + + cards.forEach(c => grid.appendChild(c.wrap)); + + // Decrypt then start ticking + (async () => { + for (const c of cards) { + try { + c.secret = await decryptTotpSecret(c.entry.totp_secret, c.entry.totp_iv); + } catch (e) { + c.secret = null; + } + } + async function tick() { + for (const c of cards) { + if (!c.secret) { c.codeEl.textContent = 'error'; continue; } + try { + const t = await generateTOTP(c.secret); + c.codeEl.textContent = t.code.replace(/(\d{3})(\d{3})/, '$1 $2'); + const newPct = (t.secondsLeft / 30) * 100; + // Detect period reset (countdown wrapped from ~0 back to 30s): + // snap the bar instantly to 100% instead of letting the CSS + // transition animate the jump backwards, which looks like a + // freeze / reverse glide. + if (newPct > (c.lastPct || 0) + 5) { + c.bar.style.transition = 'none'; + c.bar.style.width = newPct.toFixed(1) + '%'; + // Force reflow then restore the transition for the smooth + // forward countdown. + void c.bar.offsetWidth; + c.bar.style.transition = ''; + } else { + c.bar.style.width = newPct.toFixed(1) + '%'; + } + c.lastPct = newPct; + c.bar.classList.toggle('is-warning', t.secondsLeft <= 5); + } catch (e) { + c.codeEl.textContent = 'error'; + } + } + } + await tick(); + if (authTickTimer) clearInterval(authTickTimer); + authTickTimer = setInterval(tick, 1000); + })(); } function showEmptyState() { @@ -1087,6 +1573,47 @@ function initials(s) { return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'; } +// User-facing name for an entry. Falls back to `site` when `title` is empty +// (default for legacy entries and any entry the user hasn't customised). +// IMPORTANT: do NOT use this for autofill domain matching or search-by-host +// — those need the raw site/URL/hostname. +function entryDisplayName(e) { + if (!e) return ''; + const t = (e.title || '').trim(); + return t || e.site || ''; +} + +// Display label for a folder value. "All" is the default "uncategorized" +// bucket; we relabel it so users don't see two "All" entries in folder +// pickers (the top nav "All items" also says "All"). +function folderLabel(f) { + if (!f || f === 'All') return '(no folder)'; + return f; +} + +// Comparator for sorting entries. by ∈ {name, site, updated, created, folder}. +// dir ∈ {asc, desc}. Stable sort: ties keep their relative order (Array.sort +// is stable per the modern spec). +function sortEntries(list, by, dir) { + const mul = dir === 'desc' ? -1 : 1; + const get = e => { + switch (by) { + case 'site': return (e.site || '').toLowerCase(); + case 'updated': return e.updated_at || ''; + case 'created': return e.created_at || ''; + case 'folder': return (e.folder || '').toLowerCase(); + case 'name': + default: return entryDisplayName(e).toLowerCase(); + } + }; + return list.slice().sort((a, b) => { + const va = get(a), vb = get(b); + if (va < vb) return -1 * mul; + if (va > vb) return 1 * mul; + return 0; + }); +} + // Compact-action kebab menu shown on each card when state.compactActions is on. function buildKebabMenu(entry) { const wrap = el('div', { class: 'entry-kebab-wrap' }); @@ -1111,6 +1638,7 @@ function buildKebabMenu(entry) { { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, + { lbl: 'Duplicate', ic: 'i-copy', fn: () => duplicateEntry(entry) }, { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, ]; items.forEach(it => { @@ -1149,16 +1677,34 @@ function renderCard(e) { }); } - // head: avatar acts as a multi-select checkbox (click on avatar -> toggle) + // head: avatar shows identity (initials). A checkbox overlay LIVES + // INSIDE the avatar (absolute inset:0) — it covers the avatar exactly + // when visible, no separate footprint that could collide with the + // avatar's position. Visible on hover or whenever the entry is + // checked. Card click anywhere not on the checkbox opens slideover. const head = el('div', { class: 'entry-head' }); + const displayName = entryDisplayName(e); const avatar = el('div', { - class: 'entry-avatar is-checkable', - title: 'Click to select', + class: 'entry-avatar', + }, initials(displayName)); + const checkbox = el('button', { + class: 'entry-check' + (checked ? ' is-checked' : ''), + type: 'button', + title: checked ? 'Deselect' : 'Select', on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, - }, checked ? '✓' : initials(e.site)); + }); + if (checked) checkbox.appendChild(el('span', null, '✓')); + avatar.appendChild(checkbox); // NESTED inside avatar — no overlap head.appendChild(avatar); const title = el('div', { class: 'entry-title' }); - title.appendChild(el('b', null, e.site)); + title.appendChild(el('b', null, displayName)); + // If user set a custom title AND it differs from site, optionally show + // site as a small subtitle. Hidden by default to keep cards clean — + // toggled via Settings > Appearance. + if (state.showSiteOnCards && + e.title && e.title.trim() && e.title.trim() !== e.site) { + title.appendChild(el('span', { class: 'entry-subtitle' }, e.site)); + } // Username row with inline copy button (visible on card hover) const userRow = el('small', { class: 'entry-user-row' }); userRow.appendChild(el('span', null, displayUsername(e.username))); @@ -1189,7 +1735,7 @@ function renderCard(e) { head.appendChild(restore); head.appendChild(purge); } else if (state.compactActions) { - // Compact mode: single kebab menu replaces fav + del + // Compact mode: single kebab menu replaces fav + dup + del head.appendChild(buildKebabMenu(e)); } else { const fav = el('button', { @@ -1200,8 +1746,14 @@ function renderCard(e) { fav.appendChild(icon('i-star')); head.appendChild(fav); - // Quick-delete: small X visible on card hover. Always available - // without opening the slide-over. + const dup = el('button', { + class: 'entry-dup', + title: 'Duplicate', + on: { click: ev => { ev.stopPropagation(); duplicateEntry(e); } }, + }); + dup.appendChild(icon('i-copy')); + head.appendChild(dup); + const del = el('button', { class: 'entry-del', title: 'Move to trash', @@ -1224,9 +1776,10 @@ function renderCard(e) { pwRow.appendChild(copyBtn); card.appendChild(pwRow); - // meta chips: folder + first 2 tags + // meta chips: folder + first 2 tags. "All" is the default "uncategorized" + // bucket and shouldn't be shown as a chip (visually duplicates "All items"). const meta = el('div', { class: 'entry-meta' }); - if (e.folder) { + if (e.folder && e.folder !== 'All') { const chip = el('span', { class: 'entry-chip is-folder' }); chip.appendChild(icon('i-folder')); chip.appendChild(el('span', null, e.folder)); @@ -1269,84 +1822,267 @@ function renderCard(e) { } // ============================================================ -// MARQUEE (rubber-band) SELECTION +// TABLE VIEW // ============================================================ -// Click-drag on empty space in the entry grid draws a rectangle. -// Cards whose bounding box intersects the rectangle become selected. -// Shift/Ctrl held = add to existing selection (otherwise replace). +// Dense, spreadsheet-like layout for users who manage many entries. +// Sortable headers — clicking a column header sets state.sortBy/sortDir +// (with toggle on the active column) and re-renders. Same source of +// truth as the Settings dropdown — both stay in sync. -let marqueeEl = null; -let marqueeStart = null; -let marqueeAdditive = false; -let marqueeInitialSet = null; - -function startMarquee(ev) { - // Only fire on left mouse button, and only when starting on grid background - if (ev.button !== 0) return; - if (ev.target.closest('.entry-card')) return; // ignore drags from cards - if (ev.target.closest('.batch-bar')) return; - if (!ev.target.closest('#entryGrid')) return; - - marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey; - marqueeInitialSet = new Set(state.checked); - if (!marqueeAdditive) state.checked.clear(); - - marqueeStart = { x: ev.clientX, y: ev.clientY }; - marqueeEl = el('div', { class: 'marquee' }); - Object.assign(marqueeEl.style, { - left: marqueeStart.x + 'px', - top: marqueeStart.y + 'px', - width: '0px', height: '0px', - }); - document.body.appendChild(marqueeEl); - ev.preventDefault(); - - document.addEventListener('mousemove', updateMarquee); - document.addEventListener('mouseup', endMarquee); +// Columns built dynamically — the Site column tracks the user's +// "Show site under display name" preference so card view and table view +// stay consistent (default: site hidden, can be re-enabled from Settings). +function getTableColumns() { + const cols = [ + { key: 'check', label: '', sortKey: null }, + { key: 'name', label: 'Name', sortKey: 'name' }, + ]; + if (state.showSiteOnCards) { + cols.push({ key: 'site', label: 'Site', sortKey: 'site' }); + } + cols.push( + { key: 'user', label: 'Username', sortKey: null }, // no sort: derived/varies + { key: 'folder', label: 'Folder', sortKey: 'folder' }, + { key: 'updated', label: 'Updated', sortKey: 'updated' }, + { key: 'actions', label: '', sortKey: null }, + ); + return cols; } -function updateMarquee(ev) { - if (!marqueeEl) return; - const x1 = Math.min(marqueeStart.x, ev.clientX); - const y1 = Math.min(marqueeStart.y, ev.clientY); - const x2 = Math.max(marqueeStart.x, ev.clientX); - const y2 = Math.max(marqueeStart.y, ev.clientY); - Object.assign(marqueeEl.style, { - left: x1 + 'px', top: y1 + 'px', - width: (x2 - x1) + 'px', height: (y2 - y1) + 'px', +function renderPagination(total, totalPages) { + const wrap = el('div', { class: 'pagination' }); + + const start = (state.currentPage - 1) * state.pageSize + 1; + const end = Math.min(start + state.pageSize - 1, total); + wrap.appendChild(el('span', { class: 'pagination-info' }, + start + '–' + end + ' of ' + total)); + + function pageBtn(label, page, opts) { + opts = opts || {}; + const b = el('button', { + class: 'pagination-btn' + + (opts.active ? ' is-active' : '') + + (opts.disabled ? ' is-disabled' : ''), + type: 'button', + on: { click: () => { + if (opts.disabled || opts.active) return; + state.currentPage = page; + render(); + } }, + }, String(label)); + return b; + } + + wrap.appendChild(pageBtn('‹ Prev', state.currentPage - 1, + { disabled: state.currentPage <= 1 })); + + const pages = computePageList(state.currentPage, totalPages); + pages.forEach(p => { + if (p === '…') wrap.appendChild(el('span', { class: 'pagination-ellipsis' }, '…')); + else wrap.appendChild(pageBtn(p, p, { active: p === state.currentPage })); }); - // Re-check intersections - const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 }; - state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []); - $$('#entryGrid .entry-card').forEach(card => { - const r = card.getBoundingClientRect(); - const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right || - r.bottom < marqueeRect.top || r.top > marqueeRect.bottom); - if (intersects) { - const id = parseInt(card.dataset.id); - state.checked.add(id); - card.classList.add('is-checked'); - } else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) { - card.classList.remove('is-checked'); + wrap.appendChild(pageBtn('Next ›', state.currentPage + 1, + { disabled: state.currentPage >= totalPages })); + + const sizeSel = el('select', { + class: 'pagination-size', + on: { change: ev => { + state.pageSize = parseInt(ev.target.value); + state.currentPage = 1; + localStorage.setItem('pageSize', String(state.pageSize)); + saveServerSettings(); + render(); + } }, + }); + [10, 25, 50, 100].forEach(n => { + const opt = el('option', { value: String(n) }, String(n) + ' / page'); + if (n === state.pageSize) opt.selected = true; + sizeSel.appendChild(opt); + }); + wrap.appendChild(sizeSel); + + return wrap; +} + +function computePageList(current, total) { + if (total <= 7) { + const arr = []; + for (let i = 1; i <= total; i++) arr.push(i); + return arr; + } + const pages = [1]; + if (current > 3) pages.push('…'); + const from = Math.max(2, current - 1); + const to = Math.min(total - 1, current + 1); + for (let i = from; i <= to; i++) pages.push(i); + if (current < total - 2) pages.push('…'); + pages.push(total); + return pages; +} + +function renderTable(list) { + const columns = getTableColumns(); + const table = el('table', { class: 'entry-table' }); + + // Header row with click-to-sort + const thead = el('thead'); + const headerRow = el('tr'); + columns.forEach(col => { + const th = el('th', { 'data-col': col.key }); + if (col.sortKey) { + th.classList.add('is-sortable'); + const isActive = state.sortBy === col.sortKey; + if (isActive) th.classList.add('is-active'); + th.appendChild(el('span', null, col.label)); + // Arrow indicator only on the active column. + if (isActive) { + th.appendChild(el('span', { class: 'sort-arrow' }, + state.sortDir === 'asc' ? '↑' : '↓')); + } + th.addEventListener('click', () => { + if (state.sortBy === col.sortKey) { + // Same column → flip direction + state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + state.sortBy = col.sortKey; + // Sensible default direction per field + state.sortDir = (col.sortKey === 'updated' || + col.sortKey === 'created') ? 'desc' : 'asc'; + } + localStorage.setItem('sortBy', state.sortBy); + localStorage.setItem('sortDir', state.sortDir); + // Keep the Settings dropdown in sync if it's currently displayed. + const sel = $('#settingSort'); + if (sel) sel.value = state.sortBy + ':' + state.sortDir; + render(); + saveServerSettings(); + }); + } else { + th.appendChild(el('span', null, col.label)); } + headerRow.appendChild(th); }); + thead.appendChild(headerRow); + table.appendChild(thead); + + // Body rows + const tbody = el('tbody'); + list.forEach(e => tbody.appendChild(renderTableRow(e))); + table.appendChild(tbody); + + return table; } -function endMarquee() { - document.removeEventListener('mousemove', updateMarquee); - document.removeEventListener('mouseup', endMarquee); - if (marqueeEl) marqueeEl.remove(); - marqueeEl = null; - marqueeStart = null; - marqueeInitialSet = null; - // Re-render so the batch bar appears with the new count + avatar states - renderGrid(); +function renderTableRow(e) { + const checked = state.checked.has(e.id); + const inTrash = state.view === 'trash'; + const tr = el('tr', { + class: 'entry-row' + + (state.selectedId === e.id ? ' is-selected' : '') + + (checked ? ' is-checked' : ''), + 'data-id': String(e.id), + on: { click: ev => handleCardClick(ev, e, inTrash) }, + }); + + // Cells are emitted in EXACTLY the same order as getTableColumns() + // returns headers, otherwise THs and TDs drift apart and clicks land + // on the wrong column. Switch by col.key so add/remove of a column + // affects header + body in one place. + getTableColumns().forEach(col => { + let td; + switch (col.key) { + case 'check': { + td = el('td', { class: 'col-check' }); + // Standalone variant — not nested inside an avatar, so it + // needs its own explicit dimensions. .entry-check-static + // overrides the absolute/inset:0 positioning used in the + // card-view (where the box gets its size from its avatar + // parent). + const checkbox = el('button', { + class: 'entry-check entry-check-static' + (checked ? ' is-checked' : ''), + type: 'button', + title: checked ? 'Deselect' : 'Select', + on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, + }); + if (checked) checkbox.appendChild(el('span', null, '✓')); + td.appendChild(checkbox); + break; + } + case 'name': { + td = el('td', { class: 'col-name' }); + const avatar = el('span', { class: 'entry-avatar entry-avatar-sm' }, + initials(entryDisplayName(e))); + td.appendChild(avatar); + const nameWrap = el('span', { class: 'cell-name-wrap' }); + nameWrap.appendChild(el('b', null, entryDisplayName(e))); + if (e.favorite) nameWrap.appendChild(el('span', { class: 'fav-dot', title: 'Favorite' }, '★')); + td.appendChild(nameWrap); + break; + } + case 'site': + td = el('td', { class: 'col-site' }, e.site || ''); + break; + case 'user': { + td = el('td', { class: 'col-user' }); + td.appendChild(el('span', null, displayUsername(e.username))); + if (e.username) { + const btn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy username', + on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, + }); + btn.appendChild(icon('i-copy')); + td.appendChild(btn); + } + break; + } + case 'folder': + td = el('td', { class: 'col-folder' }, + (e.folder && e.folder !== 'All') ? e.folder : ''); + break; + case 'updated': + td = el('td', { class: 'col-updated' }, + formatDateShort(e.updated_at)); + break; + case 'actions': { + td = el('td', { class: 'col-actions' }); + const pwBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy password', + on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, + }); + pwBtn.appendChild(icon('i-copy')); + td.appendChild(pwBtn); + td.appendChild(buildKebabMenu(e)); + break; + } + } + tr.appendChild(td); + }); + + return tr; +} + +// Compact date for the table column. ISO string in → "2026-05-23" out. +// Avoids per-locale parsing surprises (server uses ISO already). +function formatDateShort(iso) { + if (!iso) return ''; + return iso.slice(0, 10); } // ============================================================ // MULTI-SELECTION + BATCH ACTIONS // ============================================================ +// +// Selection patterns (no rubber-band marquee — removed 2026-05-24, value +// for a password manager was too low vs the accidental-deselect cost): +// - Click the checkbox overlay on a card → toggle just that entry +// - Click a card body (when ≥1 is already checked) → toggle (sticky) +// - Ctrl+click row/card → toggle (always, no need for sticky mode) +// - Shift+click row/card → range select from last anchor +// - Ctrl+A (when not typing) → select every visible entry +// - Escape (when nothing else to dismiss) → clear selection let selectionAnchor = null; // last single-clicked card, used for shift+click range @@ -1403,7 +2139,7 @@ async function batchMoveToFolder(folder) { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site: e.site, username: e.username, + site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), @@ -1431,7 +2167,7 @@ async function batchAddTag(tag) { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site: e.site, username: e.username, + site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder: e.folder, tags: tags.join(','), }), @@ -1522,7 +2258,7 @@ function renderBatchBar() { // Normal view: Move to folder | Add tag | Delete (soft) const moveSel = el('select'); moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); - state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, f))); + state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, folderLabel(f)))); moveSel.addEventListener('change', () => { if (moveSel.value) batchMoveToFolder(moveSel.value); }); @@ -1570,7 +2306,7 @@ async function openSlideOver(id) { if (!e) return; state.selectedId = id; - $('#slideoverTitle').textContent = e.site; + $('#slideoverTitle').textContent = entryDisplayName(e); const body = $('#slideoverBody'); body.innerHTML = ''; @@ -1590,7 +2326,8 @@ async function openSlideOver(id) { soState = { id: e.id, original: { - site: e.site, username: e.username || '', password: plain, + site: e.site, title: e.title || '', + username: e.username || '', password: plain, folder: e.folder || 'All', tags: parseTags(e.tags).join(','), totp: plainTotp, }, @@ -1601,6 +2338,7 @@ async function openSlideOver(id) { originalTotpIV: e.totp_iv, }; + body.appendChild(soEditableField('Display name', 'soTitle', e.title || '')); body.appendChild(soEditableField('Site', 'soSite', e.site)); body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); body.appendChild(soPasswordField(plain)); @@ -1621,7 +2359,7 @@ async function openSlideOver(id) { body.appendChild(actions); // Wire change detection - ['#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { + ['#soTitle', '#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); if (el) el.addEventListener('change', soDirtyCheck); }); @@ -1634,10 +2372,17 @@ function soEditableField(label, id, value) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); const input = el('input', { type: 'text', id, value, class: 'so-input' }); + input.addEventListener('keydown', soOnEnterSave); wrap.appendChild(input); return wrap; } +function soOnEnterSave(ev) { + if (ev.key !== 'Enter') return; + ev.preventDefault(); + soSave(); +} + function soPasswordField(plain) { const wrap = el('div', { class: 'slideover-field' }); wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); @@ -1646,6 +2391,7 @@ function soPasswordField(plain) { type: 'password', id: 'soPassword', value: plain, class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace', }); + input.addEventListener('keydown', soOnEnterSave); const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' }); toggle.appendChild(icon('i-eye')); toggle.addEventListener('click', () => { @@ -1684,7 +2430,10 @@ function startTotpTick() { // Refresh once per second so the countdown bar moves smoothly and the // code auto-rolls when the 30s window expires. totpTickTimer = setInterval(updateTotpDisplay, 1000); - updateTotpDisplay(); + // Defer the first immediate update: callers append the wrap to the DOM + // AFTER soTotpField() returns, so a sync $() lookup here would see null + // elements and stopTotpTick() would kill the interval we just created. + setTimeout(updateTotpDisplay, 0); } function stopTotpTick() { @@ -1731,6 +2480,7 @@ function soTotpField(plainSecret) { value: plainSecret || '', class: 'so-input', placeholder: 'Paste base32 secret or otpauth:// URI', + on: { keydown: soOnEnterSave }, style: 'flex:1;font-family:JetBrains Mono,monospace', autocomplete: 'off', spellcheck: 'false', }); @@ -1802,7 +2552,7 @@ function soFolderField(current) { wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); const sel = el('select', { id: 'soFolder', class: 'so-input' }); state.folders.forEach(f => { - const opt = el('option', { value: f }, f); + const opt = el('option', { value: f }, folderLabel(f)); if (f === current) opt.selected = true; sel.appendChild(opt); }); @@ -1818,10 +2568,26 @@ function soTagsField() { type: 'text', id: 'soTagsField', placeholder: 'add a tag…', autocomplete: 'off', }); + // Render existing chips inline (renderSoChips() would no-op here because + // the container isn't attached to the DOM yet, $ would return null). + soState.tags.forEach((t, i) => { + const chip = el('span', { class: 'chip' }); + chip.appendChild(el('span', null, t)); + const x = el('button', { + type: 'button', + on: { click: ev => { + ev.stopPropagation(); + soState.tags.splice(i, 1); + renderSoChips(); + soDirtyCheck(); + } }, + }); + x.appendChild(icon('i-x')); + chip.appendChild(x); + cont.appendChild(chip); + }); cont.appendChild(input); wrap.appendChild(cont); - // Render existing chips - renderSoChips(); input.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); @@ -1851,7 +2617,12 @@ function renderSoChips() { chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', - on: { click: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, + on: { click: ev => { + ev.stopPropagation(); + soState.tags.splice(i, 1); + renderSoChips(); + soDirtyCheck(); + } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); @@ -1862,6 +2633,7 @@ function renderSoChips() { function soDirtyCheck() { if (!soState) return; const cur = { + title: ($('#soTitle') || {}).value || '', site: ($('#soSite') || {}).value || '', username: ($('#soUsername') || {}).value || '', password: ($('#soPassword') || {}).value || '', @@ -1870,6 +2642,7 @@ function soDirtyCheck() { tags: soState.tags.join(','), }; const dirty = + cur.title !== soState.original.title || cur.site !== soState.original.site || cur.username !== soState.original.username || cur.password !== soState.original.password || @@ -1882,6 +2655,14 @@ function soDirtyCheck() { async function soSave() { if (!soState) return; + // Flush any uncommitted tag text — user may have typed in the chip + // input without pressing Enter / comma before clicking Save. + const pendingTag = (($('#soTagsField') || {}).value || '').trim(); + if (pendingTag && !soState.tags.includes(pendingTag)) { + soState.tags.push(pendingTag); + $('#soTagsField').value = ''; + } + const title = ($('#soTitle') || {}).value || ''; const site = $('#soSite').value.trim(); const user = $('#soUsername').value.trim(); const pwd = $('#soPassword').value; @@ -1922,7 +2703,7 @@ async function soSave() { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site, username: user, + site, title: title.trim(), username: user, encrypted_password: enc.encrypted, iv: enc.iv, totp_secret: totpEnc, totp_iv: totpIv, folder: fold, tags: soState.tags.join(','), @@ -2012,7 +2793,12 @@ function renderChips() { chip.appendChild(el('span', null, t)); const x = el('button', { type: 'button', - on: { click: () => { editingTags.splice(i, 1); renderChips(); syncTagsHidden(); } }, + on: { click: ev => { + ev.stopPropagation(); + editingTags.splice(i, 1); + renderChips(); + syncTagsHidden(); + } }, }); x.appendChild(icon('i-x')); chip.appendChild(x); @@ -2102,6 +2888,7 @@ async function openEntryModal(entry) { if (entry) { $('#entryModalTitle').textContent = 'Edit entry'; $('#entryId').value = entry.id; + $('#entryTitle').value = entry.title || ''; $('#entrySite').value = entry.site; $('#entryUsername').value = entry.username || ''; $('#entryFolder').value = entry.folder || 'All'; @@ -2129,7 +2916,7 @@ function closeEntryModal() { function populateFolderSelect() { const sel = $('#entryFolder'); sel.innerHTML = ''; - state.folders.forEach(f => sel.appendChild(el('option', { value: f }, f))); + state.folders.forEach(f => sel.appendChild(el('option', { value: f }, folderLabel(f)))); } async function saveEntry(e) { @@ -2137,20 +2924,22 @@ async function saveEntry(e) { // Flush any pending text in the chip input as a final tag const pending = $('#entryTagsField').value.trim(); if (pending) { addTag(pending); $('#entryTagsField').value = ''; } - const id = $('#entryId').value; - const site = $('#entrySite').value.trim(); - const user = $('#entryUsername').value.trim(); - const pwd = $('#entryPassword').value; - const fold = $('#entryFolder').value; - const tags = editingTags.join(','); + const id = $('#entryId').value; + const title = $('#entryTitle').value.trim(); + const site = $('#entrySite').value.trim(); + const user = $('#entryUsername').value.trim(); + const pwd = $('#entryPassword').value; + const fold = $('#entryFolder').value; + const tags = editingTags.join(','); if (!site || !pwd) return toast('Site and password required', 'error'); const enc = await encryptPwd(pwd); const body = JSON.stringify({ - site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, + site, title, username: user, encrypted_password: enc.encrypted, iv: enc.iv, folder: fold, tags, }); try { + let savedId = id ? parseInt(id) : null; if (id) { await api('/entries/' + id, { method: 'PUT', @@ -2159,16 +2948,18 @@ async function saveEntry(e) { }); toast('Updated'); } else { - await api('/entries', { + const r = await api('/entries', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), body, }); + if (r && typeof r.id === 'number') savedId = r.id; toast('Saved'); } closeEntryModal(); await loadEntries(); render(); + if (savedId) flashEntry(savedId); } catch (err) { toast(err.message, 'error'); } @@ -2222,6 +3013,47 @@ function openTrashActions(id) { // But user can click outside the buttons to no-op. Could open a read-only view later. } +async function duplicateEntry(entry) { + if (!entry) return; + try { + const r = await api('/entries', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: entry.site, + title: entryDisplayName(entry) + ' (copy)', + username: entry.username || '', + encrypted_password: entry.encrypted_password, + iv: entry.iv, + folder: entry.folder || 'All', + tags: entry.tags || '', + totp_secret: entry.totp_secret || '', + totp_iv: entry.totp_iv || '', + }), + }); + await loadEntries(); + render(); + toast('Duplicated: ' + entryDisplayName(entry)); + if (r && typeof r.id === 'number') flashEntry(r.id); + } catch (err) { + toast(err.message || 'Duplicate failed', 'error'); + } +} + +function flashEntry(id) { + if (!id) return; + setTimeout(() => { + const el = document.querySelector( + '.entry-card[data-id="' + id + '"], ' + + '.entry-row[data-id="' + id + '"]' + ); + if (!el) return; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('is-flash'); + setTimeout(() => el.classList.remove('is-flash'), 2600); + }, 50); +} + async function deleteEntry(id) { const e = state.entries.find(x => x.id === id); if (state.askBeforeDelete) { @@ -2238,6 +3070,7 @@ async function deleteEntry(id) { toast('Moved to trash'); closeSlideOver(); await loadEntries(); + state.trashedCount = (state.trashedCount || 0) + 1; render(); } catch (err) { toast(err.message, 'error'); } } @@ -2259,7 +3092,7 @@ async function moveEntryToFolder(id, folder) { method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ - site: e.site, username: e.username, + site: e.site, title: e.title || '', username: e.username, encrypted_password: e.encrypted_password, iv: e.iv, folder, tags: e.tags || '', }), @@ -2304,6 +3137,30 @@ function displayUsername(u) { // FOLDERS CRUD // ============================================================ +async function deleteFolder(name, entryCount) { + const message = entryCount > 0 + ? '' + name + ' contains ' + entryCount + ' entries. They will be moved to (no folder). Continue?' + : 'Delete folder ' + name + '?'; + const ok = await confirmDialog({ + title: 'Delete folder', + message, + okText: 'Delete', + danger: true, + }); + if (!ok) return; + try { + await api('/folders/' + encodeURIComponent(name), { + method: 'DELETE', + headers: authHeaders(), + }); + if (state.view === 'folder:' + name) state.view = 'all'; + await loadFolders(); + await loadEntries(); + render(); + toast('Folder deleted'); + } catch (e) { toast(e.message || 'Delete failed', 'error'); } +} + async function addFolder() { const name = await promptDialog({ title: 'New folder', @@ -2312,11 +3169,19 @@ async function addFolder() { okText: 'Create', }); if (!name || !name.trim()) return; + const clean = name.trim(); + // "All" is reserved as the internal default for "uncategorized" entries + // (and would visually duplicate the "All items" top nav). Reject here + // rather than letting the user create a confusing duplicate. + if (clean.toLowerCase() === 'all') { + toast('"All" is reserved — pick another name', 'warning'); + return; + } try { await api('/folders', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ name: name.trim() }), + body: JSON.stringify({ name: clean }), }); await loadFolders(); render(); @@ -2412,7 +3277,12 @@ function paletteCommands() { function renderPaletteResults(q) { const cmds = paletteCommands(); const entries = state.entries.map(e => ({ - id: 'entry-' + e.id, label: e.site, sub: e.username || '', + id: 'entry-' + e.id, label: entryDisplayName(e), + // Sub: site (when different from displayName) + username, joined. + sub: [ + (e.title && e.title.trim() && e.title.trim() !== e.site) ? e.site : '', + e.username || '', + ].filter(Boolean).join(' · '), icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); }, })); const all = cmds.concat(entries); @@ -2420,7 +3290,7 @@ function renderPaletteResults(q) { const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all; const out = $('#cmdResults'); out.innerHTML = ''; - filtered.slice(0, 12).forEach((c, i) => { + filtered.slice(0, 50).forEach((c, i) => { const it = el('div', { class: 'cmd-item' + (i === 0 ? ' is-active' : ''), on: { click: c.run }, @@ -2548,6 +3418,29 @@ function bridgeRequestQuickUnlock() { }); } +let clipboardReadResolver = null; + +function bridgeReadClipboard() { + if (!Bridge.active) return Promise.resolve(''); + return new Promise(resolve => { + clipboardReadResolver = resolve; + setTimeout(() => { + if (clipboardReadResolver === resolve) { + clipboardReadResolver = null; + resolve(''); + } + }, 1500); + window.location.href = 'cmd://clipboard/read'; + }); +} + +Bridge.onClipboardRead = function(text) { + if (clipboardReadResolver) { + clipboardReadResolver(text || ''); + clipboardReadResolver = null; + } +}; + function bridgeQuickUnlockStatus() { if (!Bridge.active) return Promise.resolve(false); return new Promise(resolve => { @@ -2597,16 +3490,16 @@ async function enableQuickUnlock() { return toast('Wrong master password', 'error'); } - // Export the raw key + bundle session pieces needed for a cold-start - // restore (no master pw available). Send as base64-encoded UTF-8 JSON. + // Export the raw key + identity. We don't store the session token — + // tryQuickUnlock re-logs in with a verifier derived from the key, + // which always yields a fresh server session (the stored token would + // expire after 24 h and break cold-start restore on a moved exe). const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); const blob = JSON.stringify({ - v: 1, + v: 2, username: state.username, salt: state.salt, kdfIterations: state.kdfIterations, - token: state.token, - csrf: state.csrf, key: bytesToBase64(raw), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); @@ -2645,11 +3538,13 @@ function updateQuickUnlockUI() { // false otherwise (caller falls back to master-pw login). async function tryQuickUnlock() { if (!Bridge.active) return false; - if (localStorage.getItem('quickUnlockEnabled') !== '1') return false; const b64 = await bridgeRequestQuickUnlock(); if (!b64) return false; + localStorage.setItem('quickUnlockEnabled', '1'); + state.quickUnlockEnabled = true; + let parsed; try { const jsonStr = new TextDecoder().decode(base64ToBytes(b64)); @@ -2659,27 +3554,49 @@ async function tryQuickUnlock() { } if (!parsed || !parsed.key || !parsed.salt || !parsed.username) return false; - // Restore session state from the blob. + // Restore identity + crypto key from the blob. state.username = parsed.username; state.salt = parsed.salt; state.kdfIterations = parsed.kdfIterations || 600000; - state.token = parsed.token || sessionStorage.getItem('authToken') || ''; - state.csrf = parsed.csrf || sessionStorage.getItem('csrfToken') || ''; - sessionStorage.setItem('username', state.username); - sessionStorage.setItem('salt', state.salt); - sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); - if (state.token) sessionStorage.setItem('authToken', state.token); - if (state.csrf) sessionStorage.setItem('csrfToken', state.csrf); + const rawKey = base64ToBytes(parsed.key); try { state.cryptoKey = await crypto.subtle.importKey( - 'raw', base64ToBytes(parsed.key), + 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); - await persistCryptoKey(); } catch (e) { return false; } + // Always request a fresh session token via /login using the key-derived + // verifier. The stored token (if any) may have expired or been cleaned + // up by the server's session GC, which used to drop the user back to + // the login screen on cold start. + try { + const verifier = bytesToHex(rawKey); + const r = await api('/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: state.username, verifier }), + }); + state.token = r.token; + state.csrf = r.csrfToken; + if (r.salt) state.salt = r.salt; + if (r.kdfIterations) state.kdfIterations = r.kdfIterations; + } catch (e) { + // Login failed — vault credentials may have changed (master pw + // rotation) since Quick Unlock was set up. Force a fresh master-pw + // login; the user will need to re-enable Quick Unlock afterwards. + return false; + } + + sessionStorage.setItem('username', state.username); + sessionStorage.setItem('salt', state.salt); + sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); + sessionStorage.setItem('authToken', state.token); + sessionStorage.setItem('csrfToken', state.csrf); + await persistCryptoKey(); + state.locked = false; return true; } @@ -2806,18 +3723,50 @@ async function doGenerateRecoveryKey() { // Show the code ONCE. Use the confirm modal so the user has to // explicitly click "I saved it" before the value vanishes. + // + // Wire the inline Copy button BEFORE awaiting the dialog: confirmDialog + // injects the HTML synchronously, so a 0-ms task fires after the DOM + // is in place but before the user can interact. CSP forbids inline + // onclick handlers, hence the addEventListener route. + setTimeout(() => { + const btn = document.getElementById('copyRecoveryCodeBtn'); + if (!btn) return; + btn.addEventListener('click', () => { + // Same path as password copy: secure-clipboard via Delphi + // (excluded from Win+V history, auto-cleared after 30s) when + // running embedded, navigator.clipboard with manual scrub + // otherwise. + if (Bridge.copySecure(code, 30000)) { + toast('Recovery code copied · clears in 30s'); + } else { + navigator.clipboard.writeText(code).then(() => { + toast('Recovery code copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); + } + }); + }, 0); + await confirmDialog({ title: 'Your recovery code', message: '

Save this code somewhere safe (password manager, ' + 'safe deposit box, printed copy). It will not be ' + 'shown again.

' + - '

' + - code + '

' + + '
' + + '' + + code + '' + + '' + + '
' + '

' + - 'Using it later will let you recover access if you forget ' + - 'your master password. The code is single-use.

', + 'Using it lets you recover access if you forget your master ' + + 'password. The code can be used up to 5 times, and ' + + 'is permanently erased as soon as you successfully change ' + + 'your master password — so set a new one right after ' + + 'recovering.

', okText: 'I saved it', }); toast('Recovery code generated'); @@ -2849,7 +3798,11 @@ function updateRecoveryStatusLabel() { const removeBtn = $('#recoveryRemoveBtn'); if (!lbl) return; if (state.recoveryConfigured) { - lbl.textContent = 'Recovery key is configured.'; + const left = state.recoveryRemainingUses; + const usesNote = (typeof left === 'number' && left < 5) + ? ' (' + left + ' use' + (left === 1 ? '' : 's') + ' left)' + : ''; + lbl.textContent = 'Recovery key is configured.' + usesNote; if (setupBtn) setupBtn.textContent = 'Regenerate code'; if (removeBtn) removeBtn.style.display = ''; } else { @@ -2863,6 +3816,7 @@ async function refreshRecoveryStatus() { try { const r = await api('/recovery-key/status', { headers: authHeaders() }); state.recoveryConfigured = !!r.configured; + state.recoveryRemainingUses = (typeof r.remaining_uses === 'number') ? r.remaining_uses : 5; updateRecoveryStatusLabel(); } catch (e) { /* ignore */ } } @@ -2880,8 +3834,9 @@ async function doRecoveryRedeem() { if (!u) return; const code = await promptDialog({ title: 'Enter recovery code', - message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. ' + - 'They\'re single-use — using one will remove it from your account.', + message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. They allow ' + + 'up to 5 uses, and are erased when you set a new master ' + + 'password — remember to generate a fresh code afterwards.', placeholder: 'XXXX-XXXX-XXXX-XXXX', okText: 'Recover', password: true, @@ -2929,14 +3884,15 @@ async function doRecoveryRedeem() { 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); await persistCryptoKey(); - toast('Access recovered — please set a new master password'); + const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; + if (remaining <= 0) { + toast('Last recovery use — set a new master password now or the code is gone forever', 'warning'); + } else { + toast('Recovery code used. ' + remaining + ' use(s) left before it expires. Change your master password now.', 'warning'); + } + state.justRecovered = true; await enterApp(); - // Force a master pw change immediately. The recovery code is consumed - // (server deleted the row); the account is currently orphaned from - // a "we know who you are" perspective. Setting a new master pw both - // restores normal login AND lets the user generate a fresh recovery - // code afterwards. setTimeout(openChangeMasterModal, 300); } @@ -2968,8 +3924,25 @@ function openChangeMasterModal() { $('#cmConfirmPwd').value = ''; const errEl = $('#cmError'); if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } + + const curInput = $('#cmCurrentPwd'); + const curWrap = curInput.closest('.field') || curInput.parentElement; + const titleEl = $('#changeMasterTitle'); + if (state.justRecovered) { + if (curWrap) curWrap.classList.add('is-hidden'); + if (curInput) curInput.required = false; + if (titleEl) titleEl.textContent = 'Set new master password'; + } else { + if (curWrap) curWrap.classList.remove('is-hidden'); + if (curInput) curInput.required = true; + if (titleEl) titleEl.textContent = 'Change master password'; + } + $('#changeMasterModal').classList.remove('is-hidden'); - setTimeout(() => $('#cmCurrentPwd').focus(), 50); + setTimeout(() => { + if (state.justRecovered) $('#cmNewPwd').focus(); + else $('#cmCurrentPwd').focus(); + }, 50); } function closeChangeMasterModal() { @@ -2984,16 +3957,16 @@ function showCmError(msg) { } async function doChangeMasterPassword() { + const recoveryMode = !!state.justRecovered; const curPwd = $('#cmCurrentPwd').value; const newPwd = $('#cmNewPwd').value; const confPwd = $('#cmConfirmPwd').value; - // Local validation. Server enforces these too, but failing fast saves - // a round trip + leaves the modal open so the user can fix and retry. - if (!curPwd || !newPwd || !confPwd) return showCmError('All fields are required'); + if (!recoveryMode && !curPwd) return showCmError('All fields are required'); + if (!newPwd || !confPwd) return showCmError('All fields are required'); if (newPwd.length < 8) return showCmError('New password must be at least 8 characters'); if (newPwd !== confPwd) return showCmError('New password and confirmation do not match'); - if (newPwd === curPwd) return showCmError('New password must differ from the current one'); + if (!recoveryMode && newPwd === curPwd) return showCmError('New password must differ from the current one'); // Disable the confirm button so a double-click doesn't fire two // re-encryption passes in parallel. @@ -3006,8 +3979,14 @@ async function doChangeMasterPassword() { const newSalt = randomHexSalt(); const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000); const newKey = newDerived.cryptoKey; - const currentVerifier = await computeVerifier( - curPwd, state.salt, state.kdfIterations || 100000); + let currentVerifier; + if (recoveryMode) { + const rawCurrentKey = new Uint8Array(await crypto.subtle.exportKey('raw', state.cryptoKey)); + currentVerifier = bytesToHex(rawCurrentKey); + } else { + currentVerifier = await computeVerifier( + curPwd, state.salt, state.kdfIterations || 100000); + } // Step 2: re-encrypt every entry's password AND every entry's TOTP // secret (if present) under the new key. The current state.cryptoKey @@ -3085,8 +4064,9 @@ async function doChangeMasterPassword() { state.quickUnlockEnabled = false; } + state.justRecovered = false; closeChangeMasterModal(); - toast('Master password changed · other sessions signed out'); + toast(recoveryMode ? 'New master password set' : 'Master password changed · other sessions signed out'); } catch (err) { if (err.status === 401) { showCmError('Current password is incorrect'); @@ -3356,6 +4336,7 @@ async function encryptImportEntry(plain) { } return { site: plain.site, + title: plain.title || '', username: plain.username || '', encrypted_password: pw.encrypted, iv: pw.iv, @@ -3535,6 +4516,7 @@ async function doExport() { } payload.entries.push({ site: e.site, + title: e.title || '', username: e.username, password: plain, folder: e.folder, @@ -3562,12 +4544,280 @@ async function doExport() { toast(payload.entries.length + ' entries exported (encrypted)'); } +// ============================================================ +// AUTOFILL (Ctrl+Shift+L global hotkey) +// ============================================================ + +// Win32 modifier flags for RegisterHotKey. +const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 }; + +// Format a combo for human display: "Ctrl+Shift+L". +function autofillComboLabel(c) { + if (!c || !c.key) return '— not set —'; + const parts = []; + if (c.ctrl) parts.push('Ctrl'); + if (c.alt) parts.push('Alt'); + if (c.shift) parts.push('Shift'); + if (c.win) parts.push('Win'); + parts.push(c.key); + return parts.join('+'); +} + +// Convert a combo to the Win32 (mods bitmask, virtual-key code) pair that +// Delphi's RegisterHotKey takes. key='A'..'Z'/'0'..'9' → ASCII code; +// 'F1'..'F12' → 0x70..0x7B. +function autofillComboToWin32(c) { + let mods = 0; + if (c.ctrl) mods |= WIN32_MOD.ctrl; + if (c.alt) mods |= WIN32_MOD.alt; + if (c.shift) mods |= WIN32_MOD.shift; + if (c.win) mods |= WIN32_MOD.win; + let vk = 0; + const k = (c.key || '').toUpperCase(); + if (/^F([1-9]|1[0-2])$/.test(k)) vk = 0x70 + parseInt(k.slice(1)) - 1; + else if (k.length === 1 && k >= 'A' && k <= 'Z') vk = k.charCodeAt(0); + else if (k.length === 1 && k >= '0' && k <= '9') vk = k.charCodeAt(0); + return { mods, vk }; +} + +// Validate a captured combo. Requires at least one modifier (otherwise a +// single key would steal that letter globally) and a valid main key. +function autofillComboValid(c) { + if (!c) return false; + if (!(c.ctrl || c.alt || c.win)) return false; // shift-only is unreliable + const w = autofillComboToWin32(c); + return w.vk !== 0; +} + +// Capture a key combo from a single keydown event. Returns null if the +// event is "incomplete" (only modifiers pressed so far) or Escape. +function autofillCaptureFromEvent(e) { + const k = e.key; + if (k === 'Escape') return 'cancel'; + // Ignore pure-modifier keydowns (user is still building the combo). + if (k === 'Control' || k === 'Shift' || k === 'Alt' || + k === 'Meta' || k === 'OS') return null; + // Accept letter, digit, F1-F12. + let key = null; + if (k.length === 1 && /[a-z0-9]/i.test(k)) { + key = k.toUpperCase(); + } else if (/^F([1-9]|1[0-2])$/i.test(k)) { + key = k.toUpperCase(); + } else { + return 'invalid'; + } + return { + ctrl: !!e.ctrlKey, + shift: !!e.shiftKey, + alt: !!e.altKey, + win: !!e.metaKey, + key, + }; +} + +// Push current state to Delphi (toggle + both combos) and persist. +// Called after any change so Delphi's RegisterHotKey reflects state. +function autofillPushHotkeys() { + localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); + localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); + if (Bridge.active) { + Bridge.setAutofillHotkeys(state.autofillEnabled, { + full: state.autofillHotkeyFull, + password: state.autofillHotkeyPwd, + }); + } +} + +// Extract a bare hostname from a site string for fuzzy matching. +// "https://www.github.com/login" → "github.com" +// Strip the browser brand suffix that lives at the end of every tab title +// ("Some Page - Google Chrome", "Page — Mozilla Firefox", etc.). Without +// this, entries whose site is "google" / "mozilla" / "edge" would match +// every single page that has Chrome / Firefox / Edge as the browser brand. +const BROWSER_SUFFIX_RE = + /\s*[-—–|]\s*(google chrome|chromium|mozilla firefox|firefox|microsoft edge|edge|brave|opera|vivaldi|safari|tor browser|tor|arc)\s*$/i; + +function autofillStripBrowserSuffix(title) { + return (title || '').replace(BROWSER_SUFFIX_RE, '').trim(); +} + +function autofillExtractHost(site) { + return site.toLowerCase() + .replace(/^https?:\/\//i, '') + .replace(/^www\./i, '') + .split('/')[0] + .split(':')[0]; +} + +// Get the second-level domain (brand part) from a hostname. +// "github.com" → "github" ; "mail.google.com" → "google" ; "x.com" → "x" +function autofillSLD(host) { + const parts = host.split('.').filter(p => p.length > 0); + if (parts.length <= 1) return host; + return parts[parts.length - 2]; +} + +// Escape a string for safe insertion into a RegExp. +function autofillEscapeRegex(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Score how well a vault entry matches the foreground window title. +// Returns 0 (no match) or a positive integer (higher = better). +// +// Strategy (browser titles rarely contain the full hostname — usually +// just the brand name, e.g. "Sign in to GitHub" or "X. C'est… - Google Chrome"): +// 1. Full hostname substring → strongest (score 1000 + len) +// 2. SLD ≥3 chars as substring → medium (score 500 + len) +// 3. SLD <3 chars as word → weak (score 100), requires word +// boundaries to avoid matching "x" inside arbitrary words. +function autofillScore(entry, titleLower) { + // 1. Display name (entry.title) lowercased substring — strongest brand + // match. Skips when title is empty or same as site (already tested + // via the site path below). + const displayTitle = (entry.title || '').trim().toLowerCase(); + if (displayTitle.length >= 2 && titleLower.includes(displayTitle)) + return 1500 + displayTitle.length; + + if (!entry.site) return 0; + const host = autofillExtractHost(entry.site); + if (host.length < 2) return 0; + + // 2. Full hostname (rare in tab titles, but strongest URL signal) + if (titleLower.includes(host)) return 1000 + host.length; + + // 3/4. Second-level domain + const sld = autofillSLD(host); + if (sld.length === 0) return 0; + + if (sld.length >= 3) { + if (titleLower.includes(sld)) return 500 + sld.length; + return 0; + } + + // Short SLD ("x", "qq", "vk"…) — require word boundaries so we don't + // match the letter inside random words. + const re = new RegExp('(^|[^a-z0-9])' + autofillEscapeRegex(sld) + + '([^a-z0-9]|$)', 'i'); + if (re.test(titleLower)) return 100; + + return 0; +} + +// Called by Bridge.onAutofillRequest when a hotkey fires. +// kind: 'full' = Ctrl+Shift+L (user + Tab + pwd) ; 'password' = Ctrl+Shift+P. +async function autofillHandleRequest(windowTitle, kind) { + if (!state.autofillEnabled) return; + + if (!state.cryptoKey || state.locked || !state.token) { + // Vault is locked — bring the app to the front so the user can + // unlock immediately, rather than silently no-op'ing the hotkey. + Bridge.cancelAutofill(); + Bridge.focusApp(); + setTimeout(() => { + const pwd = document.getElementById('loginPassword'); + const user = document.getElementById('loginUsername'); + if (pwd && !document.getElementById('authScreen').classList.contains('is-hidden')) { + if (user && !user.value) user.focus(); + else pwd.focus(); + } + }, 80); + toast('Vault is locked — unlock to autofill', 'warning'); + return; + } + + const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase(); + const scored = state.entries + .map(e => ({ entry: e, score: autofillScore(e, titleLower) })) + .filter(x => x.score > 0) + .sort((a, b) => b.score - a.score); + + if (scored.length === 0) { + toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning'); + Bridge.cancelAutofill(); + return; + } + + if (scored.length === 1) { + await autofillFillEntry(scored[0].entry, kind); + return; + } + + // Multiple candidates — show picker. kind is captured so clicking a + // candidate honours password-only mode. + openAutofillPicker(scored.map(x => x.entry), windowTitle, kind); +} + +// Decrypt and type an entry. kind = 'full' or 'password'. +async function autofillFillEntry(entry, kind) { + const password = await decryptPwd(entry.encrypted_password, entry.iv); + if (password === '[ERROR]') { + toast('Autofill: decryption error', 'error'); + Bridge.cancelAutofill(); + return; + } + // password-only kind → empty username → Delphi skips Tab. + // full kind with empty entry.username → also no Tab (Delphi handles it). + const user = (kind === 'password') ? '' : (entry.username || ''); + Bridge.executeAutofill(user, password); + toast((kind === 'password' ? 'Password filled: ' : 'Autofilled: ') + entry.site); + // Audit (best-effort, ignore failures) + fetch('' + '/audit', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + action: kind === 'password' ? 'autofill_pwd' : 'autofill', + site: entry.site, + }), + }).catch(() => {}); +} + +// Picker modal for multi-match case. kind is forwarded to autofillFillEntry +// so the user's hotkey intent (full vs password-only) is preserved through +// the manual choice. +function openAutofillPicker(entries, windowTitle, kind) { + // Bring the app to front so the picker is unambiguously visible — + // otherwise the modal opens behind / next to the user's original + // window (e.g. Notepad) and easy to miss. ExecuteAutofill restores + // the original target HWND via ForceForegroundWindow on selection. + Bridge.focusApp(); + + const list = $('#autofillPickerList'); + list.innerHTML = ''; + entries.forEach(e => { + const btn = el('button', { + class: 'autofill-pick-btn', + on: { + click: async () => { + closeAutofillPicker(false); + await autofillFillEntry(e, kind); + }, + }, + }); + btn.appendChild(el('span', { class: 'autofill-pick-site' }, entryDisplayName(e))); + if (e.username) { + btn.appendChild(el('span', { class: 'autofill-pick-user' }, e.username)); + } + list.appendChild(btn); + }); + const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ') + + entries.length + ' match "' + windowTitle.slice(0, 30) + '…"'; + $('#autofillPickerTitle').textContent = head; + $('#autofillPickerModal').classList.remove('is-hidden'); +} + +function closeAutofillPicker(notifyCancel = true) { + $('#autofillPickerModal').classList.add('is-hidden'); + if (notifyCancel) Bridge.cancelAutofill(); +} + // ============================================================ // VIEWS / NAV // ============================================================ async function setView(v) { state.view = v; + state.currentPage = 1; if (v === 'trash') { await loadTrash(); } @@ -3583,15 +4833,24 @@ function setTheme(t) { localStorage.setItem('theme', t); const sel = $('#settingTheme'); if (sel) sel.value = t; + Bridge.syncTitleBarTheme(t); } function openSettings() { $('#settingTheme').value = state.theme; + $('#settingSort').value = state.sortBy + ':' + state.sortDir; $('#settingAutoLock').value = String(state.autoLock); $('#settingAskDelete').checked = state.askBeforeDelete; $('#settingCompact').checked = state.compactActions; $('#settingMaskUser').checked = state.maskUsernames; $('#settingHIBP').checked = state.hibpEnabled; + $('#settingShowSite').checked = state.showSiteOnCards; + $('#settingAutofill').checked = state.autofillEnabled; + $('#settingAutofillRow').style.display = Bridge.active ? '' : 'none'; + // Hotkey capture buttons — labels reflect current combos. + $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); + $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); + $('#settingAutofillHotkeysRow').style.display = Bridge.active ? '' : 'none'; $('#settingUser').textContent = state.username; // Async: query server for recovery key state and update the label refreshRecoveryStatus(); @@ -3668,34 +4927,249 @@ function resetAutoLock() { }, { passive: true }) ); -function showAuth() { +async function showAuth() { $('#authScreen').classList.remove('is-hidden'); $('#appShell').classList.add('is-hidden'); if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } + + let remembered = ''; + if (Bridge.active) { + remembered = await Bridge.getPref('rememberedUsername'); + } else { + remembered = localStorage.getItem('rememberedUsername') || ''; + } + const userInput = $('#loginUsername'); + const remCb = $('#loginRememberUser'); + if (remCb) remCb.checked = !!remembered; + if (userInput && !userInput.value && remembered) userInput.value = remembered; + // Delphi-hosted: ask Delphi to SetFocus the WebBrowser control first + // (DOM input.focus() is a no-op while the WebView2 lacks OS-level + // focus). Web fallback: direct DOM focus. + if (Bridge.active) { + Bridge.appReady(); + } else { + setTimeout(() => { + const u = $('#loginUsername'); + const p = $('#loginPassword'); + if (u && u.value) p && p.focus(); + else u && u.focus(); + }, 0); + } } async function enterApp() { $('#authScreen').classList.add('is-hidden'); $('#appShell').classList.remove('is-hidden'); $('#userName').textContent = state.username; + // Server-side prefs override localStorage cache; runs before render so + // theme / view mode / mask flags are applied to the first paint. + await loadServerSettings(); // Show skeleton cards immediately while the initial fetch runs showSkeletons(6); await loadFolders(); await loadEntries(); + await loadEntryCounts(); render(); resetAutoLock(); // Fire-and-forget HIBP scan if the user opted in. Runs in background, // re-renders when done to show badges. if (state.hibpEnabled) hibpCheckAllEntries(); + // Push the user-configured hotkeys (combos + enabled state) to Delphi. + // Replaces the historical "always Ctrl+Shift+L on startup" path. + autofillPushHotkeys(); +} + +// ============================================================ +// SERVER-SIDE SETTINGS SYNC +// ============================================================ +// +// Synced keys (user preferences, portable across devices). Device-specific +// toggles (quickUnlockEnabled, autofillEnabled) stay in localStorage. +const SYNCED_SETTING_KEYS = [ + 'theme', 'autoLock', 'askBeforeDelete', 'maskUsernames', + 'compactActions', 'viewMode', 'hibpEnabled', 'showSiteOnCards', + 'sortBy', 'sortDir', 'pageSize', + // Hotkey combos are user preferences — values are portable. The + // registration itself is Windows-only, so non-Windows clients just + // ignore them. + 'autofillHotkeyFull', 'autofillHotkeyPwd', + // Sidebar section collapsed state. Object of { folders, tags, tools } + // booleans. Synced so the user gets the same fold state across devices. + 'sidebarCollapsed', +]; + +function applySidebarCollapsed() { + const s = state.sidebarCollapsed || {}; + document.querySelectorAll('.sidebar-section[data-section]').forEach(sec => { + const k = sec.getAttribute('data-section'); + sec.classList.toggle('is-collapsed', !!s[k]); + }); +} + +async function loadServerSettings() { + try { + const r = await fetch(API + '/settings', { headers: authHeaders() }); + if (!r.ok) return; + const remote = await r.json(); + // Merge remote into state (remote wins). localStorage is updated + // too so first-paint on next reload uses the synced value. + SYNCED_SETTING_KEYS.forEach(k => { + if (!(k in remote)) return; + const v = remote[k]; + state[k] = v; + switch (k) { + case 'theme': localStorage.setItem('theme', v); break; + case 'autoLock': localStorage.setItem('autoLockMin', String(v)); break; + case 'askBeforeDelete': localStorage.setItem('askBeforeDelete', v ? '1' : '0'); break; + case 'maskUsernames': localStorage.setItem('maskUsernames', v ? '1' : '0'); break; + case 'compactActions': localStorage.setItem('compactActions', v ? '1' : '0'); break; + case 'viewMode': localStorage.setItem('viewMode', v); break; + case 'hibpEnabled': localStorage.setItem('hibpEnabled', v ? '1' : '0'); break; + case 'showSiteOnCards': localStorage.setItem('showSiteOnCards', v ? '1' : '0'); break; + case 'sortBy': localStorage.setItem('sortBy', String(v)); break; + case 'sortDir': localStorage.setItem('sortDir', String(v)); break; + case 'pageSize': localStorage.setItem('pageSize', String(v)); break; + case 'autofillHotkeyFull': + case 'autofillHotkeyPwd': + case 'sidebarCollapsed': + // Object; persist as JSON so the next cold start picks it up. + localStorage.setItem(k, JSON.stringify(v)); + break; + } + }); + // Apply visual settings immediately. + if (remote.theme) setTheme(remote.theme); + if ('sidebarCollapsed' in remote) applySidebarCollapsed(); + } catch (e) { + // Network/server hiccup is harmless — localStorage cache still works. + } +} + +let _settingsSaveTimer = null; +function saveServerSettings() { + // Debounce: collapse rapid toggles (e.g. user playing with the theme + // dropdown) into a single PUT. + if (_settingsSaveTimer) clearTimeout(_settingsSaveTimer); + _settingsSaveTimer = setTimeout(async () => { + _settingsSaveTimer = null; + const payload = {}; + SYNCED_SETTING_KEYS.forEach(k => { payload[k] = state[k]; }); + try { + await fetch(API + '/settings', { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + } catch (e) { + // Silent — next change will retry. + } + }, 400); } // ============================================================ // INIT // ============================================================ +function installCustomContextMenu() { + const menu = el('div', { class: 'custom-ctxmenu is-hidden' }); + document.body.appendChild(menu); + + function isEditable(node) { + if (!node) return false; + const tag = node.tagName; + if (tag === 'INPUT') return !['button','submit','checkbox','radio','range','color','file'].includes(node.type); + if (tag === 'TEXTAREA') return true; + if (node.isContentEditable) return true; + return false; + } + + function hide() { menu.classList.add('is-hidden'); } + + function buildItems(target) { + menu.innerHTML = ''; + const isInput = isEditable(target); + const hasSelection = isInput && target.selectionStart !== target.selectionEnd; + const items = [ + { lbl: 'Cut', on: hasSelection, fn: () => doCut(target) }, + { lbl: 'Copy', on: hasSelection, fn: () => doCopy(target) }, + { lbl: 'Paste', on: isInput && !target.readOnly, fn: () => doPaste(target) }, + { lbl: 'Select all', on: isInput, fn: () => target.select() }, + ]; + items.forEach(it => { + const mi = el('button', { + class: 'custom-ctxmenu-item' + (it.on ? '' : ' is-disabled'), + type: 'button', + on: { click: ev => { + ev.preventDefault(); + ev.stopPropagation(); + if (!it.on) return; + hide(); + it.fn(); + } }, + }, it.lbl); + menu.appendChild(mi); + }); + } + + async function doCopy(target) { + const sel = target.value.slice(target.selectionStart, target.selectionEnd); + try { await navigator.clipboard.writeText(sel); } catch (e) {} + } + async function doCut(target) { + await doCopy(target); + const s = target.selectionStart, e = target.selectionEnd; + target.value = target.value.slice(0, s) + target.value.slice(e); + target.selectionStart = target.selectionEnd = s; + target.dispatchEvent(new Event('input', { bubbles: true })); + } + async function doPaste(target) { + const txt = Bridge.active ? await bridgeReadClipboard() + : await navigator.clipboard.readText().catch(() => ''); + if (!txt) return; + const s = target.selectionStart, e = target.selectionEnd; + target.value = target.value.slice(0, s) + txt + target.value.slice(e); + target.selectionStart = target.selectionEnd = s + txt.length; + target.dispatchEvent(new Event('input', { bubbles: true })); + } + + document.addEventListener('contextmenu', ev => { + ev.preventDefault(); + if (!isEditable(ev.target)) { hide(); return; } + buildItems(ev.target); + menu.classList.remove('is-hidden'); + const vw = window.innerWidth, vh = window.innerHeight; + const mw = menu.offsetWidth || 160, mh = menu.offsetHeight || 140; + const x = Math.min(ev.clientX, vw - mw - 4); + const y = Math.min(ev.clientY, vh - mh - 4); + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + }); + document.addEventListener('mousedown', ev => { + if (!ev.target.closest('.custom-ctxmenu')) hide(); + }); + document.addEventListener('keydown', ev => { + if (ev.key === 'Escape') hide(); + }); + window.addEventListener('blur', hide); +} + async function init() { document.documentElement.setAttribute('data-theme', state.theme); + if (location.search.indexOf('pmt=') !== -1) { + history.replaceState(null, '', location.pathname + location.hash); + } + + installCustomContextMenu(); + document.addEventListener('keydown', e => { + const mod = e.ctrlKey || e.metaKey; + if (e.key === 'F12' || e.key === 'F5') return e.preventDefault(); + if (mod && e.shiftKey && /^[ijIJ]$/.test(e.key)) return e.preventDefault(); // DevTools + if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload + if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window + if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data + }); + // Auth tabs $$('.auth-tab').forEach(t => { t.addEventListener('click', () => { @@ -3721,14 +5195,23 @@ async function init() { applyViewMode(); $$('.view-btn').forEach(b => b.addEventListener('click', () => { state.viewMode = b.dataset.view; + state.currentPage = 1; localStorage.setItem('viewMode', state.viewMode); applyViewMode(); + saveServerSettings(); })); - $('#themeBtn').addEventListener('click', toggleTheme); + $('#themeBtn').addEventListener('click', () => { + toggleTheme(); + saveServerSettings(); + }); $('#newEntryBtn').addEventListener('click', () => openEntryModal()); $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); $('#lockBtn').addEventListener('click', lockVault); + $('#dropdownSettingsBtn').addEventListener('click', () => { + $('#userDropdown').classList.add('is-hidden'); + openSettings(); + }); $('#logoutBtn').addEventListener('click', doLogout); document.addEventListener('click', e => { if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden'); @@ -3767,12 +5250,10 @@ async function init() { // Search $('#searchInput').addEventListener('input', e => { state.search = e.target.value; + state.currentPage = 1; renderGrid(); }); - // Marquee rubber-band selection on the entry grid - $('#entryGrid').addEventListener('mousedown', startMarquee); - // Slide-over $('#slideoverClose').addEventListener('click', closeSlideOver); // Click outside the slide-over closes it. Clicks on cards re-open it for @@ -3788,6 +5269,19 @@ async function init() { closeSlideOver(); }); + // Click outside the settings panel closes it. Each setting change has + // already pushed to server + localStorage, so "close = autosave" is + // implicit. Ignore clicks on the triggers and on any open modal (so the + // reauth / confirm flows fired from inside settings don't dismiss it). + document.addEventListener('click', e => { + if (!$('#settingsPanel').classList.contains('is-open')) return; + if (e.target.closest('#settingsPanel')) return; + if (e.target.closest('#settingsBtn')) return; + if (e.target.closest('#dropdownSettingsBtn')) return; + if (e.target.closest('.modal')) return; + closeSettings(); + }); + // Entry modal $('#entryForm').addEventListener('submit', saveEntry); $('#entrySaveBtn').addEventListener('click', saveEntry); @@ -3796,6 +5290,10 @@ async function init() { const input = $('#entryPassword'); input.type = input.type === 'password' ? 'text' : 'password'; }); + $('#loginPwToggle').addEventListener('click', () => { + const input = $('#loginPassword'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); $('#entryPwGen').addEventListener('click', openGen); // Chip input (tags) @@ -3851,6 +5349,26 @@ async function init() { $('#sidebarGenBtn').addEventListener('click', () => openGen('standalone')); $('#sidebarExportBtn').addEventListener('click', doExport); $('#sidebarImportBtn').addEventListener('click', doImport); + $('#sidebarAuthenticatorBtn').addEventListener('click', () => { + state.view = 'authenticator'; + state.currentPage = 1; + $$('.nav-item').forEach(b => b.classList.remove('is-active')); + render(); + }); + $('#sidebarTotpToolBtn').addEventListener('click', openTotpTool); + + // Sidebar section collapse toggles + document.querySelectorAll('[data-section-toggle]').forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-section-toggle'); + state.sidebarCollapsed = state.sidebarCollapsed || {}; + state.sidebarCollapsed[key] = !state.sidebarCollapsed[key]; + applySidebarCollapsed(); + localStorage.setItem('sidebarCollapsed', JSON.stringify(state.sidebarCollapsed)); + saveServerSettings(); + }); + }); + applySidebarCollapsed(); // Idle warning "Stay unlocked" $('#idleStayBtn').addEventListener('click', resetAutoLock); @@ -3858,31 +5376,57 @@ async function init() { // Settings slide-over $('#settingsBtn').addEventListener('click', openSettings); $('#settingsClose').addEventListener('click', closeSettings); - $('#settingTheme').addEventListener('change', e => setTheme(e.target.value)); + // Theme: setTheme already writes localStorage. Capture before/after so + // sync only fires if it actually changed. + $('#settingTheme').addEventListener('change', e => { + setTheme(e.target.value); + state.theme = e.target.value; + saveServerSettings(); + }); + $('#settingSort').addEventListener('change', e => { + const [by, dir] = e.target.value.split(':'); + state.sortBy = by; state.sortDir = dir; + state.currentPage = 1; + localStorage.setItem('sortBy', by); + localStorage.setItem('sortDir', dir); + render(); + saveServerSettings(); + }); $('#settingAutoLock').addEventListener('change', e => { state.autoLock = parseInt(e.target.value); localStorage.setItem('autoLockMin', String(state.autoLock)); resetAutoLock(); + saveServerSettings(); toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled'); }); $('#settingAskDelete').addEventListener('change', e => { state.askBeforeDelete = e.target.checked; localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0'); + saveServerSettings(); toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking'); }); $('#settingCompact').addEventListener('change', e => { state.compactActions = e.target.checked; localStorage.setItem('compactActions', state.compactActions ? '1' : '0'); render(); + saveServerSettings(); }); $('#settingMaskUser').addEventListener('change', e => { state.maskUsernames = e.target.checked; localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); render(); + saveServerSettings(); + }); + $('#settingShowSite').addEventListener('change', e => { + state.showSiteOnCards = e.target.checked; + localStorage.setItem('showSiteOnCards', state.showSiteOnCards ? '1' : '0'); + render(); + saveServerSettings(); }); $('#settingHIBP').addEventListener('change', e => { state.hibpEnabled = e.target.checked; localStorage.setItem('hibpEnabled', state.hibpEnabled ? '1' : '0'); + saveServerSettings(); if (state.hibpEnabled) { toast('Checking passwords against breach database…'); hibpCheckAllEntries(); @@ -3892,6 +5436,89 @@ async function init() { toast('Breach check disabled'); } }); + $('#settingAutofill').addEventListener('change', e => { + state.autofillEnabled = e.target.checked; + localStorage.setItem('autofillEnabled', state.autofillEnabled ? '1' : '0'); + // Push the FULL state (toggle + combos) so Delphi register/unregister + // uses the user's current combos, not the defaults. + autofillPushHotkeys(); + const lbl = autofillComboLabel(state.autofillHotkeyFull); + toast(state.autofillEnabled ? ('Autofill enabled (' + lbl + ')') : 'Autofill disabled'); + }); + + // ---- Hotkey capture buttons ---- + // Click → button label becomes "Press combo…" → next keydown captures. + // While capturing, all other keys are swallowed so the user can press + // any modifier+letter combo without triggering app shortcuts. + function bindHotkeyCapture(btnId, kind) { + const btn = $(btnId); + if (!btn) return; + btn.addEventListener('click', () => { + if (btn.dataset.capturing === '1') return; + btn.dataset.capturing = '1'; + btn.classList.add('is-capturing'); + const original = btn.textContent; + btn.textContent = 'Press combo… (Esc to cancel)'; + + function finish(restore) { + btn.dataset.capturing = ''; + btn.classList.remove('is-capturing'); + document.removeEventListener('keydown', onKey, true); + if (restore) btn.textContent = original; + } + + function onKey(e) { + // Swallow EVERYTHING while capturing so the user's combo + // doesn't trigger the cmd-palette etc. + e.preventDefault(); + e.stopPropagation(); + const captured = autofillCaptureFromEvent(e); + if (captured === null) return; // still building + if (captured === 'cancel') { finish(true); return; } + if (captured === 'invalid'){ + toast('Unsupported key — use a letter, digit or F-key', 'warning'); + finish(true); + return; + } + if (!autofillComboValid(captured)) { + toast('Combo needs at least Ctrl, Alt or Win as a modifier', 'warning'); + finish(true); + return; + } + // Reject if it collides with the other slot. + const other = (kind === 'full') ? state.autofillHotkeyPwd : state.autofillHotkeyFull; + if (JSON.stringify(other) === JSON.stringify(captured)) { + toast('That combo is already used by the other hotkey', 'warning'); + finish(true); + return; + } + // Commit. + if (kind === 'full') state.autofillHotkeyFull = captured; + else state.autofillHotkeyPwd = captured; + btn.textContent = autofillComboLabel(captured); + finish(false); + autofillPushHotkeys(); // re-register in Delphi + saveServerSettings(); // sync to server (debounced) + } + document.addEventListener('keydown', onKey, true); + }); + } + bindHotkeyCapture('#settingAutofillFullCombo', 'full'); + bindHotkeyCapture('#settingAutofillPwdCombo', 'password'); + + $('#settingAutofillResetHotkeys').addEventListener('click', () => { + state.autofillHotkeyFull = { ctrl: true, shift: true, alt: false, win: false, key: 'L' }; + state.autofillHotkeyPwd = { ctrl: true, shift: true, alt: false, win: false, key: 'P' }; + $('#settingAutofillFullCombo').textContent = autofillComboLabel(state.autofillHotkeyFull); + $('#settingAutofillPwdCombo').textContent = autofillComboLabel(state.autofillHotkeyPwd); + autofillPushHotkeys(); + saveServerSettings(); + toast('Hotkeys reset to defaults'); + }); + + // Autofill picker modal close button + $$('#autofillPickerModal [data-close]').forEach(b => + b.addEventListener('click', closeAutofillPicker)); $('#openClipboardSettings').addEventListener('click', () => { toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); }); @@ -3931,11 +5558,35 @@ async function init() { b.addEventListener('click', () => closeConfirm(false)) ); - // Command palette + // Helper: is the user currently typing into an input/textarea/select + // or contenteditable surface? Shortcuts like Ctrl+A must NOT hijack + // input focus (browser default = select all text in the field). + function isTypingTarget(t) { + if (!t) return false; + const tag = t.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if (t.isContentEditable) return true; + return false; + } + + // Command palette + bulk shortcuts document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openPalette(); + } else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { + // Ctrl+A = select every visible entry. Replaces the role of + // a marquee "drag across all cards" that we used to have. + // Only fire when the user isn't typing in a field — otherwise + // we'd steal the universal Select All in inputs. + if (isTypingTarget(e.target)) return; + // Don't fire if the app isn't actually showing the entry grid + // (auth screen, locked, etc.) + if ($('#appShell').classList.contains('is-hidden')) return; + e.preventDefault(); + const visible = filteredEntries(); + visible.forEach(en => state.checked.add(en.id)); + renderGrid(); } else if (e.key === 'Escape') { // Close in priority order: confirm first (most modal-y) then others if (!$('#confirmModal').classList.contains('is-hidden')) { @@ -3950,11 +5601,28 @@ async function init() { closeSlideOver(); closeEntryModal(); closeGen(); + // If nothing else needed dismissing and there's an active + // selection, clear it. Replaces the "click empty space to + // deselect" path that the marquee provided. + if (state.checked.size > 0) { + state.checked.clear(); + renderGrid(); + } } }); $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); + // Re-sync quickUnlockEnabled from the DPAPI source of truth. localStorage + // is wiped at each launch (random port → new origin), so the cached value + // can lie about the actual server-side state. + if (Bridge.active) { + const has = await bridgeQuickUnlockStatus(); + state.quickUnlockEnabled = has; + if (has) localStorage.setItem('quickUnlockEnabled', '1'); + else localStorage.removeItem('quickUnlockEnabled'); + } + // Restore session if any if (state.token && state.salt) { const ok = await restoreCryptoKey(); @@ -3982,5 +5650,4 @@ async function init() { } } } - document.addEventListener('DOMContentLoaded', init); diff --git a/test_pbkdf2.php b/test_pbkdf2.php new file mode 100644 index 0000000..fd3a73e --- /dev/null +++ b/test_pbkdf2.php @@ -0,0 +1,51 @@ +busyTimeout(5000); + +$st = $db->prepare('SELECT id, username, password_hash, salt, hash_algo FROM users WHERE username=:u'); +$st->bindValue(':u', $u, SQLITE3_TEXT); +$row = $st->execute()->fetchArray(SQLITE3_ASSOC); + +if (!$row) { echo "User '$u' not found.\n"; exit; } + +echo "=== Stored in vault.db ===\n"; +echo "id : " . $row['id'] . "\n"; +echo "username : " . $row['username'] . "\n"; +echo "hash_algo : " . ($row['hash_algo'] ?? '(null)') . "\n"; +echo "salt : " . $row['salt'] . "\n"; +echo "salt len : " . strlen($row['salt']) . "\n"; +echo "stored hash : " . $row['password_hash'] . "\n"; +echo "stored len : " . strlen($row['password_hash']) . "\n\n"; + +$algo = $row['hash_algo'] ?? 'pbkdf2'; + +if ($algo === 'pbkdf2') { + $computed = hash_pbkdf2('sha256', $p, $row['salt'], 100000); + echo "=== PHP hash_pbkdf2('sha256', '$p', salt, 100000) ===\n"; + echo "computed : $computed\n"; + echo "computed len : " . strlen($computed) . "\n\n"; + + $match = hash_equals($row['password_hash'], $computed); + echo "Match : " . ($match ? 'YES ✓ (PHP would let this user in)' : 'NO ✗ (Delphi & PHP disagree on PBKDF2)') . "\n"; + + if (!$match) { + echo "\nFirst chars side by side:\n"; + echo "Stored : " . substr($row['password_hash'], 0, 32) . "\n"; + echo "Computed : " . substr($computed, 0, 32) . "\n"; + } +} else { + echo "Account uses BCRYPT, not PBKDF2. Cannot diagnose PBKDF2 mismatch here.\n"; +} + +$db->close();