feat: MFA tools, single-instance, tray polish, prefs persistence
Session highlights:
- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
rememberedUsername being lost across reboots due to the random
ephemeral HTTP port changing the localStorage origin every launch.
Bridge cmd://prefs/{get,set} round-trips through Delphi.
- feat(tray): icon visible from startup (NIM_ADD at constructor, not
at first minimize). Tray context menu themed via uxtheme!135
SetPreferredAppMode so it follows the app's dark/light setting.
- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
running bridge restores the window from tray. Mutex lives in Local\
namespace so distinct Windows users can still each run one.
- feat(mfa): Authenticator sidebar view (live TOTP codes for every
entry with a secret) + standalone TOTP generator modal (paste
base32 / otpauth:// URI, or generate a random 20-byte secret).
- feat(sidebar): Folders / Tags / Tools sections collapsible with
chevron toggle. Badge counts stay visible when collapsed. State
persisted in settings_json (synced across devices).
- feat(autofill): hotkey when vault is locked now restores the app
and focuses the master password input instead of no-op'ing
silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
vault was locked" path.
- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
sleep. Rationale: the DPAPI blob already gates access via the
Windows account, so re-locking on top of the OS lock is redundant.
Idle auto-lock still fires (separate opt-in).
- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
source-of-truth at boot, instead of trusting (now-volatile)
localStorage.
- docs: CLAUDE.md updated with all new modules, bridge commands,
and the port-ephemeral pitfall.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -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=<token>` (sur la 1ʳᵉ navigation) → Set-Cookie
|
||||
- OU cookie `pm_token=<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
|
||||
@@ -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); }
|
||||
@@ -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 <td>, 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;
|
||||
}
|
||||
|
||||
@@ -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:<site>").
|
||||
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<string>);
|
||||
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<string>('action', '');
|
||||
LSite := LBody.GetValue<string>('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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>('site', ''));
|
||||
LTitle := Trim(LBody.GetValue<string>('title', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
LEnc := LBody.GetValue<string>('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<string>('site', ''));
|
||||
LTitle := Trim(LBody.GetValue<string>('title', ''));
|
||||
LUser := Trim(LBody.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LBody.GetValue<string>('folder', 'All'));
|
||||
LEnc := LBody.GetValue<string>('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<string>('site', ''));
|
||||
LTitle := Trim(LEntry.GetValue<string>('title', ''));
|
||||
LUser := Trim(LEntry.GetValue<string>('username', ''));
|
||||
LFolder := Trim(LEntry.GetValue<string>('folder', 'All'));
|
||||
LEnc := LEntry.GetValue<string>('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<string>);
|
||||
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);
|
||||
|
||||
@@ -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,8 +318,6 @@ 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');
|
||||
@@ -329,6 +330,7 @@ begin
|
||||
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;
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
unit PM.Handler.Settings;
|
||||
|
||||
(*
|
||||
GET /settings -> {<arbitrary JSON object stored as-is>}
|
||||
PUT /settings body: {<arbitrary JSON object>} -> {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<string>);
|
||||
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<string>);
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -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;
|
||||
|
||||
// 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<TInput>;
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DWORD, DWORD>;
|
||||
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<DWORD, DWORD>.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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -71,9 +86,15 @@ begin
|
||||
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.
|
||||
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -0,0 +1,43 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"
|
||||
shape-rendering="crispEdges">
|
||||
|
||||
<!-- Pixel-perfect hexagon: vertices at integer coords -->
|
||||
<!-- Strategy: paint hex row by row as 1px tall rects (no diagonals) -->
|
||||
|
||||
<!-- Row 1 (y=0): tip 4px wide -->
|
||||
<rect x="6" y="0" width="4" height="1" fill="#0891b2"/>
|
||||
|
||||
<!-- Row 2 (y=1): 6px -->
|
||||
<rect x="5" y="1" width="6" height="1" fill="#0891b2"/>
|
||||
|
||||
<!-- Row 3 (y=2): 8px -->
|
||||
<rect x="4" y="2" width="8" height="1" fill="#06b6d4"/>
|
||||
|
||||
<!-- Row 4 (y=3): 10px -->
|
||||
<rect x="3" y="3" width="10" height="1" fill="#06b6d4"/>
|
||||
|
||||
<!-- Row 5 (y=4): 12px -->
|
||||
<rect x="2" y="4" width="12" height="1" fill="#06b6d4"/>
|
||||
|
||||
<!-- Rows 5-10 (y=5..10): full 14px wide body -->
|
||||
<rect x="1" y="5" width="14" height="6" fill="#06b6d4"/>
|
||||
|
||||
<!-- Row 11 (y=11): 12px -->
|
||||
<rect x="2" y="11" width="12" height="1" fill="#06b6d4"/>
|
||||
|
||||
<!-- Row 12: 10px -->
|
||||
<rect x="3" y="12" width="10" height="1" fill="#06b6d4"/>
|
||||
|
||||
<!-- Row 13: 8px -->
|
||||
<rect x="4" y="13" width="8" height="1" fill="#0891b2"/>
|
||||
|
||||
<!-- Row 14: 6px -->
|
||||
<rect x="5" y="14" width="6" height="1" fill="#0891b2"/>
|
||||
|
||||
<!-- Row 15: tip 4px -->
|
||||
<rect x="6" y="15" width="4" height="1" fill="#0891b2"/>
|
||||
|
||||
<!-- Center dot: 4x4 dark square + 2x2 purple accent -->
|
||||
<rect x="6" y="6" width="4" height="4" fill="#0f172a"/>
|
||||
<rect x="7" y="7" width="2" height="2" fill="#a78bfa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,61 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="hexFill" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0e7490"/>
|
||||
<stop offset="0.5" stop-color="#06b6d4"/>
|
||||
<stop offset="1" stop-color="#22d3ee"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hexHighlight" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="0.5" stop-color="#ffffff" stop-opacity="0.05"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="keyholeGlow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#a78bfa" stop-opacity="0.6"/>
|
||||
<stop offset="0.6" stop-color="#a78bfa" stop-opacity="0.15"/>
|
||||
<stop offset="1" stop-color="#a78bfa" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="dropHex" x="-15%" y="-15%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="6" stdDeviation="10"
|
||||
flood-color="#06b6d4" flood-opacity="0.5"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Hexagon body — pointy-top orientation -->
|
||||
<g filter="url(#dropHex)">
|
||||
<path d="M 128 8
|
||||
L 230 66
|
||||
V 190
|
||||
L 128 248
|
||||
L 26 190
|
||||
V 66 Z"
|
||||
fill="url(#hexFill)"
|
||||
stroke="#0e7490" stroke-width="3" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
||||
<!-- Top highlight band -->
|
||||
<path d="M 128 8
|
||||
L 230 66
|
||||
V 128
|
||||
L 128 100
|
||||
L 26 128
|
||||
V 66 Z"
|
||||
fill="url(#hexHighlight)"/>
|
||||
|
||||
<!-- Glow around keyhole -->
|
||||
<circle cx="128" cy="140" r="68" fill="url(#keyholeGlow)"/>
|
||||
|
||||
<!-- Inner panel (darker hex) -->
|
||||
<path d="M 128 50
|
||||
L 196 90
|
||||
V 166
|
||||
L 128 206
|
||||
L 60 166
|
||||
V 90 Z"
|
||||
fill="#0f172a" fill-opacity="0.55"/>
|
||||
|
||||
<!-- Keyhole — circle + tapered slit -->
|
||||
<circle cx="128" cy="120" r="18" fill="#22d3ee"/>
|
||||
<path d="M 120 132 L 116 172 L 140 172 L 136 132 Z" fill="#22d3ee"/>
|
||||
<circle cx="128" cy="120" r="7" fill="#0f172a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="keyBow" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#22d3ee"/>
|
||||
<stop offset="1" stop-color="#0891b2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="keyStem" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#06b6d4"/>
|
||||
<stop offset="1" stop-color="#0e7490"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="bowHole" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#a78bfa"/>
|
||||
<stop offset="0.7" stop-color="#7c3aed"/>
|
||||
<stop offset="1" stop-color="#5b21b6"/>
|
||||
</radialGradient>
|
||||
<filter id="dropK" x="-15%" y="-15%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-opacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g filter="url(#dropK)">
|
||||
<!-- Key bow (hexagonal head) -->
|
||||
<path d="M 128 24
|
||||
L 196 64
|
||||
V 144
|
||||
L 128 184
|
||||
L 60 144
|
||||
V 64 Z"
|
||||
fill="url(#keyBow)"
|
||||
stroke="#0e7490" stroke-width="3" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Stem -->
|
||||
<rect x="114" y="170" width="28" height="60" rx="6"
|
||||
fill="url(#keyStem)"/>
|
||||
|
||||
<!-- Tooth 1 -->
|
||||
<rect x="142" y="190" width="22" height="12" rx="3"
|
||||
fill="url(#keyStem)"/>
|
||||
|
||||
<!-- Tooth 2 (smaller) -->
|
||||
<rect x="142" y="212" width="14" height="10" rx="3"
|
||||
fill="url(#keyStem)"/>
|
||||
</g>
|
||||
|
||||
<!-- Center hole in bow (with accent glow) -->
|
||||
<circle cx="128" cy="104" r="26" fill="url(#bowHole)"/>
|
||||
<circle cx="128" cy="104" r="14" fill="#0f172a"/>
|
||||
<circle cx="128" cy="104" r="6" fill="#a78bfa" opacity="0.7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,41 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="shieldFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#22d3ee"/>
|
||||
<stop offset="0.5" stop-color="#06b6d4"/>
|
||||
<stop offset="1" stop-color="#0891b2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGloss" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<filter id="dropS" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Free-form shield silhouette, no container -->
|
||||
<g filter="url(#dropS)">
|
||||
<path d="M 128 16
|
||||
L 224 56
|
||||
V 134
|
||||
C 224 184, 184 224, 128 244
|
||||
C 72 224, 32 184, 32 134
|
||||
V 56 Z"
|
||||
fill="url(#shieldFill)"
|
||||
stroke="#0e7490" stroke-width="3"/>
|
||||
</g>
|
||||
|
||||
<!-- Top gloss -->
|
||||
<path d="M 128 16
|
||||
L 224 56
|
||||
V 124
|
||||
C 184 138, 72 138, 32 124
|
||||
V 56 Z"
|
||||
fill="url(#shieldGloss)"/>
|
||||
|
||||
<!-- Keyhole -->
|
||||
<circle cx="128" cy="116" r="22" fill="#0f172a"/>
|
||||
<path d="M 117 130 L 113 184 L 143 184 L 139 130 Z" fill="#0f172a"/>
|
||||
<circle cx="128" cy="116" r="10" fill="#a78bfa"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<radialGradient id="bodyV" cx="0.5" cy="0.35" r="0.7">
|
||||
<stop offset="0" stop-color="#475569"/>
|
||||
<stop offset="1" stop-color="#1e293b"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="dial" cx="0.5" cy="0.4" r="0.6">
|
||||
<stop offset="0" stop-color="#f1f5f9"/>
|
||||
<stop offset="1" stop-color="#94a3b8"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="accentV" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#22d3ee"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<filter id="dropV" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.35"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Vault body — circle with radial gradient, no container -->
|
||||
<g filter="url(#dropV)">
|
||||
<circle cx="128" cy="128" r="120" fill="url(#bodyV)"/>
|
||||
<circle cx="128" cy="128" r="120" fill="none"
|
||||
stroke="#0f172a" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Inner dial bezel -->
|
||||
<circle cx="128" cy="128" r="88" fill="#334155"/>
|
||||
<circle cx="128" cy="128" r="80" fill="url(#dial)"/>
|
||||
|
||||
<!-- Tick marks -->
|
||||
<g stroke="#475569" stroke-width="3" stroke-linecap="round">
|
||||
<line x1="128" y1="54" x2="128" y2="64"/>
|
||||
<line x1="128" y1="192" x2="128" y2="202"/>
|
||||
<line x1="54" y1="128" x2="64" y2="128"/>
|
||||
<line x1="192" y1="128" x2="202" y2="128"/>
|
||||
<line x1="76" y1="76" x2="83" y2="83"/>
|
||||
<line x1="180" y1="76" x2="173" y2="83"/>
|
||||
<line x1="76" y1="180" x2="83" y2="173"/>
|
||||
<line x1="180" y1="180" x2="173" y2="173"/>
|
||||
</g>
|
||||
|
||||
<!-- Center hub -->
|
||||
<circle cx="128" cy="128" r="28" fill="url(#accentV)"/>
|
||||
<circle cx="128" cy="128" r="10" fill="#0f172a"/>
|
||||
|
||||
<!-- Dial pointer -->
|
||||
<rect x="124" y="60" width="8" height="34" rx="3" fill="url(#accentV)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0891b2"/>
|
||||
<stop offset="1" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#a78bfa"/>
|
||||
<stop offset="1" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded square background -->
|
||||
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||
|
||||
<!-- Shackle -->
|
||||
<path d="M 88 110 V 84 a 40 40 0 0 1 80 0 V 110"
|
||||
fill="none" stroke="#ffffff" stroke-width="20"
|
||||
stroke-linecap="round"/>
|
||||
|
||||
<!-- Lock body -->
|
||||
<rect x="64" y="108" width="128" height="100" rx="18"
|
||||
fill="#ffffff"/>
|
||||
|
||||
<!-- Three "password" dots -->
|
||||
<circle cx="96" cy="158" r="11" fill="url(#accent)"/>
|
||||
<circle cx="128" cy="158" r="11" fill="url(#accent)"/>
|
||||
<circle cx="160" cy="158" r="11" fill="url(#accent)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1011 B |
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🔐 Vault — Legacy UI</title>
|
||||
<link rel="stylesheet" href="css/style-legacy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
<div class="idle-warning" id="idleWarning">
|
||||
<h3>⏰ Auto-lock</h3>
|
||||
<p>Vault locks in <span id="idleCountdown">30</span>s</p>
|
||||
<button class="btn btn-sm" onclick="resetIdle()">Stay Unlocked</button>
|
||||
</div>
|
||||
|
||||
<div class="edit-modal" id="editModal">
|
||||
<div class="edit-box">
|
||||
<h3>✏️ Edit Entry</h3>
|
||||
<label>Website / App</label><input type="text" id="editSite" placeholder="example.com">
|
||||
<label style="margin-top:.6rem">Username / Email</label><input type="text" id="editUsername" placeholder="user@example.com">
|
||||
<label style="margin-top:.6rem">Password</label>
|
||||
<div style="position:relative">
|
||||
<input type="password" id="editPassword" placeholder="Password" style="width:100%;padding-right:40px">
|
||||
<button type="button" onclick="toggleEditPassword()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:.9rem">👁️</button>
|
||||
</div>
|
||||
<label style="margin-top:.6rem">Folder</label>
|
||||
<select id="editFolder" style="width:100%;margin-bottom:.4rem"></select>
|
||||
<input type="hidden" id="editId">
|
||||
<div style="display:flex;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn" onclick="saveEdit()">💾 Save</button>
|
||||
<button class="btn btn-outline" onclick="closeEdit()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Password Modal -->
|
||||
<div class="modal-overlay" id="addModal">
|
||||
<div class="modal-box">
|
||||
<h3>➕ New Password</h3>
|
||||
<label>Website / App</label><input type="text" id="addSite" placeholder="example.com">
|
||||
<label style="margin-top:.6rem">Username / Email (optional)</label><input type="text" id="addUsername" placeholder="user@example.com">
|
||||
<label style="margin-top:.6rem">Password</label>
|
||||
<div style="position:relative">
|
||||
<input type="password" id="addPassword" placeholder="Password" style="width:100%;padding-right:40px" oninput="checkAddStrength()">
|
||||
<button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate">🎲</button>
|
||||
</div>
|
||||
<div class="strength-bar s0" id="addStrengthBar" style="margin-top:0.2rem;"></div>
|
||||
<label style="margin-top:.6rem">Folder</label>
|
||||
<select id="addFolder" style="width:100%;margin-bottom:.4rem"></select>
|
||||
<div style="display:flex;gap:.5rem;margin-top:1rem">
|
||||
<button class="btn" id="addEntryBtn" onclick="addEntry()">💾 Save</button>
|
||||
<button class="btn btn-outline" onclick="closeAdd()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating action buttons -->
|
||||
<button class="fab" onclick="openAdd()" title="Add password">+</button>
|
||||
<button class="fab fab-trash" id="trashBtn" onclick="toggleTrash()" title="Trash">🗑️</button>
|
||||
<div class="vault">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem">
|
||||
<h1 style="margin-bottom:0">🔐 Vault <span>XAMPP</span></h1>
|
||||
<button class="btn btn-outline btn-xs" onclick="toggleTheme()" title="Toggle theme">🌓</button>
|
||||
</div>
|
||||
|
||||
<!-- Auth Section -->
|
||||
<div id="authSection" class="auth-section">
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" onclick="switchTab('login')">Login</button>
|
||||
<button class="auth-tab" onclick="switchTab('register')">Register</button>
|
||||
</div>
|
||||
<div id="loginForm">
|
||||
<div class="input-group">
|
||||
<input type="text" id="loginUsername" placeholder="Username" autocomplete="off">
|
||||
<input type="password" id="loginPassword" placeholder="Master Password" autocomplete="off">
|
||||
<button class="btn" id="loginBtn" onclick="login()">🔓 Unlock</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="loginWithPasskey()" title="Use passkey" style="white-space:nowrap">🔐 Passkey</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="registerForm" class="hidden">
|
||||
<div class="input-group">
|
||||
<input type="text" id="regUsername" placeholder="Username (min 3)" autocomplete="off">
|
||||
<input type="password" id="regPassword" placeholder="Password (min 8)" autocomplete="off" oninput="checkRegStrength()">
|
||||
<button class="btn" id="registerBtn" onclick="register()">✨ Create</button>
|
||||
</div>
|
||||
<div class="strength-bar s0" id="regStrengthBar" style="margin-top:0.4rem;margin-bottom:0.2rem;flex-basis:100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vault Section -->
|
||||
<div id="vaultSection" class="hidden">
|
||||
<!-- Top bar -->
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.5rem;align-items:center;flex-wrap:wrap;gap:.4rem">
|
||||
<span class="status-badge" id="connectionStatus">🟢 Connected</span>
|
||||
<span id="entryCount" style="color:var(--text2);font-size:.8rem"></span>
|
||||
<div style="display:flex;gap:.4rem;align-items:center">
|
||||
<span id="currentUser" style="color:var(--text2);font-size:.8rem"></span>
|
||||
<div class="settings-dropdown">
|
||||
<button class="btn btn-outline btn-sm" id="settingsBtn" onclick="toggleSettings()">⚙️</button>
|
||||
<div class="settings-menu hidden" id="settingsMenu">
|
||||
<div class="settings-item">
|
||||
<span>Auto-lock</span>
|
||||
<select id="autoLockTimer" onchange="setAutoLock()">
|
||||
<option value="0">No lock</option>
|
||||
<option value="1">1 min</option>
|
||||
<option value="5" selected>5 min</option>
|
||||
<option value="15">15 min</option>
|
||||
<option value="30">30 min</option>
|
||||
<option value="60">1 hour</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleSound();event.stopPropagation();">
|
||||
<span>Sound</span>
|
||||
<div class="toggle-switch" id="soundToggleSwitch"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleTheme();event.stopPropagation();">
|
||||
<span>Theme</span>
|
||||
<div class="toggle-switch" id="themeToggleSwitch"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleViewBtn();event.stopPropagation();">
|
||||
<span>👁️ View password</span>
|
||||
<div class="toggle-switch active" id="showViewBtnToggle"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="toggleShowEmail();event.stopPropagation();">
|
||||
<span>📧 Show email</span>
|
||||
<div class="toggle-switch active" id="showEmailToggle"></div>
|
||||
</div>
|
||||
<div class="settings-item" onclick="if(window.PublicKeyCredential){registerPasskey()}else{toast('Passkeys not supported','error')};event.stopPropagation();">
|
||||
<span>🔐 Set up passkey</span>
|
||||
<span style="color:var(--accent);font-size:0.7rem">⇗</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline btn-sm" onclick="doLogout()">🔒 Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Entry Form -->
|
||||
<!-- <form onsubmit="return false" class="input-group" autocomplete="off"> -->
|
||||
<!-- <input type="text" id="siteInput" placeholder="Website *" autocomplete="off"> -->
|
||||
<!-- <input type="text" id="usernameInput" placeholder="Username (optional)" autocomplete="off"> -->
|
||||
<!-- <div style="flex:1;min-width:130px;position:relative"> -->
|
||||
<!-- <input type="password" id="passwordInput" placeholder="Password *" autocomplete="new-password" style="width:100%;padding-right:40px" oninput="checkStrength()"> -->
|
||||
<!-- <button type="button" onclick="openGen()" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;color:var(--text2);cursor:pointer;font-size:1.1rem;line-height:1" title="Generate password">🎲</button> -->
|
||||
<!-- </div> -->
|
||||
<!-- <div class="strength-bar s0" id="strengthBar" style="flex-basis:100%;margin-top:-0.3rem;margin-bottom:0.3rem"></div> -->
|
||||
<!-- <select id="addFolderSelect"></select> -->
|
||||
<!-- <button type="button" class="btn" id="addBtn" onclick="addEntry()">➕ Add</button> -->
|
||||
<!-- </form> -->
|
||||
|
||||
<!-- Toolbar with search + trash + views -->
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Search..." oninput="searchEntries()" autocomplete="off">
|
||||
<button type="button" class="clear-search-btn" id="clearSearchBtn" onclick="clearSearch()" title="Clear search">✕</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;align-items:center;flex-wrap:wrap">
|
||||
<button class="btn btn-outline btn-sm" onclick="showShortcutsHelp()" title="Shortcuts">⌨️</button>
|
||||
<div class="view-dropdown" id="viewDropdown">
|
||||
<button class="btn btn-outline btn-sm" id="viewDropdownBtn">🟫 Grid ▾</button>
|
||||
<div class="view-dropdown-menu hidden" id="viewDropdownMenu">
|
||||
<button class="view-opt" data-view="grid">🟫 Grid</button>
|
||||
<button class="view-opt" data-view="compact">📝 Compact</button>
|
||||
<button class="view-opt" data-view="list">📋 List</button>
|
||||
<button class="view-opt" data-view="table">📊 Table</button>
|
||||
<button class="view-opt" data-view="card">🃏 Card</button>
|
||||
<button class="view-opt" data-view="grouped">📂 Grouped</button>
|
||||
<button class="view-opt" data-view="detail">🔍 Detail</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="folders-bar" id="foldersBar"></div>
|
||||
<div id="trashActions" class="hidden" style="margin-bottom:.5rem;text-align:right;">
|
||||
<button class="empty-trash-btn" onclick="emptyTrash()">🗑️ Empty Trash</button>
|
||||
</div>
|
||||
<div id="entriesContainer" class="grid-view">
|
||||
<div style="text-align:center;color:var(--text2);padding:2rem;grid-column:1/-1">📭 No entries</div>
|
||||
</div>
|
||||
<div style="margin-top:.6rem;text-align:right">
|
||||
<button class="btn btn-outline btn-sm" onclick="showExportModal()">📤 Export</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generator Modal -->
|
||||
<div id="genModal" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);display:none;justify-content:center;align-items:center;z-index:2000">
|
||||
<div style="background:var(--bg2);border-radius:1.5rem;padding:1.5rem;min-width:320px;max-width:90%">
|
||||
<h3 style="margin-bottom:.8rem;color:var(--text)">🎲 Generator</h3>
|
||||
<div style="background:var(--input);padding:.8rem;border-radius:1rem;font-family:monospace;text-align:center;color:#4ade80;margin:.6rem 0;word-break:break-all" id="genPreview">Click Generate</div>
|
||||
<div style="display:flex;align-items:center;gap:.6rem;margin:.8rem 0">
|
||||
<span>Length:</span>
|
||||
<input type="range" id="pwdLen" min="8" max="64" value="16" oninput="onLenChange()" style="flex:1">
|
||||
<span id="lenVal" style="background:var(--input);padding:.2rem .6rem;border-radius:1rem;min-width:30px;text-align:center">16</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.5rem;margin:.5rem 0">
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useUpper" checked onchange="genPwd()"> A-Z</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useLower" checked onchange="genPwd()"> a-z</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useNum" checked onchange="genPwd()"> 0-9</label>
|
||||
<label style="font-size:.8rem;color:var(--text2)"><input type="checkbox" id="useSym" checked onchange="genPwd()"> !@#$</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.3rem;margin-top:0.8rem;flex-wrap:wrap">
|
||||
<button class="btn btn-sm" onclick="genPreset(16,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 16</button>
|
||||
<button class="btn btn-sm" onclick="genPreset(20,'upper+lower+num+sym')" style="flex:1;min-width:70px">🔒 Strong 20</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="genPreset(32,'upper+lower+num+sym')" style="flex:1;min-width:70px">🛡️ Paranoid 32</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:.4rem;margin-top:.6rem">
|
||||
<button class="btn" onclick="genPwd()" style="flex:1">🔄</button>
|
||||
<button class="btn" onclick="useGen()" style="flex:1">✅ Use</button>
|
||||
<button class="btn btn-outline" onclick="closeGen()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/app-legacy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,7 +38,11 @@
|
||||
<symbol id="i-log-out" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></symbol>
|
||||
<symbol id="i-table" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="1"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></symbol>
|
||||
<symbol id="i-more" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1.4" fill="currentColor"/><circle cx="12" cy="12" r="1.4" fill="currentColor"/><circle cx="12" cy="19" r="1.4" fill="currentColor"/></symbol>
|
||||
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol>
|
||||
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-2 2 2 2-3 3-2-2"/></symbol>
|
||||
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
|
||||
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
|
||||
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
|
||||
@@ -99,7 +103,16 @@
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Master password</span>
|
||||
<div class="input-with-action">
|
||||
<input id="loginPassword" type="password" required>
|
||||
<button type="button" class="icon-btn" id="loginPwToggle" title="Show/hide password">
|
||||
<svg><use href="#i-eye"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="remember-row">
|
||||
<input type="checkbox" id="loginRememberUser">
|
||||
<span>Remember username on this device</span>
|
||||
</label>
|
||||
<button id="loginBtn" type="submit" class="btn btn-primary btn-block">
|
||||
<svg><use href="#i-unlock"/></svg>
|
||||
@@ -157,28 +170,51 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section" data-section="folders">
|
||||
<div class="sidebar-section-header">
|
||||
<button class="section-toggle" data-section-toggle="folders">
|
||||
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
|
||||
<span>Folders</span>
|
||||
<span class="section-count" id="countFolders">0</span>
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-sm" id="addFolderBtn" title="New folder">
|
||||
<svg><use href="#i-plus"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav" id="foldersList"></nav>
|
||||
<nav class="sidebar-nav section-body" id="foldersList"></nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header"><span>Tags</span></div>
|
||||
<nav class="sidebar-nav" id="tagsList"></nav>
|
||||
<div class="sidebar-section" data-section="tags">
|
||||
<div class="sidebar-section-header">
|
||||
<button class="section-toggle" data-section-toggle="tags">
|
||||
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
|
||||
<span>Tags</span>
|
||||
<span class="section-count" id="countTags">0</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav section-body" id="tagsList"></nav>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-header"><span>Tools</span></div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="sidebar-section" data-section="tools">
|
||||
<div class="sidebar-section-header">
|
||||
<button class="section-toggle" data-section-toggle="tools">
|
||||
<svg class="section-chevron"><use href="#i-chevron-down"/></svg>
|
||||
<span>Tools</span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav section-body">
|
||||
<button class="nav-item" id="sidebarGenBtn">
|
||||
<svg><use href="#i-dice"/></svg>
|
||||
<span>Generator</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarAuthenticatorBtn">
|
||||
<svg><use href="#i-shield"/></svg>
|
||||
<span>Authenticator</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarTotpToolBtn">
|
||||
<svg><use href="#i-key"/></svg>
|
||||
<span>TOTP generator</span>
|
||||
</button>
|
||||
<button class="nav-item" id="sidebarImportBtn">
|
||||
<svg><use href="#i-log-in"/></svg>
|
||||
<span>Import vault</span>
|
||||
@@ -219,6 +255,9 @@
|
||||
<button class="view-btn" id="viewListBtn" data-view="list" title="List view">
|
||||
<svg><use href="#i-list"/></svg>
|
||||
</button>
|
||||
<button class="view-btn" id="viewTableBtn" data-view="table" title="Table view">
|
||||
<svg><use href="#i-table"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button class="icon-btn" id="themeBtn" title="Toggle theme">
|
||||
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
|
||||
@@ -233,6 +272,9 @@
|
||||
<span id="userName">user</span>
|
||||
</button>
|
||||
<div class="user-dropdown is-hidden" id="userDropdown">
|
||||
<button class="dropdown-item" id="dropdownSettingsBtn">
|
||||
<svg><use href="#i-settings"/></svg> Settings
|
||||
</button>
|
||||
<button class="dropdown-item" id="lockBtn">
|
||||
<svg><use href="#i-lock"/></svg> Lock vault
|
||||
</button>
|
||||
@@ -287,6 +329,17 @@
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Sort entries by</span>
|
||||
<select id="settingSort">
|
||||
<option value="name:asc">Name (A → Z)</option>
|
||||
<option value="name:desc">Name (Z → A)</option>
|
||||
<option value="updated:desc">Most recent first</option>
|
||||
<option value="updated:asc">Oldest update first</option>
|
||||
<option value="created:desc">Recently created</option>
|
||||
<option value="created:asc">Oldest created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>Compact action menu (⋯)</span>
|
||||
<label class="toggle">
|
||||
@@ -301,6 +354,16 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span>
|
||||
Show site / URL
|
||||
<small class="setting-hint">On cards: shown under the display name when the entry has a custom title. In table view: adds a Site column.</small>
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingShowSite">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slideover-field">
|
||||
@@ -333,6 +396,39 @@
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-row" id="settingAutofillRow">
|
||||
<span>
|
||||
Autofill with global hotkey
|
||||
<small class="setting-hint">
|
||||
Press your hotkey in any browser to fill the matching
|
||||
login. The vault must be unlocked.
|
||||
</small>
|
||||
</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="settingAutofill">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="settingAutofillHotkeysRow" style="margin-top:8px">
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
Full fill (username + Tab + password)
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillFullCombo">Ctrl+Shift+L</button>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<span style="font-size:12px;color:var(--text-dim)">
|
||||
Password only (for step-2 forms / unlock screens)
|
||||
</span>
|
||||
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
|
||||
</div>
|
||||
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
|
||||
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
|
||||
</p>
|
||||
<button class="btn btn-ghost btn-sm" id="settingAutofillResetHotkeys" style="margin-top:6px">
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="slideover-field">
|
||||
@@ -451,7 +547,7 @@
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3>Change master password</h3>
|
||||
<h3 id="changeMasterTitle">Change master password</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<form id="changeMasterForm" class="modal-body" autocomplete="off">
|
||||
@@ -497,8 +593,12 @@
|
||||
<form id="entryForm" class="modal-body" autocomplete="off">
|
||||
<input type="hidden" id="entryId">
|
||||
<label class="field">
|
||||
<span>Website / App</span>
|
||||
<input id="entrySite" type="text" required placeholder="example.com">
|
||||
<span>Display name <small style="color:var(--text-faint);font-weight:400">— shown on cards, also used for autofill matching</small></span>
|
||||
<input id="entryTitle" type="text" placeholder="GitHub (work account)">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Website / App <small style="color:var(--text-faint);font-weight:400">— hostname or brand, used for autofill matching</small></span>
|
||||
<input id="entrySite" type="text" required placeholder="github.com">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Username / Email</span>
|
||||
@@ -565,6 +665,49 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MODAL: Standalone TOTP generator (paste secret → live code) -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="totpToolModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3>TOTP generator</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<p style="margin:0 0 12px;color:var(--text-dim);font-size:13px;line-height:1.5">
|
||||
Paste a base32 secret or an <code>otpauth://</code> URI to
|
||||
generate a code on the fly. Nothing is saved.
|
||||
</p>
|
||||
<label class="field">
|
||||
<span>Secret</span>
|
||||
<input id="totpToolSecret" type="text" autocomplete="off"
|
||||
placeholder="JBSWY3DPEHPK3PXP or otpauth://totp/...">
|
||||
<div style="display:flex;gap:6px;margin-top:6px">
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="totpToolGen" style="flex:1">
|
||||
<svg><use href="#i-dice"/></svg> Generate
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="totpToolCopySecret" style="flex:1">
|
||||
<svg><use href="#i-copy"/></svg> Copy secret
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<div class="totp-panel" style="margin-top:12px">
|
||||
<div class="totp-code" id="totpToolCode">— — — — — —</div>
|
||||
<button class="icon-btn icon-btn-sm" id="totpToolCopy" title="Copy code">
|
||||
<svg><use href="#i-copy"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="totp-bar-wrap"><div class="totp-bar" id="totpToolBar"></div></div>
|
||||
<p id="totpToolError" style="margin:8px 0 0;color:#dc2626;font-size:12px;display:none"></p>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" data-close>Close</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CONFIRM / PROMPT MODAL (replaces native confirm/prompt) -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -592,6 +735,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- AUTOFILL PICKER — shown when multiple entries match hotkey -->
|
||||
<!-- ============================================================ -->
|
||||
<div id="autofillPickerModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||
<div class="modal-backdrop" data-close></div>
|
||||
<div class="modal-panel modal-panel-sm">
|
||||
<header class="modal-header">
|
||||
<h3 id="autofillPickerTitle">Choose an entry to autofill</h3>
|
||||
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||
</header>
|
||||
<div class="modal-body" style="padding:8px 0">
|
||||
<p style="margin:0 0 8px 16px;font-size:12px;color:var(--text-dim)">
|
||||
Click an entry to type its credentials into the active window.
|
||||
</p>
|
||||
<div id="autofillPickerList"></div>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button class="btn btn-ghost" data-close>Cancel</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- COMMAND PALETTE (Cmd+K) — phase 3, shell only for now -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
// Diagnostic tool: compare what PHP's hash_pbkdf2 produces vs what Delphi stored.
|
||||
// Usage: http://localhost/password-manager/test_pbkdf2.php?u=YOURUSER&p=YOURPASSWORD
|
||||
//
|
||||
// DELETE THIS FILE after diagnosis — it exposes hashes/salts in plaintext.
|
||||
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
|
||||
$u = $_GET['u'] ?? '';
|
||||
$p = $_GET['p'] ?? '';
|
||||
if (!$u || !$p) { echo "Provide ?u=USER&p=PASSWORD\n"; exit; }
|
||||
|
||||
$db = new SQLite3(__DIR__ . '/vault.db');
|
||||
$db->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();
|
||||