From 506aee7e6faa7e659355c491a454620ca5c665ef Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Fri, 22 May 2026 23:47:57 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Delphi=20backend=20+=20JS=E2=86=94Delph?= =?UTF-8?q?i=20bridge=20(clipboard,=20tray,=20auto-lock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the Delphi 12 FMX backend (PMServer) that hosts the embedded WebView2 vault on 127.0.0.1, and a native bridge between JS and Delphi that wires three privacy-focused features: 1. Secure clipboard Copying a password registers the Win32 "ExcludeClipboardContentFromMonitorProcessing" format alongside CF_UNICODETEXT, so Win+V clipboard history never sees the value. Auto-clears after 30s via TTimer. Bridge.copySecure() in app.js routes all password/username/secret copy paths through the native layer when running inside the Delphi WebView2 (falls back to navigator.clipboard for the PHP standalone). 2. Tray icon (X-to-tray when server running) Closing the dev panel hides both the form HWND and the TFMAppClass per-process proxy window that owns the FMX taskbar entry — the form's HWND alone is not the taskbar-visible one in FMX (took some iteration to discover). Tray menu: Open, Lock vault, Quit. Clipboard is force- cleared on minimize as extra safety. First-time minimize fires a balloon notification so the user knows the app is still running. 3. Auto-lock on Windows session lock (Win+L) wtsapi32.dll!WTSRegisterSessionNotification on a dedicated message-only window. On WM_WTSSESSION_CHANGE / WTS_SESSION_LOCK, the bridge calls ExecuteJavaScript('lockVault()'). Same path used by the tray "Lock vault" menu item. Bridge architecture: - JS → Delphi via cmd:// URLs intercepted in OnBeforeNavigate (pattern lifted from DeskInsight Monaco). Currently exposes cmd://clipboard/copy?text=...&clear=... and cmd://clipboard/clear. - Delphi → JS via TTMSFNCWebBrowser.ExecuteJavaScript with guarded calls (typeof check) so the bridge degrades cleanly if app.js isn't loaded yet. Files: - Source/PM.Bridge.pas (new) — TSecureClipboard + TPMBridge - UMainForm.pas/.fmx — bridge wiring, FormCloseQuery intercept, tray callbacks (BridgeTrayRestore / BridgeLockRequest / BridgeQuit) - js/app.js — Bridge object, 5 navigator.clipboard sites migrated to Bridge.copySecure with PHP-compatible fallback, Bridge.onTrayRestore handler that resets the auto-lock timer .gitignore extended with Delphi build artifacts (*.dcu, Win32/, Win64/, __history/, __recovery/, *.identcache, *.dsk, *.local, etc.) so source checkouts stay clean. --- .gitignore | 33 +- delphi-backend/Handlers/PM.Handler.Auth.pas | 343 ++ .../Handlers/PM.Handler.Entries.pas | 456 +++ .../Handlers/PM.Handler.Folders.pas | 200 + .../Handlers/PM.Handler.Passkey.pas | 47 + delphi-backend/Handlers/PM.Handler.Ping.pas | 31 + delphi-backend/PMServer.dpr | 31 + delphi-backend/PMServer.dproj | 270 ++ delphi-backend/PMServer.res | Bin 0 -> 154792 bytes delphi-backend/Source/PM.Audit.pas | 43 + delphi-backend/Source/PM.Bridge.pas | 487 +++ delphi-backend/Source/PM.Crypto.pas | 269 ++ delphi-backend/Source/PM.Database.pas | 229 ++ delphi-backend/Source/PM.EmbeddedAssets.pas | 115 + delphi-backend/Source/PM.HTTPServer.pas | 209 + delphi-backend/Source/PM.JSON.pas | 78 + delphi-backend/Source/PM.RateLimit.pas | 94 + delphi-backend/Source/PM.Router.pas | 104 + delphi-backend/Source/PM.Session.pas | 219 + delphi-backend/Source/PM.StaticFiles.pas | 137 + delphi-backend/UMainForm.fmx | 133 + delphi-backend/UMainForm.pas | 325 ++ delphi-backend/assets/BuildAssets.cmd | 21 + delphi-backend/assets/BuildAssets.ps1 | 135 + delphi-backend/assets/assets.inc | 8 + delphi-backend/assets/assets.rc | 6 + delphi-backend/assets/assets.res | Bin 0 -> 146968 bytes js/app.js | 3607 ++++++++++------- 28 files changed, 6172 insertions(+), 1458 deletions(-) create mode 100644 delphi-backend/Handlers/PM.Handler.Auth.pas create mode 100644 delphi-backend/Handlers/PM.Handler.Entries.pas create mode 100644 delphi-backend/Handlers/PM.Handler.Folders.pas create mode 100644 delphi-backend/Handlers/PM.Handler.Passkey.pas create mode 100644 delphi-backend/Handlers/PM.Handler.Ping.pas create mode 100644 delphi-backend/PMServer.dpr create mode 100644 delphi-backend/PMServer.dproj create mode 100644 delphi-backend/PMServer.res create mode 100644 delphi-backend/Source/PM.Audit.pas create mode 100644 delphi-backend/Source/PM.Bridge.pas create mode 100644 delphi-backend/Source/PM.Crypto.pas create mode 100644 delphi-backend/Source/PM.Database.pas create mode 100644 delphi-backend/Source/PM.EmbeddedAssets.pas create mode 100644 delphi-backend/Source/PM.HTTPServer.pas create mode 100644 delphi-backend/Source/PM.JSON.pas create mode 100644 delphi-backend/Source/PM.RateLimit.pas create mode 100644 delphi-backend/Source/PM.Router.pas create mode 100644 delphi-backend/Source/PM.Session.pas create mode 100644 delphi-backend/Source/PM.StaticFiles.pas create mode 100644 delphi-backend/UMainForm.fmx create mode 100644 delphi-backend/UMainForm.pas create mode 100644 delphi-backend/assets/BuildAssets.cmd create mode 100644 delphi-backend/assets/BuildAssets.ps1 create mode 100644 delphi-backend/assets/assets.inc create mode 100644 delphi-backend/assets/assets.rc create mode 100644 delphi-backend/assets/assets.res diff --git a/.gitignore b/.gitignore index 64ba219..bb5b310 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,33 @@ -vault-error.log \ No newline at end of file +vault-error.log + +# Delphi build artifacts +*.dcu +*.dcp +*.dpu +*.local +*.identcache +*.dsk +*.~dsk +*.~* +*.~bpl +*.~dll +*.~exe +*.exe +*.dll +*.bpl +*.so +*.dylib +__history/ +__recovery/ +Win32/ +Win64/ +OSX64/ +Android/ +iOSDevice64/ + +# Debug logs (created at runtime next to the exe) +pm-debug.log + +# Asset build log (regenerated by BuildAssets.cmd/ps1) +delphi-backend/assets/build.log diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas new file mode 100644 index 0000000..fe8b087 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -0,0 +1,343 @@ +unit PM.Handler.Auth; + +(* + /register POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} + /login POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} + /logout POST auth + csrf -> {message} + /reauth POST auth + csrf + body{masterPassword} -> {message} + + Hashing strategy: + - Delphi creates new accounts with PBKDF2-SHA256 100k iterations (hash_algo='pbkdf2'), + same format as PHP hash_pbkdf2. PHP can verify these too. + - For login, we read hash_algo: + pbkdf2 -> verify natively + bcrypt -> reject with clear message (bcrypt verify not implemented yet) +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.Classes, + FireDAC.Comp.Client, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Database, PM.Crypto, + PM.Session, PM.RateLimit, PM.Audit; + +const + PBKDF2_ITERATIONS = 100000; + DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); + +procedure EnsureDefaultFolders(AUserId: Integer); +var + LQ: TFDQuery; + I: Integer; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'; + for I := Low(DEFAULT_FOLDERS) to High(DEFAULT_FOLDERS) do + begin + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ParamByName('name').AsString := DEFAULT_FOLDERS[I]; + LQ.ExecSQL; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure SendAuthSuccess(AResponse: TIdHTTPResponseInfo; + AUserId: Integer; const AToken, ASalt, ACSRFToken: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', 'OK'); + LObj.AddPair('token', AToken); + LObj.AddPair('userId', TJSONNumber.Create(AUserId)); + LObj.AddPair('salt', ASalt); + LObj.AddPair('csrfToken', ACSRFToken); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== /register ============================================================= + +procedure HandleRegister(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LBody: TJSONObject; + LUser, LPwd, LSalt, LHash, LToken, LCSRF, LIP: string; + LQ: TFDQuery; + LUserId: Integer; +begin + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 5 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + if (Length(LUser) < 3) or (Length(LPwd) < 8) then + begin + TJSONHelper.SendError(AResponse, 400, 'Min 3/8 chars'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT id FROM users WHERE username = :u'; + LQ.ParamByName('u').AsString := LUser; + LQ.Open; + if not LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 409, 'Username exists'); + Exit; + end; + finally + LQ.Free; + end; + + LSalt := RandomHex(32); + LHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO users (username, password_hash, salt, hash_algo) ' + + 'VALUES (:u, :h, :s, ''pbkdf2'')'; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('h').AsString := LHash; + LQ.ParamByName('s').AsString := LSalt; + LQ.ExecSQL; + LUserId := DB.Connection.GetLastAutoGenValue('users'); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + EnsureDefaultFolders(LUserId); + CreateSession(LUserId, LToken, LCSRF); + LogAudit(LUserId, 'register', LIP); + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); +end; + +// ===== /login ================================================================ + +procedure HandleLogin(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LBody: TJSONObject; + LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; + LUserId: Integer; + LQ: TFDQuery; + LComputed: string; + LValid: Boolean; +begin + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 10 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT id, password_hash, salt, hash_algo FROM users WHERE username = :u'; + LQ.ParamByName('u').AsString := LUser; + LQ.Open; + if LQ.IsEmpty then + begin + RecordAttempt(LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); + Exit; + end; + LUserId := LQ.FieldByName('id').AsInteger; + LStoredHash := LQ.FieldByName('password_hash').AsString; + LSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + if LAlgo = '' then LAlgo := 'pbkdf2'; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LValid := False; + if SameText(LAlgo, 'pbkdf2') then + begin + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end + else if SameText(LAlgo, 'bcrypt') then + begin + // Not implemented in Delphi backend yet + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_login_bcrypt', LIP); + TJSONHelper.SendError(AResponse, 501, + 'This account was created with bcrypt (PHP). The Delphi backend does ' + + 'not verify bcrypt yet. Register a new account here, or login via PHP.'); + Exit; + end; + + if not LValid then + begin + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_login', LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); + Exit; + end; + + ClearAttempts(LIP); + DeleteAllUserSessions(LUserId); + EnsureDefaultFolders(LUserId); + CreateSession(LUserId, LToken, LCSRF); + LogAudit(LUserId, 'login', LIP); + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); +end; + +// ===== /logout =============================================================== + +procedure HandleLogout(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LToken, LAuth: string; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LAuth := ARequest.RawHeaders.Values['Authorization']; + if LAuth.StartsWith('Bearer ', True) then + begin + LToken := Copy(LAuth, 8, MaxInt); + DeleteSessionByTokenHash(SHA256Hex(LToken)); + end; + + LogAudit(LUserId, 'logout', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Logged out'); +end; + +// ===== /reauth =============================================================== + +procedure HandleReauth(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; + LQ: TFDQuery; + LValid: Boolean; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 5 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT password_hash, salt, hash_algo FROM users WHERE id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + if LQ.IsEmpty then + begin + RecordAttempt(LIP); + TJSONHelper.SendError(AResponse, 401, 'User not found'); + Exit; + end; + LStoredHash := LQ.FieldByName('password_hash').AsString; + LSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + if LAlgo = '' then LAlgo := 'pbkdf2'; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LValid := False; + if SameText(LAlgo, 'pbkdf2') then + begin + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end; + + if not LValid then + begin + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_reauth', LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid password'); + Exit; + end; + + ClearAttempts(LIP); + LogAudit(LUserId, 'reauth', LIP); + TJSONHelper.SendOK(AResponse, 'OK'); +end; + +initialization + Router.Register('POST', '/register', HandleRegister); + Router.Register('POST', '/login', HandleLogin); + Router.Register('POST', '/logout', HandleLogout); + Router.Register('POST', '/reauth', HandleReauth); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas new file mode 100644 index 0000000..ffff9e8 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -0,0 +1,456 @@ +unit PM.Handler.Entries; + +(* + GET /entries?search=&deleted=0 -> JSON array of entries + POST /entries body {site,username,encrypted_password,iv,folder} -> {id,site,username,folder} + PUT /entries/{id} body {site,username,encrypted_password,iv,folder} -> {message} + DELETE /entries/{id}?permanent=0|1 -> {message} + POST /entries/{id}/restore -> {message} + POST /entries/{id}/favorite -> {message} + DELETE /entries/trash/empty -> {message} +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.StrUtils, System.NetEncoding, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, IdGlobalProtocols, IdURI, + PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; + +function GetQueryParam(ARequest: TIdHTTPRequestInfo; const AName: string; + const ADefault: string = ''): string; +begin + Result := ARequest.Params.Values[AName]; + if Result = '' then Result := ADefault; +end; + +// SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString +// would format in system locale (DD/MM/YYYY in French). Force ISO format +// 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and +// what the JS frontend parses. +function ISODateTimeField(AField: TField): string; +begin + if AField.IsNull then + Result := '' + else + Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime); +end; + +// ===== GET /entries ========================================================== + +procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; + LSearch, LDeletedStr: string; + LDeleted: Integer; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LSearch := GetQueryParam(ARequest, 'search', ''); + LDeletedStr := GetQueryParam(ARequest, 'deleted', '0'); + if LDeletedStr = '1' then LDeleted := 1 else LDeleted := 0; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + if LSearch <> '' then + begin + LQ.SQL.Text := + 'SELECT * FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = :del ' + + 'AND (site LIKE :q OR username LIKE :q) ' + + 'ORDER BY updated_at DESC'; + LQ.ParamByName('q').AsString := '%' + LSearch + '%'; + end + else + begin + LQ.SQL.Text := + 'SELECT * FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = :del ' + + 'ORDER BY updated_at DESC'; + end; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('del').AsInteger := LDeleted; + LQ.Open; + while not LQ.Eof do + begin + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); + LObj.AddPair('site', LQ.FieldByName('site').AsString); + LObj.AddPair('username', LQ.FieldByName('username').AsString); + LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString); + LObj.AddPair('iv', LQ.FieldByName('iv').AsString); + LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString); + LObj.AddPair('folder', LQ.FieldByName('folder').AsString); + LObj.AddPair('deleted', TJSONNumber.Create(LQ.FieldByName('deleted').AsInteger)); + if LQ.FieldByName('deleted_at').IsNull then + LObj.AddPair('deleted_at', TJSONNull.Create) + else + LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); + LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger)); + LObj.AddPair('tags', LQ.FieldByName('tags').AsString); + LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); + LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /entries ========================================================= + +procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LNewId: Integer; + LBody, LObj: TJSONObject; + LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + finally + LBody.Free; + end; + + if (LSite = '') or (LEnc = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Site & password required'); + Exit; + end; + + LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO vault_entries ' + + '(user_id, site, username, encrypted_password, iv, encryption_method, ' + + ' folder, tags, created_at, updated_at) ' + + 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :c, :c2)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('e').AsString := LEnc; + LQ.ParamByName('i').AsString := LIV; + LQ.ParamByName('f').AsString := LFolder; + LQ.ParamByName('t').AsString := LTags; + LQ.ParamByName('c').AsString := LNow; + LQ.ParamByName('c2').AsString := LNow; + LQ.ExecSQL; + LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'add_entry', GetClientIP(ARequest)); + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LNewId)); + LObj.AddPair('site', LSite); + LObj.AddPair('username', LUser); + LObj.AddPair('folder', LFolder); + LObj.AddPair('tags', LTags); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== PUT /entries/{id} ===================================================== + +procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LBody: TJSONObject; + LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + finally + LBody.Free; + end; + + if (LSite = '') or (LEnc = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Site & password required'); + Exit; + end; + + LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' + + ' folder=:f, tags=:t, updated_at=:c ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('e').AsString := LEnc; + LQ.ParamByName('i').AsString := LIV; + LQ.ParamByName('f').AsString := LFolder; + LQ.ParamByName('t').AsString := LTags; + LQ.ParamByName('c').AsString := LNow; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'edit_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Updated'); +end; + +// ===== DELETE /entries/{id} ================================================== + +procedure HandleDeleteEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LPermanent: Boolean; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LPermanent := GetQueryParam(ARequest, 'permanent', '0') = '1'; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + if LPermanent then + LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid' + else + LQ.SQL.Text := + 'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + if LPermanent then + LogAudit(LUserId, 'permanent_delete', GetClientIP(ARequest)) + else + LogAudit(LUserId, 'delete_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Deleted'); +end; + +// ===== POST /entries/{id}/restore ============================================ + +procedure HandleRestoreEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET deleted=0, deleted_at=NULL, ' + + ' updated_at=datetime(''now'') ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'restore_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Restored'); +end; + +// ===== POST /entries/{id}/favorite =========================================== + +procedure HandleToggleFavorite(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET favorite = CASE WHEN favorite=1 THEN 0 ELSE 1 END ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'toggle_favorite', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Toggled'); +end; + +// ===== DELETE /entries/trash/empty =========================================== + +procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'empty_trash', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Trash emptied'); +end; + +initialization + // /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match + Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); + Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); + Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); + Router.Register('GET', '/entries', HandleGetEntries); + Router.Register('POST', '/entries', HandleCreateEntry); + Router.Register('PUT', '/entries/(\d+)', HandleUpdateEntry); + Router.Register('DELETE', '/entries/(\d+)', HandleDeleteEntry); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Folders.pas b/delphi-backend/Handlers/PM.Handler.Folders.pas new file mode 100644 index 0000000..72cf2b9 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Folders.pas @@ -0,0 +1,200 @@ +unit PM.Handler.Folders; + +(* + GET /folders -> JSON array of folder names + POST /folders body {name} -> {message,name} + DELETE /folders/{name} -> {message} +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.NetEncoding, + FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; + +// ===== GET /folders ========================================================== + +procedure HandleGetFolders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT name FROM folders WHERE user_id = :uid ORDER BY name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + while not LQ.Eof do + begin + LArr.Add(LQ.FieldByName('name').AsString); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /folders ========================================================= + +procedure HandleCreateFolder(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LName: string; + LQ: TFDQuery; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LName := Trim(LBody.GetValue('name', '')); + finally + LBody.Free; + end; + + if LName = '' then + begin + TJSONHelper.SendError(AResponse, 400, 'Folder name required'); + Exit; + end; + if SameText(LName, 'All') then + begin + TJSONHelper.SendError(AResponse, 400, 'Cannot use All'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO folders (user_id, name) VALUES (:uid, :name)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + try + LQ.ExecSQL; + except + on E: Exception do + begin + TJSONHelper.SendError(AResponse, 409, 'Folder exists'); + Exit; + end; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'add_folder', GetClientIP(ARequest)); + LObj := TJSONObject.Create; + LObj.AddPair('message', 'Created'); + LObj.AddPair('name', LName); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== DELETE /folders/{name} ================================================ + +procedure HandleDeleteFolder(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LName: string; + LQ: TFDQuery; + LChanges: Integer; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + if Length(AParams) < 1 then + begin + TJSONHelper.SendError(AResponse, 400, 'Folder name required'); + Exit; + end; + LName := TNetEncoding.URL.Decode(AParams[0]); + + if SameText(LName, 'All') then + begin + TJSONHelper.SendError(AResponse, 400, 'Cannot delete All'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM folders WHERE user_id = :uid AND name = :name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + LQ.ExecSQL; + LChanges := LQ.RowsAffected; + finally + LQ.Free; + end; + + if LChanges = 0 then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + + // Reassign entries from the deleted folder to 'All' + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET folder = ''All'' ' + + 'WHERE user_id = :uid AND folder = :name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'delete_folder', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Deleted'); +end; + +initialization + Router.Register('GET', '/folders', HandleGetFolders); + Router.Register('POST', '/folders', HandleCreateFolder); + Router.Register('DELETE', '/folders/(.+)', HandleDeleteFolder); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Passkey.pas b/delphi-backend/Handlers/PM.Handler.Passkey.pas new file mode 100644 index 0000000..92c01a1 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Passkey.pas @@ -0,0 +1,47 @@ +unit PM.Handler.Passkey; + +(* + WebAuthn / Passkey endpoints — stubbed to 501 Not Implemented. + + Why stubbed: full WebAuthn server requires: + - CBOR decoder for COSE keys + attestation objects + - DER ASN.1 encoder for ES256/RS256 public keys + - ECDSA P-256 signature verification (no native Delphi support) + - Challenge management with constant-time compares + Roughly 500-700 lines of crypto-sensitive code. The PHP version (api.php + lines 168-657) handles this with OpenSSL bindings. A faithful Delphi port + would either bind libssl/libcrypto DLLs or pull in a pure-Pascal EC lib. + + For v1 of the Delphi backend we return 501 with a clear message so the + frontend gracefully falls back to master password login. The PHP backend + remains the reference for passkey-enabled deployments. + + When implemented, see: + api.php :169-211 cbor_decode, derLen, coseToPem + api.php :537-657 register/begin, register/complete, login/begin, login/complete +*) + +interface + +implementation + +uses + System.SysUtils, + IdCustomHTTPServer, + PM.Router, PM.JSON; + +procedure HandleStub(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +begin + TJSONHelper.SendError(AResponse, 501, + 'Passkey/WebAuthn is not implemented in the Delphi backend yet. ' + + 'Use master-password login, or run the PHP backend for passkey support.'); +end; + +initialization + Router.Register('POST', '/passkey/register/begin', HandleStub); + Router.Register('POST', '/passkey/register/complete', HandleStub); + Router.Register('POST', '/passkey/login/begin', HandleStub); + Router.Register('POST', '/passkey/login/complete', HandleStub); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Ping.pas b/delphi-backend/Handlers/PM.Handler.Ping.pas new file mode 100644 index 0000000..5dbf60a --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Ping.pas @@ -0,0 +1,31 @@ +unit PM.Handler.Ping; + +(* + Minimal stub route to verify the build/run pipeline. + GET /ping returns a small JSON object with message=pong and server=delphi. +*) + +interface + +implementation + +uses + System.JSON, System.SysUtils, + IdCustomHTTPServer, + PM.Router, PM.JSON; + +procedure HandlePing(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', 'pong'); + LObj.AddPair('server', 'delphi'); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +initialization + Router.Register('GET', '/ping', HandlePing); + +end. diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr new file mode 100644 index 0000000..5d582f3 --- /dev/null +++ b/delphi-backend/PMServer.dpr @@ -0,0 +1,31 @@ +program PMServer; + +uses + System.StartUpCopy, + FMX.Forms, + UMainForm in 'UMainForm.pas' {MainForm}, + PM.JSON in 'Source\PM.JSON.pas', + PM.Database in 'Source\PM.Database.pas', + PM.Router in 'Source\PM.Router.pas', + PM.StaticFiles in 'Source\PM.StaticFiles.pas', + PM.EmbeddedAssets in 'Source\PM.EmbeddedAssets.pas', + PM.Crypto in 'Source\PM.Crypto.pas', + PM.RateLimit in 'Source\PM.RateLimit.pas', + PM.Audit in 'Source\PM.Audit.pas', + PM.Session in 'Source\PM.Session.pas', + PM.HTTPServer in 'Source\PM.HTTPServer.pas', + PM.Bridge in 'Source\PM.Bridge.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'; + +{$R *.res} +{$R assets\assets.res} + +begin + Application.Initialize; + Application.CreateForm(TMainForm, MainForm); + Application.Run; +end. diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj new file mode 100644 index 0000000..ff21530 --- /dev/null +++ b/delphi-backend/PMServer.dproj @@ -0,0 +1,270 @@ + + + {59A8733F-111A-41EC-80BE-9848275FC80D} + PMServer.dpr + True + Debug + 693249 + Application + FMX + 20.1 + Win32 + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_1 + true + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + false + false + false + false + false + 00400000 + PMServer + 1036 + CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= + System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) + $(BDS)\bin\delphi_PROJECTICON.ico + $(BDS)\bin\delphi_PROJECTICNS.icns + + + package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey= + Debug + true + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png + true + true + true + true + true + true + true + true + true + true + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\iOS\iPhone\FM_SettingIcon_87x87.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_180x180.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_SpotlightSearchIcon_120x120.png + $(BDS)\bin\Artwork\iOS\iPad\FM_ApplicationIcon_167x167.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_3x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_3x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_1024x1024.png + + + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + Debug + true + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + 1033 + $(BDS)\bin\default_app.manifest + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + RELEASE;$(DCC_Define) + 0 + false + 0 + + + PerMonitorV2 + + + DEBUG;$(DCC_Define) + false + true + true + true + + + Debug + + + Debug + + + Debug + + + Debug + + + PerMonitorV2 + true + 1033 + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + + + + + MainSource + + +
MainForm
+
+ + + + + + + + + + + + + + + + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + +
+ + Delphi.Personality.12 + + + + + PMServer.dpr + + + Microsoft Office 2000 Sample Automation Server Wrapper Components + Microsoft Office XP Sample Automation Server Wrapper Components + + + + False + True + True + True + True + True + True + False + + + 12 + + + + + "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + False + + False + + False + +
diff --git a/delphi-backend/PMServer.res b/delphi-backend/PMServer.res new file mode 100644 index 0000000000000000000000000000000000000000..2ac980a4b2b057687f9c6fb5203521b63f14ec0a GIT binary patch literal 154792 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 literal 0 HcmV?d00001 diff --git a/delphi-backend/Source/PM.Audit.pas b/delphi-backend/Source/PM.Audit.pas new file mode 100644 index 0000000..0e87c5c --- /dev/null +++ b/delphi-backend/Source/PM.Audit.pas @@ -0,0 +1,43 @@ +unit PM.Audit; + +{ + Mirrors api.php logAudit(). + user_id may be NULL for pre-auth events; pass 0 to record without user. +} + +interface + +uses + System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, + PM.Database; + +procedure LogAudit(AUserId: Integer; const AAction, AIP: string); + +implementation + +procedure LogAudit(AUserId: Integer; const AAction, AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO audit_log (user_id, action, ip) VALUES (:uid, :action, :ip)'; + if AUserId > 0 then + LQ.ParamByName('uid').AsInteger := AUserId + else + LQ.ParamByName('uid').Clear; + LQ.ParamByName('action').AsString := AAction; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas new file mode 100644 index 0000000..f4529d3 --- /dev/null +++ b/delphi-backend/Source/PM.Bridge.pas @@ -0,0 +1,487 @@ +unit PM.Bridge; + +{ + PM.Bridge — JS↔Delphi native capability bridge. + + Three features exposed to the embedded WebView2 via cmd:// URLs: + + 1. TSecureClipboard + Sets text on the Windows clipboard alongside the + ExcludeClipboardContentFromMonitorProcessing format, which prevents + Win+V clipboard history from recording the password. Auto-clears + after a configurable delay via TTimer. + + 2. Tray icon (TPMBridge.MinimizeToTray / RestoreFromTray) + Shell_NotifyIcon-based. The main window hides; a tray icon appears. + Single-click or double-click on the tray icon restores the window. + OnTrayRestore is called on the main thread so the caller can Show/BringToFront. + + 3. Windows session-lock detection + WTSRegisterSessionNotification on a dedicated message-only window. + On WTS_SESSION_LOCK the bridge fires OnSystemLock (main thread) so + the Delphi host can inject lockVault() into the WebView2. + + Both tray icon messages and WTS notifications are routed through a + single message-only window created with AllocateHWnd, avoiding any + subclassing of the FMX main window. +} + +interface + +uses + System.SysUtils, System.Classes, System.Math, + FMX.Types, FMX.Forms, + Winapi.Windows, Winapi.ShellAPI, Winapi.Messages; + +type + // ------------------------------------------------------------------------- + // TSecureClipboard + // ------------------------------------------------------------------------- + TSecureClipboard = class + private + FClearTimer: TTimer; + procedure ClearTimerTick(Sender: TObject); + public + constructor Create; + destructor Destroy; override; + // 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; + + // ------------------------------------------------------------------------- + // TPMBridge + // ------------------------------------------------------------------------- + TPMBridge = class + private + FMainForm: TForm; + FMsgWindow: HWND; + FTrayAdded: Boolean; + FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle + FIconHandle: HICON; + FNid: TNotifyIconData; + FSecureClipboard: TSecureClipboard; + FBalloonShown: Boolean; + FOnSystemLock: TProc; + FOnTrayRestore: TProc; + FOnLockRequest: TProc; + FOnQuit: TProc; + procedure MsgWindowHandler(var AMsg: TMessage); + procedure PrepareNid; + procedure ShowTrayMenu; + procedure ShowFirstTimeBalloon; + function FindFMXAppWindow: HWND; + public + constructor Create(AMainForm: TForm); + destructor Destroy; override; + // Hide main window and show tray icon. + procedure MinimizeToTray; + // Restore main window and remove tray icon. + procedure RestoreFromTray; + property SecureClipboard: TSecureClipboard read FSecureClipboard; + property TrayAdded: Boolean read FTrayAdded; + // 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. + property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore; + // Fired when the user picks "Lock vault" from the tray menu. Handler + // should trigger the JS lockVault() (typically via ExecuteJavaScript). + property OnLockRequest: TProc read FOnLockRequest write FOnLockRequest; + // Fired when the user picks "Quit" from the tray menu. Handler must + // actually terminate the app (Application.Terminate or similar) — the + // 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; + end; + +implementation + +uses + FMX.Platform.Win; + +// Win32 format name that suppresses Win+V clipboard history recording. +// Introduced in Windows 10 1809 (build 17763). Silently ignored on older builds. +const + CLIPBOARD_EXCLUDE_FORMAT = 'ExcludeClipboardContentFromMonitorProcessing'; + +// Tray callback message routed to our message-only window. +const + WM_TRAY_ICON = WM_APP + 1; + +// WTS session change message and state constants (declared here to avoid +// a hard dependency on Winapi.WtsApi32 which varies across Delphi versions). +const + WM_WTSSESSION_CHANGE = $02B1; + WTS_SESSION_LOCK = 7; + NOTIFY_FOR_THIS_SESSION = 0; + +// 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. +var + _WTSRegister : function(hWnd: HWND; dwFlags: DWORD): BOOL; stdcall = nil; + _WTSUnregister: function(hWnd: HWND): BOOL; stdcall = nil; + _WtsApiLoaded : Boolean = False; + _WtsLib : HMODULE = 0; + +procedure LoadWtsApi; +begin + if _WtsApiLoaded then Exit; + _WtsApiLoaded := True; + _WtsLib := LoadLibrary('wtsapi32.dll'); + if _WtsLib = 0 then Exit; + _WTSRegister := GetProcAddress(_WtsLib, 'WTSRegisterSessionNotification'); + _WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification'); +end; + +// ============================================================================= +// TSecureClipboard +// ============================================================================= + +constructor TSecureClipboard.Create; +begin + inherited; + FClearTimer := TTimer.Create(nil); + FClearTimer.Enabled := False; + FClearTimer.OnTimer := ClearTimerTick; +end; + +destructor TSecureClipboard.Destroy; +begin + FClearTimer.Free; + inherited; +end; + +procedure TSecureClipboard.ClearTimerTick(Sender: TObject); +begin + FClearTimer.Enabled := False; + Clear; +end; + +procedure TSecureClipboard.SetText(const AText: string; AClearAfterMs: Integer); +var + CFExclude: UINT; + LMem: THandle; // HGLOBAL — renamed to avoid Pascal's case-insensitive + // collision with the HGLOBAL type identifier. + LDest: Pointer; + LByteCount: NativeUInt; +begin + FClearTimer.Enabled := False; + + // Register (or look up if already registered) the exclusion format. + CFExclude := RegisterClipboardFormat(CLIPBOARD_EXCLUDE_FORMAT); + + LByteCount := NativeUInt(Length(AText) + 1) * SizeOf(Char); + LMem := GlobalAlloc(GMEM_MOVEABLE, LByteCount); + if LMem = 0 then Exit; + + LDest := GlobalLock(LMem); + try + Move(PChar(AText)^, LDest^, LByteCount); + finally + GlobalUnlock(LMem); + end; + + if not OpenClipboard(0) then + begin + GlobalFree(LMem); + Exit; + end; + try + EmptyClipboard; + // CF_UNICODETEXT ownership is transferred to the clipboard on success. + if SetClipboardData(CF_UNICODETEXT, LMem) = 0 then + GlobalFree(LMem); + // Exclusion marker: presence of this format is the signal to Windows; + // the data handle is nil and ignored by the subsystem. + SetClipboardData(CFExclude, 0); + finally + CloseClipboard; + end; + + if AClearAfterMs > 0 then + begin + FClearTimer.Interval := AClearAfterMs; + FClearTimer.Enabled := True; + end; +end; + +procedure TSecureClipboard.Clear; +begin + if OpenClipboard(0) then + try + EmptyClipboard; + finally + CloseClipboard; + end; +end; + +// ============================================================================= +// TPMBridge +// ============================================================================= + +constructor TPMBridge.Create(AMainForm: TForm); +begin + inherited Create; + FMainForm := AMainForm; + FSecureClipboard := TSecureClipboard.Create; + FTrayAdded := False; + FBalloonShown := False; + + // Dedicated message-only window for tray + WTS notifications. + FMsgWindow := AllocateHWnd(MsgWindowHandler); + + PrepareNid; + + // Session-lock detection (fails silently if wtsapi32.dll is absent). + LoadWtsApi; + if Assigned(_WTSRegister) then + _WTSRegister(FMsgWindow, NOTIFY_FOR_THIS_SESSION); +end; + +destructor TPMBridge.Destroy; +begin + if Assigned(_WTSUnregister) then + _WTSUnregister(FMsgWindow); + + if FTrayAdded then + begin + Shell_NotifyIcon(NIM_DELETE, @FNid); + FTrayAdded := False; + end; + + if FIconOwned and (FIconHandle <> 0) then + DestroyIcon(FIconHandle); + + DeallocateHWnd(FMsgWindow); + FSecureClipboard.Free; + inherited; +end; + +procedure TPMBridge.PrepareNid; +var + LargeIcon, SmallIcon: HICON; +begin + // Attempt to extract the small (16×16) icon from the exe. + // ExtractIconEx returns the number of icons extracted. + LargeIcon := 0; + SmallIcon := 0; + FIconOwned := False; + if ExtractIconEx(PChar(ParamStr(0)), 0, LargeIcon, SmallIcon, 1) > 0 then + begin + if LargeIcon <> 0 then DestroyIcon(LargeIcon); // we only need the small one + if SmallIcon <> 0 then + begin + FIconHandle := SmallIcon; + FIconOwned := True; + end; + end; + if FIconHandle = 0 then + FIconHandle := LoadIcon(0, IDI_APPLICATION); // shared system icon, never destroy + + FillChar(FNid, SizeOf(FNid), 0); + FNid.cbSize := SizeOf(FNid); + FNid.Wnd := FMsgWindow; + FNid.uID := 1; + FNid.uFlags := NIF_ICON or NIF_MESSAGE or NIF_TIP; + FNid.uCallbackMessage := WM_TRAY_ICON; + FNid.hIcon := FIconHandle; + // szTip: array[0..127] of WideChar — copy tooltip text safely. + Move(PChar('Password Manager')^, FNid.szTip[0], + Min(Length('Password Manager'), High(FNid.szTip)) * SizeOf(Char)); +end; + +function MainFormHWND(AForm: TForm): HWND; +begin + Result := WindowHandleToPlatform(AForm.Handle).Wnd; +end; + +function TPMBridge.FindFMXAppWindow: HWND; +var + LWnd: HWND; + LWndPid, LCurrentPid: DWORD; +begin + // FMX on Windows creates a hidden per-process window of class "TFMAppClass" + // that owns the application's taskbar entry — NOT the form's HWND. + // Hiding the form (via ShowWindow / Visible := False / WS_EX_TOOLWINDOW / + // ITaskbarList.DeleteTab) is therefore ineffective at removing the taskbar + // entry: those calls target the wrong window. The correct fix is to find + // the TFMAppClass window owned by our process and hide IT. + // Reference: https://stackoverflow.com/q/16768986 + Result := 0; + LCurrentPid := GetCurrentProcessId; + LWnd := 0; + repeat + LWnd := FindWindowEx(0, LWnd, 'TFMAppClass', nil); + if LWnd <> 0 then + begin + LWndPid := 0; + GetWindowThreadProcessId(LWnd, LWndPid); + if LWndPid = LCurrentPid then + Exit(LWnd); + end; + until LWnd = 0; +end; + +procedure TPMBridge.MinimizeToTray; +var + LFormHwnd, LAppHwnd: HWND; +begin + if not FTrayAdded then + begin + if Shell_NotifyIcon(NIM_ADD, @FNid) then + FTrayAdded := True; + end; + + // Extra safety: clear the clipboard immediately when the user minimizes, + // rather than waiting for the 30s auto-clear timer to fire. A password + // the user just copied shouldn't sit in the clipboard while the app is + // out of sight. + FSecureClipboard.Clear; + + LFormHwnd := MainFormHWND(FMainForm); + LAppHwnd := FindFMXAppWindow; + + // 1. Hide the visible form via both FMX state and Win32 ShowWindow. + // Keeps the form invisible to the user. + FMainForm.Hide; + ShowWindow(LFormHwnd, SW_HIDE); + + // 2. Hide the FMX application proxy window (TFMAppClass). THIS is what + // removes the entry from the taskbar — the form's HWND was never the + // taskbar-visible one in FMX. + if LAppHwnd <> 0 then + ShowWindow(LAppHwnd, SW_HIDE); + + // 3. First-time only: pop a balloon notification so the user knows the + // app is still running in the tray (and didn't crash). + if not FBalloonShown then + begin + ShowFirstTimeBalloon; + FBalloonShown := True; + end; +end; + +procedure TPMBridge.ShowFirstTimeBalloon; +var + LBalloon: TNotifyIconData; +const + BALLOON_TITLE = 'Password Manager'; + BALLOON_TEXT = 'Still running in the tray — click the icon to restore, ' + + 'right-click for menu.'; +begin + // Build a separate TNotifyIconData with NIF_INFO set, NIM_MODIFY on the + // same uID. szInfo/szInfoTitle carry the balloon content. NIIF_INFO + // gives the system info icon — no scary warning glyph. + FillChar(LBalloon, SizeOf(LBalloon), 0); + LBalloon.cbSize := SizeOf(LBalloon); + LBalloon.Wnd := FMsgWindow; + LBalloon.uID := 1; + LBalloon.uFlags := NIF_INFO; + Move(PChar(BALLOON_TITLE)^, LBalloon.szInfoTitle[0], + Min(Length(BALLOON_TITLE), High(LBalloon.szInfoTitle)) * SizeOf(Char)); + Move(PChar(BALLOON_TEXT)^, LBalloon.szInfo[0], + Min(Length(BALLOON_TEXT), High(LBalloon.szInfo)) * SizeOf(Char)); + LBalloon.dwInfoFlags := NIIF_INFO; + Shell_NotifyIcon(NIM_MODIFY, @LBalloon); +end; + +procedure TPMBridge.RestoreFromTray; +var + LFormHwnd, LAppHwnd: HWND; +begin + if FTrayAdded then + begin + Shell_NotifyIcon(NIM_DELETE, @FNid); + FTrayAdded := False; + end; + + LFormHwnd := MainFormHWND(FMainForm); + LAppHwnd := FindFMXAppWindow; + + // Reverse order: show the app proxy first so the taskbar entry comes back, + // then show and foreground the form. + if LAppHwnd <> 0 then + ShowWindow(LAppHwnd, SW_SHOW); + + FMainForm.Show; + ShowWindow(LFormHwnd, SW_SHOW); + ShowWindow(LFormHwnd, SW_RESTORE); + SetForegroundWindow(LFormHwnd); +end; + +procedure TPMBridge.ShowTrayMenu; +const + ID_OPEN = 1; + ID_LOCK = 2; + ID_QUIT = 3; +var + LMenu: HMENU; + LPt: TPoint; + LCmd: Cardinal; +begin + LMenu := CreatePopupMenu; + if LMenu = 0 then Exit; + try + AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open'); + AppendMenu(LMenu, MF_STRING, ID_LOCK, 'Lock vault'); + AppendMenu(LMenu, MF_SEPARATOR, 0, nil); + AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit'); + + GetCursorPos(LPt); + // SetForegroundWindow + WM_NULL post is the canonical Win32 workaround + // that lets TrackPopupMenu auto-dismiss when the user clicks elsewhere. + // Without it, the menu can become "sticky" on a hidden window. + SetForegroundWindow(FMsgWindow); + // Delphi's TrackPopupMenu is declared as returning BOOL, but with + // TPM_RETURNCMD it actually returns the selected menu item ID (or 0). + // Cast through the declared return type to read the real value. + LCmd := Cardinal(TrackPopupMenu(LMenu, + TPM_RETURNCMD or TPM_RIGHTBUTTON or TPM_NONOTIFY, + LPt.X, LPt.Y, 0, FMsgWindow, nil)); + PostMessage(FMsgWindow, WM_NULL, 0, 0); + + case LCmd of + ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); + ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); + ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); + end; + finally + DestroyMenu(LMenu); + end; +end; + +procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage); +var + LMouseEvent: Word; +begin + // AllocateHWnd creates the window on the thread that called it (here: the + // main thread, since TPMBridge.Create runs from FormCreate). Windows + // dispatches messages on the owning thread, so this handler is already + // on the main thread — no need to marshal via TThread.Queue/ForceQueue. + if AMsg.Msg = WM_TRAY_ICON then + begin + // For Shell_NotifyIcon callback messages, the mouse event is in the + // low word of LParam (regardless of NOTIFYICON_VERSION). Extracting + // it via LOWORD is more portable than comparing the full LPARAM. + LMouseEvent := Word(AMsg.LParam and $FFFF); + if (LMouseEvent = WM_LBUTTONUP) or (LMouseEvent = WM_LBUTTONDBLCLK) then + begin + if Assigned(FOnTrayRestore) then FOnTrayRestore(); + end + else if (LMouseEvent = WM_RBUTTONUP) or (LMouseEvent = WM_CONTEXTMENU) then + begin + ShowTrayMenu; + end; + end + else if AMsg.Msg = WM_WTSSESSION_CHANGE then + begin + if AMsg.WParam = WTS_SESSION_LOCK then + if Assigned(FOnSystemLock) then FOnSystemLock(); + end; + + AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam); +end; + +end. diff --git a/delphi-backend/Source/PM.Crypto.pas b/delphi-backend/Source/PM.Crypto.pas new file mode 100644 index 0000000..b5cc5bb --- /dev/null +++ b/delphi-backend/Source/PM.Crypto.pas @@ -0,0 +1,269 @@ +unit PM.Crypto; + +{ + Cryptographic primitives for the password manager backend. + + - RandomBytes: cryptographically secure via Windows CNG (BCryptGenRandom) + - SHA256Hex / SHA256Bytes: identical to PHP hash('sha256', ...) + - PBKDF2_SHA256_Hex: identical to PHP hash_pbkdf2('sha256', pwd, salt, iters) + - ConstantTimeEquals: timing-safe comparison (api.php uses hash_equals()) + - BytesToHex / HexToBytes: PHP bin2hex / hex2bin equivalents + + NOTE on bcrypt: PHP api.php hashes new passwords with PASSWORD_BCRYPT. + This Delphi backend does NOT implement bcrypt verification yet (would take + ~250 lines for Blowfish + EKS). Accounts created here use pbkdf2 — which + PHP knows how to verify and migrate. Bcrypt accounts from PHP cannot login + here yet; the auth handler returns a clear error in that case. +} + +interface + +uses + System.SysUtils, System.Classes, System.NetEncoding, + System.Hash; + +function RandomBytes(ALen: Integer): TBytes; +function RandomHex(AByteLen: Integer): string; + +function BytesToHex(const ABytes: TBytes): string; +function HexToBytes(const AHex: string): TBytes; + +function SHA256Hex(const AInput: string): string; overload; +function SHA256Hex(const AInput: TBytes): string; overload; + +function PBKDF2_SHA256_Hex(const APassword, ASaltHex: string; + AIterations: Integer; ADKLenBytes: Integer = 32): string; + +// Sanity check at unit initialization — verifies PBKDF2-HMAC-SHA256 matches +// the reference (PHP-equivalent) output. Raises if implementation drifts. +procedure SelfTestCrypto; + +function ConstantTimeEquals(const A, B: string): Boolean; + +implementation + +uses + Winapi.Windows; + +// ===== Windows CNG random ==================================================== + +const + BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002; + +function BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte; + cbBuffer: ULONG; dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptGenRandom'; + +function RandomBytes(ALen: Integer): TBytes; +var + LStatus: NTSTATUS; +begin + SetLength(Result, ALen); + if ALen = 0 then Exit; + LStatus := BCryptGenRandom(nil, @Result[0], ALen, + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if LStatus <> 0 then + raise Exception.CreateFmt('BCryptGenRandom failed (0x%x)', [LStatus]); +end; + +function RandomHex(AByteLen: Integer): string; +begin + Result := BytesToHex(RandomBytes(AByteLen)); +end; + +// ===== Hex helpers (PHP bin2hex / hex2bin) =================================== + +function BytesToHex(const ABytes: TBytes): string; +const + HEX: array[0..15] of Char = + ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'); +var + I: Integer; +begin + SetLength(Result, Length(ABytes) * 2); + for I := 0 to High(ABytes) do + begin + Result[(I * 2) + 1] := HEX[ABytes[I] shr 4]; + Result[(I * 2) + 2] := HEX[ABytes[I] and $0F]; + end; +end; + +function HexCharToInt(C: Char): Integer; inline; +begin + case C of + '0'..'9': Result := Ord(C) - Ord('0'); + 'a'..'f': Result := Ord(C) - Ord('a') + 10; + 'A'..'F': Result := Ord(C) - Ord('A') + 10; + else + raise Exception.Create('Invalid hex character: ' + C); + end; +end; + +function HexToBytes(const AHex: string): TBytes; +var + I, LLen: Integer; +begin + LLen := Length(AHex); + if Odd(LLen) then + raise Exception.Create('Hex string has odd length'); + SetLength(Result, LLen div 2); + for I := 0 to High(Result) do + Result[I] := (HexCharToInt(AHex[(I * 2) + 1]) shl 4) or + HexCharToInt(AHex[(I * 2) + 2]); +end; + +// ===== SHA256 ================================================================ + +function SHA256Hex(const AInput: string): string; +begin + Result := LowerCase(THashSHA2.GetHashString(AInput, THashSHA2.TSHA2Version.SHA256)); +end; + +function SHA256Hex(const AInput: TBytes): string; +var + H: THashSHA2; +begin + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + if Length(AInput) > 0 then + H.Update(AInput); + Result := LowerCase(BytesToHex(H.HashAsBytes)); +end; + +// ===== PBKDF2-SHA256 ========================================================= +// Matches PHP hash_pbkdf2('sha256', $password, $salt, $iterations) which +// returns lowercase hex. $salt is whatever bytes you pass — api.php stores +// salt as bin2hex(random_bytes(32)), then passes that hex STRING as the salt +// argument to hash_pbkdf2. So the "salt" fed to PBKDF2 is the 64-char hex +// representation, NOT the 32 raw bytes. We replicate that quirk here. + +// Manual HMAC-SHA256 — avoid any ambiguity with System.Hash overloads. +// Verified against RFC 4231 test vectors. +function HMAC_SHA256(const AKey, AMsg: TBytes): TBytes; +const + BLOCK = 64; // SHA256 block size in bytes +var + LKey, LIpad, LOpad, LInner: TBytes; + I: Integer; + H: THashSHA2; +begin + // Step 1: derive working key + LKey := Copy(AKey, 0, Length(AKey)); + if Length(LKey) > BLOCK then + begin + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LKey); + LKey := H.HashAsBytes; + end; + if Length(LKey) < BLOCK then + SetLength(LKey, BLOCK); // zero-padded to block size + + // Step 2: inner & outer pads + SetLength(LIpad, BLOCK); + SetLength(LOpad, BLOCK); + for I := 0 to BLOCK - 1 do + begin + LIpad[I] := LKey[I] xor $36; + LOpad[I] := LKey[I] xor $5C; + end; + + // Step 3: inner = SHA256(ipad || msg) + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LIpad); + if Length(AMsg) > 0 then H.Update(AMsg); + LInner := H.HashAsBytes; + + // Step 4: result = SHA256(opad || inner) + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LOpad); + H.Update(LInner); + Result := H.HashAsBytes; +end; + +function PBKDF2_SHA256_Hex(const APassword, ASaltHex: string; + AIterations: Integer; ADKLenBytes: Integer): string; +var + LPwd, LSalt, LU, LT, LBlock: TBytes; + LBlocks, I, J, K: Integer; + LCtr: array[0..3] of Byte; + LOut: TBytes; +begin + LPwd := TEncoding.UTF8.GetBytes(APassword); + // PHP behavior: pass salt argument as-is. api.php passes the hex string, + // so HMAC sees the ascii bytes of the hex. + LSalt := TEncoding.UTF8.GetBytes(ASaltHex); + + LBlocks := (ADKLenBytes + 31) div 32; // SHA256 block = 32 bytes + SetLength(LOut, 0); + + for I := 1 to LBlocks do + begin + LCtr[0] := (I shr 24) and $FF; + LCtr[1] := (I shr 16) and $FF; + LCtr[2] := (I shr 8) and $FF; + LCtr[3] := I and $FF; + + SetLength(LBlock, Length(LSalt) + 4); + if Length(LSalt) > 0 then + Move(LSalt[0], LBlock[0], Length(LSalt)); + Move(LCtr[0], LBlock[Length(LSalt)], 4); + + LU := HMAC_SHA256(LPwd, LBlock); + LT := Copy(LU, 0, Length(LU)); + + for J := 2 to AIterations do + begin + LU := HMAC_SHA256(LPwd, LU); + for K := 0 to High(LT) do + LT[K] := LT[K] xor LU[K]; + end; + + LOut := LOut + LT; + end; + + SetLength(LOut, ADKLenBytes); + Result := BytesToHex(LOut); +end; + +// ===== Timing-safe compare =================================================== + +function ConstantTimeEquals(const A, B: string): Boolean; +var + I, LDiff, LLen: Integer; +begin + LLen := Length(A); + if Length(B) <> LLen then Exit(False); + LDiff := 0; + for I := 1 to LLen do + LDiff := LDiff or (Ord(A[I]) xor Ord(B[I])); + Result := LDiff = 0; +end; + +// ===== Self-test ============================================================= + +procedure SelfTestCrypto; +const + // Test vector: password='password', salt='salt', iters=1, dkLen=32, sha256 + // Independently verified: matches PHP hash_pbkdf2('sha256','password','salt',1) + EXPECTED_1 = '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'; + // Same with iterations=2 + EXPECTED_2 = 'ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43'; +var + Got1, Got2: string; +begin + Got1 := PBKDF2_SHA256_Hex('password', 'salt', 1, 32); + if not SameText(Got1, EXPECTED_1) then + raise Exception.CreateFmt( + 'PBKDF2 self-test FAILED (iters=1):'#10' expected %s'#10' got %s', + [EXPECTED_1, Got1]); + + Got2 := PBKDF2_SHA256_Hex('password', 'salt', 2, 32); + if not SameText(Got2, EXPECTED_2) then + raise Exception.CreateFmt( + 'PBKDF2 self-test FAILED (iters=2):'#10' expected %s'#10' got %s', + [EXPECTED_2, Got2]); +end; + +initialization + SelfTestCrypto; + +end. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas new file mode 100644 index 0000000..7d9b316 --- /dev/null +++ b/delphi-backend/Source/PM.Database.pas @@ -0,0 +1,229 @@ +unit PM.Database; + +{ + SQLite connection (FireDAC) toward the shared vault.db file. + CreateSchema mirrors api.php (CREATE TABLE IF NOT EXISTS + ALTER migrations). + Per-thread connection is NOT implemented yet — single connection guarded by + TMonitor. Indy's TIdHTTPServer is thread-per-connection, so we serialize DB + access for safety until we move to a connection pool. +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, System.SyncObjs, + FireDAC.Comp.Client, FireDAC.Stan.Def, FireDAC.Stan.Async, + FireDAC.Phys.SQLite, FireDAC.DApt, FireDAC.Stan.Param, + FireDAC.FMXUI.Wait, FireDAC.Stan.Intf, FireDAC.UI.Intf, + Data.DB; + +type + TPMDatabase = class + private + FConn: TFDConnection; + FLock: TCriticalSection; + FDBPath: string; + function ColumnExists(const ATable, AColumn: string): Boolean; + procedure AddColumnIfMissing(const ATable, AColumn, ADef: string); + procedure CreateSchema; + procedure ApplyMigrations; + procedure CleanupExpired; + public + constructor Create(const ADBPath: string); + destructor Destroy; override; + procedure Lock; + procedure Unlock; + property Connection: TFDConnection read FConn; + property DBPath: string read FDBPath; + end; + +var + DB: TPMDatabase; + +procedure InitDatabase(const ADBPath: string); +procedure DoneDatabase; + +implementation + +constructor TPMDatabase.Create(const ADBPath: string); +begin + inherited Create; + FDBPath := ADBPath; + FLock := TCriticalSection.Create; + FConn := TFDConnection.Create(nil); + FConn.DriverName := 'SQLite'; + FConn.Params.Values['Database'] := FDBPath; + FConn.Params.Values['LockingMode'] := 'Normal'; + FConn.Params.Values['Synchronous'] := 'Normal'; + FConn.Params.Values['BusyTimeout'] := '5000'; + FConn.Params.Values['JournalMode'] := 'WAL'; + FConn.Open; + CreateSchema; + ApplyMigrations; + CleanupExpired; +end; + +destructor TPMDatabase.Destroy; +begin + FConn.Free; + FLock.Free; + inherited; +end; + +procedure TPMDatabase.Lock; +begin + FLock.Enter; +end; + +procedure TPMDatabase.Unlock; +begin + FLock.Leave; +end; + +procedure TPMDatabase.CreateSchema; +begin + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS users (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' username TEXT UNIQUE NOT NULL,' + + ' password_hash TEXT NOT NULL,' + + ' salt TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' hash_algo TEXT DEFAULT ''pbkdf2''' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS folders (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' name TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' + + ' UNIQUE(user_id, name)' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS vault_entries (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' site TEXT NOT NULL,' + + ' username TEXT NOT NULL,' + + ' encrypted_password TEXT NOT NULL,' + + ' iv TEXT NOT NULL,' + + ' encryption_method TEXT DEFAULT ''server'',' + + ' folder TEXT DEFAULT ''All'',' + + ' deleted INTEGER DEFAULT 0,' + + ' deleted_at DATETIME,' + + ' favorite INTEGER DEFAULT 0,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' updated_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS sessions (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' token_hash TEXT UNIQUE NOT NULL,' + + ' csrf_token TEXT,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' expires_at DATETIME NOT NULL,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS login_attempts (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' ip TEXT NOT NULL,' + + ' attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS audit_log (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER,' + + ' action TEXT NOT NULL,' + + ' ip TEXT,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS passkey_challenges (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER,' + + ' challenge BLOB NOT NULL,' + + ' type TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS passkey_credentials (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' credential_id BLOB NOT NULL UNIQUE,' + + ' public_key BLOB NOT NULL,' + + ' counter INTEGER DEFAULT 0,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' + + ')'); +end; + +function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; +var + LQ: TFDQuery; +begin + Result := False; + LQ := TFDQuery.Create(nil); + try + LQ.Connection := FConn; + // PRAGMA table_info returns one row per column with name in column 'name' + LQ.SQL.Text := 'PRAGMA table_info(' + ATable + ')'; + LQ.Open; + while not LQ.Eof do + begin + if SameText(LQ.FieldByName('name').AsString, AColumn) then + Exit(True); + LQ.Next; + end; + finally + LQ.Free; + end; +end; + +procedure TPMDatabase.AddColumnIfMissing(const ATable, AColumn, ADef: string); +begin + if not ColumnExists(ATable, AColumn) then + FConn.ExecSQL('ALTER TABLE ' + ATable + ' ADD COLUMN ' + AColumn + ' ' + ADef); +end; + +procedure TPMDatabase.ApplyMigrations; +begin + // Idempotent: only ALTER when the column is actually missing — no exception + // bubbling up to the debugger like api.php's try/catch did. + AddColumnIfMissing('vault_entries', 'encryption_method', 'TEXT DEFAULT ''server'''); + AddColumnIfMissing('vault_entries', 'folder', 'TEXT DEFAULT ''All'''); + AddColumnIfMissing('vault_entries', 'deleted', 'INTEGER DEFAULT 0'); + AddColumnIfMissing('vault_entries', 'deleted_at', 'DATETIME'); + AddColumnIfMissing('vault_entries', 'favorite', 'INTEGER DEFAULT 0'); + // 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 '''''); + AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); + AddColumnIfMissing('sessions', 'csrf_token', 'TEXT'); +end; + +procedure TPMDatabase.CleanupExpired; +begin + FConn.ExecSQL('DELETE FROM sessions WHERE expires_at < datetime(''now'')'); + FConn.ExecSQL('DELETE FROM login_attempts WHERE attempted_at < datetime(''now'', ''-15 minutes'')'); + FConn.ExecSQL('DELETE FROM audit_log WHERE created_at < datetime(''now'', ''-30 days'')'); + FConn.ExecSQL('DELETE FROM passkey_challenges WHERE created_at < datetime(''now'', ''-10 minutes'')'); +end; + +procedure InitDatabase(const ADBPath: string); +begin + if DB = nil then + DB := TPMDatabase.Create(ADBPath); +end; + +procedure DoneDatabase; +begin + FreeAndNil(DB); +end; + +initialization +finalization + DoneDatabase; +end. diff --git a/delphi-backend/Source/PM.EmbeddedAssets.pas b/delphi-backend/Source/PM.EmbeddedAssets.pas new file mode 100644 index 0000000..699f35c --- /dev/null +++ b/delphi-backend/Source/PM.EmbeddedAssets.pas @@ -0,0 +1,115 @@ +unit PM.EmbeddedAssets; + +(* + Serves the password-manager HTML/JS/CSS from Win32 RCDATA resources + embedded inside PMServer.exe by BuildAssets.ps1. + + Workflow: + 1. Edit ../../index.html, ../../js/*.js, ../../css/*.css + 2. Run delphi-backend/assets/BuildAssets.ps1 + 3. Rebuild — exe ships self-contained + 4. Run — TryServe pulls bytes from HInstance resources + + No disk I/O at runtime. No external files needed beside PMServer.exe. + + The manifest (URL path -> resource name) is generated alongside the .res: + delphi-backend/assets/assets.inc, included below via {$I}. If the file + does not exist (BuildAssets.ps1 never ran), the compile-time fallback + registers no assets and TryServe always returns False. +*) + +interface + +uses + System.SysUtils, System.Classes, + IdCustomHTTPServer; + +type + TEmbeddedAsset = record + UrlPath: string; + ResName: string; + end; + +function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + +implementation + +uses + Winapi.Windows; + +// The manifest is auto-generated. A stub is checked in so the project +// compiles before BuildAssets.ps1 ever runs; running the script overwrites +// it with the real list. +{$I ..\assets\assets.inc} + +function MimeTypeFor(const AExt: string): string; +var + E: string; +begin + E := LowerCase(AExt); + if (E = '.html') or (E = '.htm') then Exit('text/html; charset=utf-8'); + if E = '.js' then Exit('application/javascript; charset=utf-8'); + if E = '.mjs' then Exit('application/javascript; charset=utf-8'); + if E = '.css' then Exit('text/css; charset=utf-8'); + if E = '.json' then Exit('application/json; charset=utf-8'); + if E = '.svg' then Exit('image/svg+xml'); + if E = '.png' then Exit('image/png'); + if E = '.jpg' then Exit('image/jpeg'); + if E = '.jpeg' then Exit('image/jpeg'); + if E = '.gif' then Exit('image/gif'); + if E = '.ico' then Exit('image/x-icon'); + if E = '.woff' then Exit('font/woff'); + if E = '.woff2' then Exit('font/woff2'); + Result := 'application/octet-stream'; +end; + +function FindResourceFor(const AUrlPath: string; out AResName: string): Boolean; +var + I: Integer; + LPath: string; +begin + LPath := AUrlPath; + if (LPath = '') or (LPath = '/') then LPath := '/index.html'; + for I := 0 to EMBEDDED_ASSET_COUNT - 1 do + if SameText(EMBEDDED_ASSETS[I].UrlPath, LPath) then + begin + AResName := EMBEDDED_ASSETS[I].ResName; + Exit(True); + end; + Result := False; +end; + +function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LResName, LExt: string; + LStream: TResourceStream; + LMS: TMemoryStream; +begin + Result := False; + if not SameText(ARequest.Command, 'GET') then Exit; + if not FindResourceFor(ARequest.Document, LResName) then Exit; + if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit; + + LExt := ExtractFileExt(ARequest.Document); + if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then + LExt := '.html'; + AResponse.ContentType := MimeTypeFor(LExt); + + LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA); + try + // Copy into a TMemoryStream so Indy can own and free it after the response. + LMS := TMemoryStream.Create; + LMS.CopyFrom(LStream, 0); + LMS.Position := 0; + AResponse.ContentStream := LMS; + AResponse.FreeContentStream := True; + finally + LStream.Free; + end; + AResponse.ResponseNo := 200; + Result := True; +end; + +end. diff --git a/delphi-backend/Source/PM.HTTPServer.pas b/delphi-backend/Source/PM.HTTPServer.pas new file mode 100644 index 0000000..1ac68d6 --- /dev/null +++ b/delphi-backend/Source/PM.HTTPServer.pas @@ -0,0 +1,209 @@ +unit PM.HTTPServer; + +{ + Indy TIdHTTPServer wrapper. + - Binds 127.0.0.1 ONLY (hardcoded — never expose on LAN) + - Sets security headers (CSP, HSTS, CORS localhost) + - Handles OPTIONS preflight + - Dispatches to PM.Router; 404 if no match +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, + IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, + PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets; + +type + TLogProc = reference to procedure(const AMsg: string); + + TPMHTTPServer = class + private + FServer: TIdHTTPServer; + FOnLog: TLogProc; + procedure HandleCommand(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); + procedure HandleCommandOther(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); + procedure HandleParseAuthentication(AContext: TIdContext; + const AAuthType, AAuthData: string; + var VUsername, VPassword: string; var VHandled: Boolean); + procedure HandleException(AContext: TIdContext; AException: Exception); + procedure ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo); + procedure Log(const AMsg: string); + function GetActive: Boolean; + public + constructor Create; + destructor Destroy; override; + procedure Start(APort: Integer); + procedure Stop; + property Active: Boolean read GetActive; + property OnLog: TLogProc read FOnLog write FOnLog; + end; + +implementation + +constructor TPMHTTPServer.Create; +begin + inherited; + FServer := TIdHTTPServer.Create(nil); + FServer.OnCommandGet := HandleCommand; + FServer.OnCommandOther := HandleCommandOther; + // Tell Indy NOT to raise EIdHTTPUnsupportedAuthorisationScheme on 'Bearer'. + // We parse the Authorization header ourselves in PM.Session. + FServer.OnParseAuthentication := HandleParseAuthentication; + // Swallow harmless socket disconnect exceptions (10053 / 10054) — Edge + // Chromium pre-fetches and cancels connections, which is normal but noisy + // under the debugger. + FServer.OnException := HandleException; +end; + +procedure TPMHTTPServer.HandleException(AContext: TIdContext; + AException: Exception); +begin + // EIdSocketError with 10053/10054 = client aborted, expected. Log everything + // else. + if (AException.ClassName = 'EIdSocketError') + or (AException.ClassName = 'EIdConnClosedGracefully') then + Exit; + Log('Server exception: ' + AException.ClassName + ' - ' + AException.Message); +end; + +procedure TPMHTTPServer.HandleParseAuthentication(AContext: TIdContext; + const AAuthType, AAuthData: string; + var VUsername, VPassword: string; var VHandled: Boolean); +begin + // Accept any scheme silently; we read the raw header ourselves. + VHandled := True; +end; + +destructor TPMHTTPServer.Destroy; +begin + Stop; + FServer.Free; + inherited; +end; + +function TPMHTTPServer.GetActive: Boolean; +begin + Result := Assigned(FServer) and FServer.Active; +end; + +procedure TPMHTTPServer.Log(const AMsg: string); +begin + if Assigned(FOnLog) then FOnLog(AMsg); +end; + +procedure TPMHTTPServer.Start(APort: Integer); +var + LBinding: TIdSocketHandle; + LDBPath, LWebRoot: string; +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)), '..\')); + Log('Opening database: ' + LDBPath); + InitDatabase(LDBPath); + Log('Database ready.'); + Log('Web root: ' + LWebRoot); + InitStaticServer(LWebRoot); + + FServer.Bindings.Clear; + LBinding := FServer.Bindings.Add; + LBinding.IP := '127.0.0.1'; + LBinding.Port := APort; + + FServer.Active := True; + Log('Server started on http://127.0.0.1:' + IntToStr(APort)); +end; + +procedure TPMHTTPServer.Stop; +begin + if not Assigned(FServer) then Exit; + if FServer.Active then + begin + FServer.Active := False; + Log('Server stopped.'); + end; +end; + +procedure TPMHTTPServer.ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo); +var + LOrigin: string; +begin + AResponse.CustomHeaders.Values['Strict-Transport-Security'] := + 'max-age=31536000; includeSubDomains'; + AResponse.CustomHeaders.Values['Content-Security-Policy'] := + 'default-src ''self''; script-src ''self'' ''unsafe-inline''; ' + + 'style-src ''self'' ''unsafe-inline''; connect-src ''self''; ' + + 'img-src ''self'' data:; font-src ''self''; form-action ''self''; ' + + 'frame-ancestors ''none''; base-uri ''self''; object-src ''none'''; + AResponse.CustomHeaders.Values['X-Content-Type-Options'] := 'nosniff'; + AResponse.CustomHeaders.Values['Referrer-Policy'] := 'no-referrer'; + + // CORS — accept only localhost / 127.0.0.1 origins (any port) + LOrigin := ARequest.RawHeaders.Values['Origin']; + if (LOrigin <> '') and ( + (Pos('http://localhost', LOrigin) = 1) or + (Pos('http://127.0.0.1', LOrigin) = 1) or + (Pos('https://localhost', LOrigin) = 1) or + (Pos('https://127.0.0.1', LOrigin) = 1) + ) then + begin + AResponse.CustomHeaders.Values['Access-Control-Allow-Origin'] := LOrigin; + AResponse.CustomHeaders.Values['Access-Control-Allow-Methods'] := + 'GET, POST, PUT, DELETE, OPTIONS'; + AResponse.CustomHeaders.Values['Access-Control-Allow-Headers'] := + 'Content-Type, Authorization, X-CSRF-Token'; + end; +end; + +procedure TPMHTTPServer.HandleCommand(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); +begin + ApplySecurityHeaders(ARequest, AResponse); + 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; + TJSONHelper.SendError(AResponse, 404, 'Not found'); + except + on E: Exception do + begin + Log('ERROR ' + ARequest.Command + ' ' + ARequest.Document + ' : ' + E.Message); + TJSONHelper.SendError(AResponse, 500, 'Internal server error'); + end; + end; +end; + +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 + try + if not Router.DispatchRequest(ARequest, AResponse) then + TJSONHelper.SendError(AResponse, 404, 'Not found'); + except + on E: Exception do + begin + Log('ERROR ' + ARequest.Command + ' ' + ARequest.Document + ' : ' + E.Message); + TJSONHelper.SendError(AResponse, 500, 'Internal server error'); + end; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.JSON.pas b/delphi-backend/Source/PM.JSON.pas new file mode 100644 index 0000000..ae9d757 --- /dev/null +++ b/delphi-backend/Source/PM.JSON.pas @@ -0,0 +1,78 @@ +unit PM.JSON; + +interface + +uses + System.SysUtils, System.Classes, System.JSON, IdCustomHTTPServer; + +type + TJSONHelper = class + public + class function ReadBody(ARequest: TIdHTTPRequestInfo): TJSONObject; + class procedure SendJSON(AResponse: TIdHTTPResponseInfo; AObj: TJSONValue; + ACode: Integer = 200; AOwnsObj: Boolean = True); + class procedure SendError(AResponse: TIdHTTPResponseInfo; ACode: Integer; + const AMsg: string); + class procedure SendOK(AResponse: TIdHTTPResponseInfo; const AMessage: string = 'OK'); + end; + +implementation + +class function TJSONHelper.ReadBody(ARequest: TIdHTTPRequestInfo): TJSONObject; +var + S: string; + LSS: TStringStream; + LValue: TJSONValue; +begin + Result := nil; + if ARequest.PostStream = nil then Exit(TJSONObject.Create); + LSS := TStringStream.Create('', TEncoding.UTF8); + try + ARequest.PostStream.Position := 0; + LSS.CopyFrom(ARequest.PostStream); + S := LSS.DataString; + finally + LSS.Free; + end; + if Trim(S) = '' then Exit(TJSONObject.Create); + LValue := TJSONObject.ParseJSONValue(S); + if LValue is TJSONObject then + Result := TJSONObject(LValue) + else + begin + LValue.Free; + Result := TJSONObject.Create; + end; +end; + +class procedure TJSONHelper.SendJSON(AResponse: TIdHTTPResponseInfo; + AObj: TJSONValue; ACode: Integer; AOwnsObj: Boolean); +begin + AResponse.ResponseNo := ACode; + AResponse.ContentType := 'application/json; charset=utf-8'; + AResponse.CharSet := 'utf-8'; + AResponse.ContentText := AObj.ToJSON; + if AOwnsObj then AObj.Free; +end; + +class procedure TJSONHelper.SendError(AResponse: TIdHTTPResponseInfo; + ACode: Integer; const AMsg: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('error', AMsg); + SendJSON(AResponse, LObj, ACode); +end; + +class procedure TJSONHelper.SendOK(AResponse: TIdHTTPResponseInfo; + const AMessage: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', AMessage); + SendJSON(AResponse, LObj); +end; + +end. diff --git a/delphi-backend/Source/PM.RateLimit.pas b/delphi-backend/Source/PM.RateLimit.pas new file mode 100644 index 0000000..61f2e2d --- /dev/null +++ b/delphi-backend/Source/PM.RateLimit.pas @@ -0,0 +1,94 @@ +unit PM.RateLimit; + +{ + Mirrors api.php checkRateLimit / recordAttempt / clearAttempts. + 15-minute window. Caller decides the threshold (5 for register, 10 for login). +} + +interface + +uses + System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, + PM.Database; + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +function CheckRateLimit(const AIP: string): Integer; +procedure RecordAttempt(const AIP: string); +procedure ClearAttempts(const AIP: string); + +implementation + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +begin + // api.php trusts X-Forwarded-For (security flaw H1 in audit). Since this + // server is loopback-only and not behind a proxy, prefer the actual peer IP. + Result := ARequest.RemoteIP; + if Result = '' then Result := 'unknown'; +end; + +function CheckRateLimit(const AIP: string): Integer; +var + LQ: TFDQuery; +begin + Result := 0; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT COUNT(*) AS cnt FROM login_attempts ' + + 'WHERE ip = :ip ' + + 'AND attempted_at > datetime(''now'', ''-15 minutes'')'; + LQ.ParamByName('ip').AsString := AIP; + LQ.Open; + Result := LQ.FieldByName('cnt').AsInteger; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure RecordAttempt(const AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO login_attempts (ip) VALUES (:ip)'; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure ClearAttempts(const AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM login_attempts WHERE ip = :ip'; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.Router.pas b/delphi-backend/Source/PM.Router.pas new file mode 100644 index 0000000..ffbc940 --- /dev/null +++ b/delphi-backend/Source/PM.Router.pas @@ -0,0 +1,104 @@ +unit PM.Router; + +{ + URL dispatcher. Mirrors the switch(true) pattern of api.php. + Each handler unit registers its routes here. The Router itself owns no state. +} + +interface + +uses + System.SysUtils, System.Classes, System.Generics.Collections, + System.RegularExpressions, + IdCustomHTTPServer; + +type + TRouteParams = TArray; + + TRouteHandler = reference to procedure( + ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; + const AParams: TRouteParams); + + TRoute = record + Method: string; + Pattern: string; // regex; ^ and $ added automatically + Regex: TRegEx; + Handler: TRouteHandler; + end; + + TPMRouter = class + private + FRoutes: TList; + public + constructor Create; + destructor Destroy; override; + procedure Register(const AMethod, APattern: string; + const AHandler: TRouteHandler); + function DispatchRequest(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + end; + +var + Router: TPMRouter; + +implementation + +constructor TPMRouter.Create; +begin + inherited; + FRoutes := TList.Create; +end; + +destructor TPMRouter.Destroy; +begin + FRoutes.Free; + inherited; +end; + +procedure TPMRouter.Register(const AMethod, APattern: string; + const AHandler: TRouteHandler); +var + R: TRoute; +begin + R.Method := UpperCase(AMethod); + R.Pattern := APattern; + R.Regex := TRegEx.Create('^' + APattern + '$'); + R.Handler := AHandler; + FRoutes.Add(R); +end; + +function TPMRouter.DispatchRequest(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LRoute: TRoute; + LMatch: TMatch; + LParams: TRouteParams; + I: Integer; + LMethod, LPath: string; +begin + Result := False; + LMethod := UpperCase(ARequest.Command); + LPath := ARequest.Document; + for LRoute in FRoutes do + begin + if LRoute.Method <> LMethod then Continue; + LMatch := LRoute.Regex.Match(LPath); + if LMatch.Success then + begin + SetLength(LParams, LMatch.Groups.Count - 1); + for I := 1 to LMatch.Groups.Count - 1 do + LParams[I - 1] := LMatch.Groups[I].Value; + LRoute.Handler(ARequest, AResponse, LParams); + Exit(True); + end; + end; +end; + +initialization + Router := TPMRouter.Create; + +finalization + Router.Free; + +end. diff --git a/delphi-backend/Source/PM.Session.pas b/delphi-backend/Source/PM.Session.pas new file mode 100644 index 0000000..6bfee85 --- /dev/null +++ b/delphi-backend/Source/PM.Session.pas @@ -0,0 +1,219 @@ +unit PM.Session; + +{ + Session lookup + CSRF validation. + + - Authenticate: read Bearer token from Authorization header, SHA256 it, + look up sessions.token_hash. Reject if missing/expired. Returns userId. + On failure, writes 401 + JSON error and raises ESessionRejected so the + handler aborts cleanly. + + - RequireCSRF: for non-GET methods, validate X-CSRF-Token header against + the user's latest session csrf_token (constant-time compare). +} + +interface + +uses + System.SysUtils, System.Classes, System.StrUtils, + FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Database, PM.Crypto, PM.JSON; + +type + ESessionRejected = class(Exception); + +function Authenticate(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Integer; +procedure RequireCSRF(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; AUserId: Integer); + +function CreateSession(AUserId: Integer; out AToken, ACSRFToken: string): Boolean; +procedure DeleteSessionByTokenHash(const ATokenHash: string); +procedure DeleteAllUserSessions(AUserId: Integer); + +implementation + +uses + System.DateUtils; + +function ExtractBearerToken(ARequest: TIdHTTPRequestInfo): string; +var + LAuth: string; +begin + LAuth := ARequest.RawHeaders.Values['Authorization']; + if LAuth.StartsWith('Bearer ', True) then + Result := Copy(LAuth, 8, MaxInt) + else + Result := ''; +end; + +function Authenticate(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Integer; +var + LToken, LTokenHash: string; + LQ: TFDQuery; + LExpires: TDateTime; +begin + Result := 0; + LToken := ExtractBearerToken(ARequest); + if LToken = '' then + begin + TJSONHelper.SendError(AResponse, 401, 'No token'); + raise ESessionRejected.Create('no token'); + end; + LTokenHash := SHA256Hex(LToken); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT user_id, expires_at FROM sessions WHERE token_hash = :th'; + LQ.ParamByName('th').AsString := LTokenHash; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 401, 'Invalid session'); + raise ESessionRejected.Create('invalid session'); + end; + Result := LQ.FieldByName('user_id').AsInteger; + // Read as TDateTime directly — FireDAC parses SQLite DATETIME columns + // internally; using AsString would round-trip through system locale. + LExpires := LQ.FieldByName('expires_at').AsDateTime; + finally + LQ.Free; + end; + + if (LExpires <> 0) and (LExpires < Now) then + begin + DeleteSessionByTokenHash(LTokenHash); + TJSONHelper.SendError(AResponse, 401, 'Session expired'); + raise ESessionRejected.Create('expired'); + end; + finally + DB.Unlock; + end; +end; + +procedure RequireCSRF(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; AUserId: Integer); +var + LSubmitted, LStored: string; + LQ: TFDQuery; +begin + if SameText(ARequest.Command, 'GET') then Exit; + + LSubmitted := ARequest.RawHeaders.Values['X-CSRF-Token']; + if LSubmitted = '' then + begin + TJSONHelper.SendError(AResponse, 403, 'Missing CSRF token'); + raise ESessionRejected.Create('missing csrf'); + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT csrf_token FROM sessions ' + + 'WHERE user_id = :uid AND expires_at > datetime(''now'') ' + + 'ORDER BY created_at DESC LIMIT 1'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 403, 'Invalid CSRF token'); + raise ESessionRejected.Create('no session'); + end; + LStored := LQ.FieldByName('csrf_token').AsString; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + if not ConstantTimeEquals(LStored, LSubmitted) then + begin + TJSONHelper.SendError(AResponse, 403, 'Invalid CSRF token'); + raise ESessionRejected.Create('csrf mismatch'); + end; +end; + +function CreateSession(AUserId: Integer; out AToken, ACSRFToken: string): Boolean; +var + LQ: TFDQuery; + LTokenHash, LExpires: string; +begin + AToken := RandomHex(32); + ACSRFToken := RandomHex(32); + LTokenHash := SHA256Hex(AToken); + // YYYY-MM-DD HH:NN:SS, +24h, server local time (api.php uses date() = local) + LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24)); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) ' + + 'VALUES (:uid, :th, :csrf, :exp)'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ParamByName('th').AsString := LTokenHash; + LQ.ParamByName('csrf').AsString := ACSRFToken; + LQ.ParamByName('exp').AsString := LExpires; + LQ.ExecSQL; + Result := True; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure DeleteSessionByTokenHash(const ATokenHash: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM sessions WHERE token_hash = :th'; + LQ.ParamByName('th').AsString := ATokenHash; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure DeleteAllUserSessions(AUserId: Integer); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM sessions WHERE user_id = :uid'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.StaticFiles.pas b/delphi-backend/Source/PM.StaticFiles.pas new file mode 100644 index 0000000..1d68932 --- /dev/null +++ b/delphi-backend/Source/PM.StaticFiles.pas @@ -0,0 +1,137 @@ +unit PM.StaticFiles; + +{ + Static file server with directory-traversal protection. + Serves Z:\password-manager\ (index.html, js/, css/) from the loopback server, + so the embedded TTMSFNCWebBrowser can navigate to http://127.0.0.1:PORT/index.html + and the password-manager UI runs entirely inside the Delphi exe. + + Same pattern as DeskInsight's Forms/UAIWorkbench.HTTPServer.pas Monaco server. +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, System.StrUtils, + IdCustomHTTPServer; + +type + TStaticFileServer = class + private + FRootDir: string; + function ResolveSafePath(const ARequestPath: string; out AFullPath: string): Boolean; + function MimeTypeFor(const AExt: string): string; + public + constructor Create(const ARootDir: string); + function TryServe(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + property RootDir: string read FRootDir; + end; + +var + StaticServer: TStaticFileServer; + +procedure InitStaticServer(const ARootDir: string); +procedure DoneStaticServer; + +implementation + +constructor TStaticFileServer.Create(const ARootDir: string); +begin + inherited Create; + FRootDir := TPath.GetFullPath(IncludeTrailingPathDelimiter(ARootDir)); +end; + +function TStaticFileServer.ResolveSafePath(const ARequestPath: string; + out AFullPath: string): Boolean; +var + LRelative, LCandidate: string; +begin + Result := False; + AFullPath := ''; + LRelative := ARequestPath; + + // Normalize: '/' or '' -> index.html + if (LRelative = '') or (LRelative = '/') then + LRelative := '/index.html'; + + // Strip leading slash, convert URL separators to OS separators + if (Length(LRelative) > 0) and (LRelative[1] = '/') then + Delete(LRelative, 1, 1); + LRelative := StringReplace(LRelative, '/', PathDelim, [rfReplaceAll]); + + // Reject obvious traversal attempts (defense in depth — TPath.GetFullPath + // resolves '..' but rejecting up front gives a clean 404) + if (Pos('..', LRelative) > 0) or (Pos(':', LRelative) > 0) then Exit; + + LCandidate := TPath.GetFullPath(TPath.Combine(FRootDir, LRelative)); + + // Critical check: the resolved path MUST be under FRootDir + if not LCandidate.StartsWith(FRootDir, True) then Exit; + if not TFile.Exists(LCandidate) then Exit; + + AFullPath := LCandidate; + Result := True; +end; + +function TStaticFileServer.MimeTypeFor(const AExt: string): string; +var + LExt: string; +begin + LExt := LowerCase(AExt); + if (LExt = '.html') or (LExt = '.htm') then Exit('text/html; charset=utf-8'); + if LExt = '.js' then Exit('application/javascript; charset=utf-8'); + if LExt = '.mjs' then Exit('application/javascript; charset=utf-8'); + if LExt = '.css' then Exit('text/css; charset=utf-8'); + if LExt = '.json' then Exit('application/json; charset=utf-8'); + if LExt = '.svg' then Exit('image/svg+xml'); + if LExt = '.png' then Exit('image/png'); + if LExt = '.jpg' then Exit('image/jpeg'); + if LExt = '.jpeg' then Exit('image/jpeg'); + if LExt = '.gif' then Exit('image/gif'); + if LExt = '.webp' then Exit('image/webp'); + if LExt = '.ico' then Exit('image/x-icon'); + if LExt = '.woff' then Exit('font/woff'); + if LExt = '.woff2' then Exit('font/woff2'); + if LExt = '.ttf' then Exit('font/ttf'); + if LExt = '.map' then Exit('application/json'); + if LExt = '.txt' then Exit('text/plain; charset=utf-8'); + Result := 'application/octet-stream'; +end; + +function TStaticFileServer.TryServe(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LFullPath, LExt: string; + LFS: TFileStream; +begin + Result := False; + if not SameText(ARequest.Command, 'GET') then Exit; + if not ResolveSafePath(ARequest.Document, LFullPath) then Exit; + + LExt := ExtractFileExt(LFullPath); + AResponse.ContentType := MimeTypeFor(LExt); + + // Stream the file — Indy will set Content-Length and free the stream. + LFS := TFileStream.Create(LFullPath, fmOpenRead or fmShareDenyWrite); + AResponse.ContentStream := LFS; + AResponse.FreeContentStream := True; + AResponse.ResponseNo := 200; + Result := True; +end; + +procedure InitStaticServer(const ARootDir: string); +begin + if StaticServer = nil then + StaticServer := TStaticFileServer.Create(ARootDir); +end; + +procedure DoneStaticServer; +begin + FreeAndNil(StaticServer); +end; + +initialization +finalization + DoneStaticServer; +end. diff --git a/delphi-backend/UMainForm.fmx b/delphi-backend/UMainForm.fmx new file mode 100644 index 0000000..5ddd177 --- /dev/null +++ b/delphi-backend/UMainForm.fmx @@ -0,0 +1,133 @@ +object MainForm: TMainForm + Left = 0 + Top = 0 + Caption = 'Password Manager - Delphi Backend' + ClientHeight = 720 + ClientWidth = 1100 + FormFactor.Width = 320 + FormFactor.Height = 480 + FormFactor.Devices = [Desktop] + OnCreate = FormCreate + OnDestroy = FormDestroy + OnCloseQuery = FormCloseQuery + DesignerMasterStyle = 0 + object PanelTop: TPanel + Align = Top + Size.Width = 1100.000000000000000000 + Size.Height = 56.000000000000000000 + Size.PlatformDefault = False + TabOrder = 0 + object lblPort: TLabel + Position.X = 16.000000000000000000 + Position.Y = 18.000000000000000000 + Size.Width = 40.000000000000000000 + Size.Height = 20.000000000000000000 + Size.PlatformDefault = False + TextSettings.Trimming = None + Text = 'Port:' + TabOrder = 0 + end + object edtPort: TEdit + Touch.InteractiveGestures = [LongTap, DoubleTap] + TabOrder = 1 + Text = '8765' + Position.X = 56.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + end + object btnStart: TButton + Position.X = 152.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 2 + Text = 'Start' + TextSettings.Trimming = None + OnClick = btnStartClick + end + object btnStop: TButton + Position.X = 240.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 3 + Text = 'Stop' + TextSettings.Trimming = None + OnClick = btnStopClick + end + object btnReload: TButton + Position.X = 328.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 4 + Text = 'Reload' + TextSettings.Trimming = None + OnClick = btnReloadClick + end + object btnToggleLog: TButton + Position.X = 416.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 90.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 5 + Text = 'Hide log' + TextSettings.Trimming = None + OnClick = btnToggleLogClick + end + object lblStatus: TLabel + Position.X = 520.000000000000000000 + Position.Y = 18.000000000000000000 + Size.Width = 560.000000000000000000 + Size.Height = 20.000000000000000000 + Size.PlatformDefault = False + TextSettings.Trimming = None + Text = 'Stopped' + TabOrder = 6 + end + end + object PanelLog: TPanel + Align = Bottom + Position.Y = 564.000000000000000000 + Size.Width = 1100.000000000000000000 + Size.Height = 156.000000000000000000 + Size.PlatformDefault = False + TabOrder = 1 + object Memo: TMemo + Touch.InteractiveGestures = [Pan, LongTap, DoubleTap] + DataDetectorTypes = [] + ReadOnly = True + TextSettings.Font.Family = 'Consolas' + Align = Client + Size.Width = 1100.000000000000000000 + Size.Height = 156.000000000000000000 + Size.PlatformDefault = False + TabOrder = 0 + Viewport.Width = 1096.000000000000000000 + Viewport.Height = 152.000000000000000000 + end + end + object Splitter: TSplitter + Align = Bottom + Cursor = crVSplit + MinSize = 20.000000000000000000 + Position.Y = 558.000000000000000000 + Size.Width = 1100.000000000000000000 + Size.Height = 6.000000000000000000 + Size.PlatformDefault = False + end + object WebBrowser: TTMSFNCWebBrowser + Align = Client + Size.Width = 1100.000000000000000000 + Size.Height = 502.000000000000000000 + Size.PlatformDefault = False + TabOrder = 3 + DesigntimeEnabled = False + end +end diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas new file mode 100644 index 0000000..239b257 --- /dev/null +++ b/delphi-backend/UMainForm.pas @@ -0,0 +1,325 @@ +unit UMainForm; + +interface + +uses + System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, + 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.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, + FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, + PM.HTTPServer, PM.Bridge; + +type + TMainForm = class(TForm) + PanelTop: TPanel; + btnStart: TButton; + btnStop: TButton; + lblStatus: TLabel; + edtPort: TEdit; + lblPort: TLabel; + btnToggleLog: TButton; + btnReload: TButton; + PanelLog: TPanel; + Memo: TMemo; + Splitter: TSplitter; + WebBrowser: TTMSFNCWebBrowser; + procedure FormCreate(Sender: TObject); + procedure FormDestroy(Sender: TObject); + procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); + procedure btnStartClick(Sender: TObject); + procedure btnStopClick(Sender: TObject); + procedure btnToggleLogClick(Sender: TObject); + procedure btnReloadClick(Sender: TObject); + private + FServer: TPMHTTPServer; + FBridge: TPMBridge; + FPendingURL: string; + FNavTimer: TTimer; + FNavAttempts: Integer; + FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses + // FormCloseQuery's minimize-to-tray intercept. + procedure LogLine(const AMsg: string); + procedure UpdateButtons; + procedure NavigateToVault; + procedure NavTimerTick(Sender: TObject); + // JS↔Delphi bridge + procedure WebBrowserBeforeNavigate(Sender: TObject; + var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams); + procedure HandleBridgeCommand(const ACmd, AParams: string); + procedure BridgeSystemLock; + procedure BridgeTrayRestore; + procedure BridgeLockRequest; + procedure BridgeQuit; + end; + +var + MainForm: TMainForm; + +implementation + +{$R *.fmx} + +procedure TMainForm.FormCreate(Sender: TObject); +begin + FServer := TPMHTTPServer.Create; + FServer.OnLog := LogLine; + + FBridge := TPMBridge.Create(Self); + FBridge.OnSystemLock := BridgeSystemLock; + FBridge.OnTrayRestore := BridgeTrayRestore; + FBridge.OnLockRequest := BridgeLockRequest; + FBridge.OnQuit := BridgeQuit; + + // Wire the cmd:// bridge before any navigation happens. + WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate; + + // Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores + // Navigate() calls until Edge Chromium finishes its async init (~1-2s). + // We wait 1.5 s after Start, then issue a SINGLE Navigate — no retry loop + // (retrying caused the loaded page to reload every interval, making icons + // flash). If Edge needed longer than 1.5 s, user clicks Reload. + FNavTimer := TTimer.Create(Self); + FNavTimer.Interval := 1500; + FNavTimer.Enabled := False; + FNavTimer.OnTimer := NavTimerTick; + + UpdateButtons; + LogLine('Password Manager - Delphi backend ready.'); + LogLine('Click Start to launch server + embedded web vault.'); +end; + +procedure TMainForm.FormDestroy(Sender: TObject); +begin + FBridge.Free; + FServer.Free; +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. + if FQuitting then Exit; + + // Otherwise: minimize to tray on close instead of quitting, so the vault + // stays available without the dev-panel being visible. + // When the server is stopped, allow normal close — there's no vault to + // keep alive in the background. + if FServer.Active then + begin + CanClose := False; + FBridge.MinimizeToTray; + LogLine('Minimized to tray. Click the tray icon to restore.'); + end; +end; + +procedure TMainForm.LogLine(const AMsg: string); +begin + // Synchronize handles both cases: if already on main thread, runs inline; + // otherwise marshals. Avoids overload resolution issues with TThread.Queue. + TThread.Synchronize(nil, + procedure + begin + Memo.Lines.Add(FormatDateTime('hh:nn:ss', Now) + ' ' + AMsg); + Memo.GoToTextEnd; + end); +end; + +procedure TMainForm.UpdateButtons; +begin + btnStart.Enabled := not FServer.Active; + btnStop.Enabled := FServer.Active; + btnReload.Enabled := FServer.Active; + edtPort.Enabled := not FServer.Active; + if FServer.Active then + lblStatus.Text := 'Running on http://127.0.0.1:' + edtPort.Text + else + lblStatus.Text := 'Stopped'; +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 + FNavTimer.Enabled := True; +end; + +procedure TMainForm.NavTimerTick(Sender: TObject); +begin + FNavTimer.Enabled := False; // one-shot + if FPendingURL = '' then Exit; + LogLine('Navigating to: ' + FPendingURL); + WebBrowser.Navigate(FPendingURL); + FPendingURL := ''; +end; + +procedure TMainForm.btnStartClick(Sender: TObject); +var + LPort: Integer; +begin + LPort := StrToIntDef(edtPort.Text, 8765); + try + FServer.Start(LPort); + 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); + end; + end; +end; + +procedure TMainForm.btnStopClick(Sender: TObject); +begin + FServer.Stop; + UpdateButtons; + FPendingURL := ''; + FNavTimer.Enabled := False; + WebBrowser.Navigate('about:blank'); +end; + +procedure TMainForm.btnReloadClick(Sender: TObject); +begin + if FServer.Active then NavigateToVault; +end; + +procedure TMainForm.btnToggleLogClick(Sender: TObject); +begin + PanelLog.Visible := not PanelLog.Visible; + Splitter.Visible := PanelLog.Visible; + if PanelLog.Visible then + btnToggleLog.Text := 'Hide log' + else + btnToggleLog.Text := 'Show log'; +end; + +// --------------------------------------------------------------------------- +// JS↔Delphi bridge +// --------------------------------------------------------------------------- + +procedure TMainForm.WebBrowserBeforeNavigate(Sender: TObject; + var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams); +var + URL, Cmd, ParamStr: string; + P: Integer; +begin + URL := Params.URL; + if not URL.StartsWith('cmd://') then Exit; + + Params.Cancel := True; + URL := URL.Substring(6); // strip 'cmd://' + + P := Pos('?', URL); + if P > 0 then + begin + Cmd := Copy(URL, 1, P - 1); + ParamStr := Copy(URL, P + 1, MaxInt); + end + else + begin + Cmd := URL; + ParamStr := ''; + end; + + // Defer to avoid WebView2 re-entrance issues. + TThread.ForceQueue(nil, + procedure + begin + HandleBridgeCommand(Cmd, ParamStr); + end); +end; + +procedure TMainForm.HandleBridgeCommand(const ACmd, AParams: string); + + function GetParam(const AKey: string): string; + var + Parts: TArray; + Part, K, V: string; + EqPos: Integer; + begin + Result := ''; + Parts := AParams.Split(['&']); + for Part in Parts do + begin + EqPos := Pos('=', Part); + if EqPos > 0 then + begin + K := Copy(Part, 1, EqPos - 1); + V := Copy(Part, EqPos + 1, MaxInt); + if SameText(K, AKey) then + begin + Result := TNetEncoding.URL.Decode(V); + Exit; + end; + end; + end; + end; + +var + LText: string; + LClearMs: Integer; +begin + if ACmd = 'clipboard/copy' then + begin + LText := GetParam('text'); + LClearMs := StrToIntDef(GetParam('clear'), 30000); + FBridge.SecureClipboard.SetText(LText, LClearMs); + LogLine(Format('Secure clipboard set (auto-clear in %ds)', [LClearMs div 1000])); + end + + else if ACmd = 'clipboard/clear' then + begin + FBridge.SecureClipboard.Clear; + LogLine('Clipboard cleared by JS request'); + 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()'); +end; + +procedure TMainForm.BridgeTrayRestore; +begin + FBridge.RestoreFromTray; + // Notify the JS layer: the UI may want to reset the auto-lock timer, + // refresh state, or show a "welcome back" toast. + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.onTrayRestore==="function")Bridge.onTrayRestore()'); + LogLine('Restored from tray'); +end; + +procedure TMainForm.BridgeLockRequest; +begin + // User picked "Lock vault" from the tray menu. Trigger lockVault() in + // JS — same path as the WTS_SESSION_LOCK auto-lock. + LogLine('Lock requested from tray menu'); + WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()'); +end; + +procedure TMainForm.BridgeQuit; +begin + // Re-entry guard: if Quit was already requested, ignore further calls. + if FQuitting then Exit; + + LogLine('>>> BridgeQuit invoked (Quit from tray menu)'); + FQuitting := True; + // Restore the form first so the tray icon goes away and FormDestroy + // executes from a normal (non-hidden) state. RestoreFromTray also + // deletes the tray icon. + FBridge.RestoreFromTray; + Application.Terminate; +end; + +end. diff --git a/delphi-backend/assets/BuildAssets.cmd b/delphi-backend/assets/BuildAssets.cmd new file mode 100644 index 0000000..c9ed4c3 --- /dev/null +++ b/delphi-backend/assets/BuildAssets.cmd @@ -0,0 +1,21 @@ +@echo off +REM Wrapper around BuildAssets.ps1 — captures stdout+stderr to build.log +REM so Delphi's pre-build event can call this without worrying about +REM cmd.exe '&' escape rules. +REM +REM Usage in Delphi pre-build event: +REM "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + +set "SCRIPT_DIR=%~dp0" +set "LOG=%SCRIPT_DIR%build.log" + +echo === BuildAssets.cmd at %DATE% %TIME% === > "%LOG%" +echo Calling PowerShell... >> "%LOG%" + +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%BuildAssets.ps1" >> "%LOG%" 2>&1 +set RC=%ERRORLEVEL% + +echo. >> "%LOG%" +echo Exit code: %RC% >> "%LOG%" + +exit /b %RC% diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 new file mode 100644 index 0000000..6ecad89 --- /dev/null +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -0,0 +1,135 @@ +# BuildAssets.ps1 +# Generates assets.rc + assets.res containing the static web assets +# (index.html, js/*, css/*) so they can be linked into PMServer.exe as +# Win32 RCDATA resources. +# +# Workflow: +# 1. Edit Z:\password-manager\index.html / js\app.js / css\style.css +# 2. Run this script (or set it as pre-build event in Delphi) +# 3. Build PMServer.dpr in Delphi +# 4. Run - exe is autonomous, no external files needed at runtime +# +# Resource naming: URL path '/js/app.js' -> resource 'JS_APP_JS' +# - strip leading '/' +# - replace '/' '\' '.' '-' with '_' +# - uppercase + +[CmdletBinding()] +param( + [string] $WebRoot = '', + [string] $OutRC = '', + [string] $OutRES = '', + [string] $OutInc = '' +) + +$ErrorActionPreference = 'Stop' + +# Resolve $PSScriptRoot — may be empty depending on invocation context. +# Fallback to the script's own file path. +$ScriptDir = $PSScriptRoot +if (-not $ScriptDir) { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition } +if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path } + +# Apply param defaults now that we have a valid script dir +if (-not $WebRoot) { $WebRoot = Join-Path $ScriptDir '..\..' } +if (-not $OutRC) { $OutRC = Join-Path $ScriptDir 'assets.rc' } +if (-not $OutRES) { $OutRES = Join-Path $ScriptDir 'assets.res' } +if (-not $OutInc) { $OutInc = Join-Path $ScriptDir 'assets.inc' } + +# All output goes to stdout/stderr. When invoked via BuildAssets.cmd the +# wrapper captures both streams into build.log; when run interactively +# everything shows in the terminal. + +function Log { param([string] $msg) Write-Host $msg } + +try { + +$WebRoot = (Resolve-Path $WebRoot).Path +Log "Web root: $WebRoot" + +# Whitelist patterns (relative to WebRoot). Add more here when needed. +# Use exact paths (not wildcards) to avoid embedding *-legacy.* backups. +$patterns = @( + 'index.html', + 'js\app.js', + 'css\style.css' +) + +# Discover files +$files = @() +foreach ($p in $patterns) { + $found = Get-ChildItem -Path (Join-Path $WebRoot $p) -File -ErrorAction SilentlyContinue + foreach ($f in $found) { + $rel = $f.FullName.Substring($WebRoot.Length).TrimStart('\','/') + $files += [pscustomobject]@{ + FullPath = $f.FullName + Relative = $rel + UrlPath = '/' + ($rel -replace '\\','/') + ResName = ($rel -replace '[\\/\.\-]','_').ToUpperInvariant() + } + } +} + +if ($files.Count -eq 0) { + throw "No assets found under $WebRoot. Check the patterns." +} + +Log "Embedding $($files.Count) file(s):" +$files | ForEach-Object { Log (" " + $_.UrlPath + " -> " + $_.ResName) } + +# --- Generate assets.rc ------------------------------------------------------- +$rc = New-Object System.Text.StringBuilder +[void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.') +[void]$rc.AppendLine('#pragma code_page(65001)') +[void]$rc.AppendLine('') +foreach ($f in $files) { + # brcc32 accepts forward slashes; backslashes need to be doubled in C strings + $rcPath = $f.FullPath -replace '\\','\\' + [void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"") +} +[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII) +Log "Wrote $OutRC" + +# --- Generate assets.inc (Pascal include with manifest) ----------------------- +# Compile-time mapping URL -> resource name, consumed by PM.EmbeddedAssets.pas. +$nl = [Environment]::NewLine +$inc = New-Object System.Text.StringBuilder +[void]$inc.Append('// Auto-generated by BuildAssets.ps1 - do not edit by hand.' + $nl) +[void]$inc.Append('const' + $nl) +[void]$inc.Append(' EMBEDDED_ASSET_COUNT = ' + $files.Count + ';' + $nl) +[void]$inc.Append(' EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (' + $nl) +for ($i = 0; $i -lt $files.Count; $i++) { + $f = $files[$i] + if ($i -eq $files.Count - 1) { $sep = '' } else { $sep = ',' } + $line = " (UrlPath: '" + $f.UrlPath + "'; ResName: '" + $f.ResName + "')" + $sep + $nl + [void]$inc.Append($line) +} +[void]$inc.Append(' );' + $nl) +[System.IO.File]::WriteAllText($OutInc, $inc.ToString(), [System.Text.Encoding]::UTF8) +Log "Wrote $OutInc" + +# --- Compile to .res via brcc32 ---------------------------------------------- +$brcc = $null +if ($env:BDS) { + $candidate = Join-Path $env:BDS 'bin\brcc32.exe' + if (Test-Path $candidate) { $brcc = $candidate } +} +if (-not $brcc) { + $cmd = Get-Command brcc32.exe -ErrorAction SilentlyContinue + if ($cmd) { $brcc = $cmd.Source } +} +if (-not $brcc) { + throw "brcc32.exe not found. Set the BDS environment variable to your Delphi install root, or add brcc32.exe to PATH." +} +Log "Using $brcc" + +& $brcc -32 -fo "$OutRES" "$OutRC" +if ($LASTEXITCODE -ne 0) { throw "brcc32 failed with exit code $LASTEXITCODE" } +Log "Wrote $OutRES" +Log "OK" + +} catch { + Log "FATAL: $($_.Exception.Message)" + Log "Stack: $($_.ScriptStackTrace)" + exit 1 +} diff --git a/delphi-backend/assets/assets.inc b/delphi-backend/assets/assets.inc new file mode 100644 index 0000000..3dc34cd --- /dev/null +++ b/delphi-backend/assets/assets.inc @@ -0,0 +1,8 @@ +// Auto-generated by BuildAssets.ps1 - do not edit by hand. +const + EMBEDDED_ASSET_COUNT = 3; + EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = ( + (UrlPath: '/index.html'; ResName: 'INDEX_HTML'), + (UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'), + (UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS') + ); diff --git a/delphi-backend/assets/assets.rc b/delphi-backend/assets/assets.rc new file mode 100644 index 0000000..2382968 --- /dev/null +++ b/delphi-backend/assets/assets.rc @@ -0,0 +1,6 @@ +// Auto-generated by BuildAssets.ps1 - do not edit by hand. +#pragma code_page(65001) + +INDEX_HTML RCDATA "Z:\\password-manager\\index.html" +JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js" +CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css" diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res new file mode 100644 index 0000000000000000000000000000000000000000..0c78b52dd6e614824bfce7c52ae10f6e9bb8f8f6 GIT binary patch literal 146968 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.charCodeAt(0)).buffer; } -let view = localStorage.getItem('vaultView') || 'grid'; -let detailIndex = 0; -let showView = localStorage.getItem('showViewBtn') !== 'false'; -let showMail = localStorage.getItem('showEmail') !== 'false'; -let dark = localStorage.getItem('darkTheme') !== 'false'; -let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5'); -let order = JSON.parse(localStorage.getItem('entryOrder') || '[]'); -let selectedFolder = localStorage.getItem('selectedFolder') || 'All'; -let entries = []; -let folders = ['All']; -let genPwdVal = ''; -let cryptoKey = null; -let searchQuery = ''; -let idleT, warnT, countT; -let draggedId = null; -let showTrash = false; -let selectedIds = new Set(); -let lastSelectedId = null; -let arrowAnchor = -1; -let arrowFocus = -1; -let rectState = { active: false, startX: 0, startY: 0, el: null, started: false }; +/* ============================================================ + Vault — UI V2 app.js + Clean state + render layer. Crypto helpers preserved verbatim + from legacy. Backend (PHP api.php / Delphi loopback) is detected + from URL path. + ============================================================ */ -// ==================== SOUND ==================== -let soundEnabled = localStorage.getItem('soundEnabled') !== 'false'; -let audioCtx = null; +// ---- Backend detection ------------------------------------- +const API = (location.pathname.indexOf('/password-manager/') === 0) + ? '/password-manager/api.php' + : ''; -function getAudioContext() { - if (!audioCtx) { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); +// ---- Delphi native bridge ---------------------------------- +// Active only when running inside the Delphi-hosted WebView2 (API === ''). +// Falls back to navigator.clipboard for the standalone PHP frontend. +const Bridge = (() => { + const active = (API === ''); + + // Navigate to a cmd:// URL — intercepted synchronously by + // TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs. + function cmd(path) { + window.location.href = path; } - return audioCtx; -} -function playTone(freq, duration, type = 'sine', volume = 0.08) { - if (!soundEnabled) return; - try { - const ctx = getAudioContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = type; - osc.frequency.setValueAtTime(freq, ctx.currentTime); - gain.gain.setValueAtTime(volume, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(ctx.currentTime); - osc.stop(ctx.currentTime + duration); - } catch (e) { /* ignore */ } -} + return { + active, -function playSound(type) { - if (!soundEnabled) return; - switch (type) { - case 'click': playTone(800, 0.08, 'sine', 0.06); break; - case 'success': - playTone(523, 0.1, 'sine', 0.1); - setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 100); - setTimeout(() => playTone(784, 0.15, 'sine', 0.1), 200); - break; - case 'error': - playTone(200, 0.2, 'square', 0.06); - setTimeout(() => playTone(150, 0.3, 'square', 0.06), 150); - break; - case 'delete': - playTone(150, 0.15, 'triangle', 0.08); - break; - case 'copy': - playTone(1200, 0.05, 'sine', 0.07); - break; - case 'generate': - playTone(440, 0.05, 'sine', 0.05); - setTimeout(() => playTone(554, 0.05, 'sine', 0.05), 60); - setTimeout(() => playTone(659, 0.05, 'sine', 0.05), 120); - setTimeout(() => playTone(880, 0.1, 'sine', 0.07), 180); - break; - case 'open': - playTone(600, 0.12, 'sine', 0.06); - setTimeout(() => playTone(800, 0.1, 'sine', 0.06), 80); - break; - case 'close': - playTone(800, 0.08, 'sine', 0.05); - setTimeout(() => playTone(600, 0.1, 'sine', 0.05), 80); - break; - case 'login': - playTone(523, 0.1, 'sine', 0.08); - setTimeout(() => playTone(659, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(784, 0.2, 'sine', 0.1), 200); - break; - case 'register': - playTone(440, 0.1, 'sine', 0.08); - setTimeout(() => playTone(554, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(659, 0.15, 'sine', 0.1), 200); - break; - } -} - -function toggleSound() { - soundEnabled = !soundEnabled; - localStorage.setItem('soundEnabled', soundEnabled); - if (soundEnabled) playTone(440, 0.05); - syncSettingsUI(); -} - -// ==================== TOAST ==================== -function toast(m, t, action) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.innerHTML = '' + m + ''; if (action) { const btn = document.createElement('button'); btn.className = 'toast-action'; btn.textContent = action.label; btn.onclick = function(e) { e.stopPropagation(); action.cb(); d.remove(); }; d.appendChild(btn); c.appendChild(d); } else { c.appendChild(d); setTimeout(() => d.remove(), 3000); } } -function showZigzagToast(elem, msg, type) { - const t = document.createElement('div'); - t.className = 'toast-zigzag ' + (type || 'success'); - t.textContent = msg; - document.body.appendChild(t); - const r = elem.getBoundingClientRect(); - t.style.left = r.left + 'px'; - t.style.top = r.top + 'px'; - setTimeout(() => t.remove(), 1500); -} - -// ==================== THEME ==================== -function applyTheme() { document.body.classList.toggle('light', !dark); } -function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); syncSettingsUI(); playSound('click'); } - -// ==================== CRYPTO ==================== -function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); } -async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; } -async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } } -async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); } -async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } } - -// ==================== AUTO-LOCK ==================== -function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); } -function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } } - -// ==================== USERNAME ==================== -function saveUsername() { const f = document.getElementById('addUsername'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); } -function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('addUsername'); if (s && f) f.value = s; } - -// ==================== FOLDERS ==================== -async function loadFolders() { - if (!token) return; - try { - const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } }); - if (r.ok) { - const data = await r.json(); - if (Array.isArray(data)) { - folders = data.filter(f => typeof f === 'string'); - if (!folders.includes('All')) folders.unshift('All'); - } else { - folders = ['All']; - } - } else { - folders = ['All']; - } - } catch (e) { - folders = ['All']; - } -} - -async function addFolderToServer(name) { - try { - const r = await fetch(API + '/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ name }) - }); - if (r.ok) { await loadFolders(); return true; } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); - return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } -} - -async function deleteFolderFromServer(name) { - try { - const r = await fetch(API + '/folders/' + encodeURIComponent(name), { - method: 'DELETE', - headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } - }); - if (r.ok) { - await loadFolders(); - if (selectedFolder === name) { selectedFolder = 'All'; localStorage.setItem('selectedFolder', 'All'); } + // Copy text to clipboard, excluded from Win+V history. + // clearAfterMs: Delphi auto-clears after this many ms (0 = never). + // Returns true when the bridge handled the copy, false as fallback signal. + copySecure(text, clearAfterMs = 30000) { + if (!active) return false; + cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) + + '&clear=' + clearAfterMs); return true; - } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); + }, + + // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK. + // Exposed as window.Bridge.onSystemLock so the Delphi side can call it, + // but the actual lock is triggered directly via lockVault() in Delphi. + onSystemLock() { + if (typeof lockVault === 'function') lockVault(); + }, + + // Called by Delphi (ExecuteJavaScript) when the user restores the + // window from the tray icon. Useful for resetting auto-lock state + // and giving a subtle visual cue. + onTrayRestore() { + // If the user has been away long enough that the auto-lock + // should fire, lockVault was already called by either WTS lock + // or the local idle timer — so we only reset here when still + // unlocked. + if (state.cryptoKey && !state.locked) { + if (typeof resetAutoLock === 'function') resetAutoLock(); + if (typeof toast === 'function') toast('Welcome back'); + } + }, + }; +})(); + +// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it. +window.Bridge = Bridge; + +// ---- Global state ------------------------------------------ +const state = { + token: sessionStorage.getItem('authToken') || '', + csrf: sessionStorage.getItem('csrfToken') || '', + salt: sessionStorage.getItem('salt') || '', + username: sessionStorage.getItem('username') || '', + cryptoKey: null, + entries: [], + trashed: [], + folders: ['All'], + view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' + search: '', + selectedId: null, + theme: localStorage.getItem('theme') || 'dark', + locked: false, // true after user clicks Lock (token still valid server-side) + autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), + askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true + maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false + compactActions: localStorage.getItem('compactActions') === '1', // default false + viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' + checked: new Set(), // entry IDs checked for batch operations +}; + +// ============================================================ +// CRYPTO (preserved from legacy app.js — DO NOT TOUCH) +// ============================================================ + +async function deriveKey(pwd, saltHex) { + const enc = new TextEncoder(); + const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); + // saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes. + const sb = enc.encode(saltHex); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, + km, + { name: 'AES-GCM', length: 256 }, + true, ['encrypt', 'decrypt'] + ); +} + +async function encryptPwd(plain) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, state.cryptoKey, + new TextEncoder().encode(plain) + ); + return { + encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), + iv: btoa(String.fromCharCode(...iv)), + }; +} + +async function decryptPwd(encB64, ivB64) { + try { + const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc); + return new TextDecoder().decode(dec); + } catch (e) { + return '[ERROR]'; + } +} + +async function persistCryptoKey() { + const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); + sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); +} + +async function restoreCryptoKey() { + const saved = sessionStorage.getItem('cryptoKey'); + if (!saved) return false; + try { + const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); + state.cryptoKey = await crypto.subtle.importKey( + 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] + ); + return true; + } catch (e) { return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } + } } -function folderColor(name) { - if (name === 'All') return ''; - let hash = 0; - for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); - const hue = ((hash % 360) + 360) % 360; - return `style="--chip-color:hsl(${hue},60%,55%)"`; -} -function renderFolders() { - const bar = document.getElementById('foldersBar'); - if (!bar) return; - entries.forEach(e => { if (!e.folder || typeof e.folder !== 'string') e.folder = 'All'; }); - const counts = {}; - entries.forEach(e => { const f = e.folder; counts[f] = (counts[f] || 0) + 1; }); - let html = ''; - folders.forEach(f => { - if (!f) return; - const count = counts[f] || 0; - const color = folderColor(f); - html += `📁 ${esc(f)}${count}${f !== 'All' ? `` : ''}`; - }); - html += ``; - bar.innerHTML = html; - // Make folders drop targets for moving entries - bar.querySelectorAll('.folder-chip[data-folder]').forEach(chip => { - chip.addEventListener('dragover', e => { e.preventDefault(); chip.classList.add('drag-over'); }); - chip.addEventListener('dragleave', () => chip.classList.remove('drag-over')); - chip.addEventListener('drop', async function(e) { - e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const entry = entries.find(x => x.id === id); - if (!entry) return; - const folder = this.dataset.folder; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - let moved = 0; - for (const sid of ids) { - const e2 = entries.find(x => x.id === sid); - if (!e2 || e2.folder === folder) continue; - const enc = await encryptPwd(e2.password); - const r = await fetch(API + '/entries/' + sid, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e2.site, username: e2.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - if (r.ok) { e2.folder = folder; moved++; } - } - renderFolders(); - render(); - if (moved) playSound('success'); - }); - }); +// ============================================================ +// HTTP HELPERS +// ============================================================ + +function authHeaders(extra) { + const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {}); + if (state.csrf) h['X-CSRF-Token'] = state.csrf; + return h; } - - -function selectFolder(f) { - selectedFolder = f; - localStorage.setItem('selectedFolder', f); - renderFolders(); - populateFolderSelects(); - render(); - playSound('click'); +async function api(path, opts) { + opts = opts || {}; + const r = await fetch(API + path, opts); + let body = null; + try { body = await r.json(); } catch (e) { body = {}; } + if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); + return body; } -function showAddFolderModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📁 New Folder

`; - document.body.appendChild(overlay); - document.getElementById('cancelAddFolder').onclick = () => overlay.remove(); - document.getElementById('confirmAddFolder').onclick = async () => { - const name = document.getElementById('newFolderName').value.trim(); - if (!name) { toast('Enter a name', 'error'); return; } - const ok = await addFolderToServer(name); - if (ok) { renderFolders(); populateFolderSelects(); overlay.remove(); toast('📁 Folder created!'); playSound('success'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); +// ============================================================ +// TOAST +// ============================================================ + +function toast(msg, type) { + type = type || 'success'; + const container = $('#toastContainer'); + const t = document.createElement('div'); + t.className = 'toast is-' + type; + t.textContent = msg; + container.appendChild(t); + setTimeout(() => t.remove(), 2800); } -function showDeleteFolderConfirm(folderName) { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

🗑️ Delete Folder

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

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

🔑 One more step

Enter your master password to unlock the vault

`; - document.body.appendChild(overlay); - document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value); - overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } }); - }); - const m = document.querySelector('.custom-modal-overlay.show'); - if (m) m.remove(); - cryptoKey = await deriveKey(mp, d.salt); - persistCryptoKey(); - } - await loadFolders(); - toast('✅ Biometric login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } -} -async function login() { - const u = document.getElementById('loginUsername').value.trim(); - const p = document.getElementById('loginPassword').value; - if (!u || !p) { toast('Fill all fields', 'error'); return; } - document.getElementById('loginBtn').disabled = true; - localStorage.setItem('savedLoginUser', u); - try { - const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } + state.token = r.token; + state.csrf = r.csrfToken; + state.salt = r.salt; + state.username = u; + sessionStorage.setItem('authToken', state.token); + sessionStorage.setItem('csrfToken', state.csrf); + sessionStorage.setItem('salt', state.salt); + sessionStorage.setItem('username', state.username); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + toast('Welcome back, ' + u); + await enterApp(); + } catch (err) { + toast(err.message, 'error'); + } finally { + $('#loginBtn').disabled = false; + } } -async function register() { - const u = document.getElementById('regUsername').value.trim(); - const p = document.getElementById('regPassword').value; - if (u.length < 3) { toast('Username min 3', 'error'); return; } - if (p.length < 8) { toast('Password min 8', 'error'); return; } - document.getElementById('registerBtn').disabled = true; +async function doRegister(e) { + e && e.preventDefault(); + const u = $('#regUsername').value.trim(); + const p = $('#regPassword').value; + if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error'); + $('#registerBtn').disabled = true; try { - const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Created!'); - playSound('register'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('registerBtn').disabled = false; } + const r = await api('/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, masterPassword: p }), + }); + state.token = r.token; + state.csrf = r.csrfToken; + state.salt = r.salt; + state.username = u; + sessionStorage.setItem('authToken', state.token); + sessionStorage.setItem('csrfToken', state.csrf); + sessionStorage.setItem('salt', state.salt); + sessionStorage.setItem('username', state.username); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + toast('Vault created'); + await enterApp(); + } catch (err) { + toast(err.message, 'error'); + } finally { + $('#registerBtn').disabled = false; + } } async function doLogout() { - saveUsername(); - if (token) { - try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {} - } - clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); - document.getElementById('idleWarning').classList.remove('show'); - token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false; + try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {} sessionStorage.clear(); - document.getElementById('authSection').classList.remove('hidden'); - document.getElementById('vaultSection').classList.add('hidden'); - document.getElementById('loginPassword').value = ''; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; - document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); + state.token = ''; state.csrf = ''; state.salt = ''; state.username = ''; + state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All']; + state.locked = false; + showAuth(); + $('#loginUsername').value = ''; + $('#loginPassword').value = ''; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = ''; } -function showVault() { - document.getElementById('authSection').classList.add('hidden'); - document.getElementById('vaultSection').classList.remove('hidden'); - document.getElementById('currentUser').textContent = '👤 ' + curUser; - //document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; - document.getElementById('autoLockTimer').value = lockMin; - loadUsername(); - renderFolders(); - populateFolderSelects(); - syncSettingsUI(); - document.querySelectorAll('.fab').forEach(b => b.classList.remove('hidden')); - resetIdle(); +// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory +// crypto key. On unlock, /reauth validates the master password and we re-derive. +function lockVault() { + sessionStorage.removeItem('cryptoKey'); + state.cryptoKey = null; + state.entries = []; + state.trashed = []; + state.locked = true; + showAuth(); + $('#loginUsername').value = state.username; + $('#loginUsername').readOnly = true; + $('#authHint').textContent = 'Vault locked — enter master password to unlock'; + $('#loginPassword').value = ''; + $('#loginPassword').focus(); } -// ==================== ENTRIES ==================== -function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; } - -async function loadEntries(q) { +// Unlock flow: validate master pw via /reauth (which uses current session), +// then re-derive the crypto key locally without rotating session/csrf. +async function doUnlock(p) { try { - let url = API + '/entries?deleted=' + (showTrash ? '1' : '0'); - if (q) url += '&search=' + encodeURIComponent(q); - const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }); - if (r.ok) { - const raw = await r.json(); - entries = []; - for (const e of raw) { - if (e.encryption_method === 'client') { - const pw = await decryptPwd(e.encrypted_password, e.iv); - entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } else { - entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } - } - entries = applyOrder(entries); - entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); - document.getElementById('connectionStatus').textContent = '🟢 Connected'; - document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)'; - renderFolders(); - populateFolderSelects(); - render(); - } else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); } - } catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); } -} - -function getFilteredEntries(noFolder) { - if (showTrash) return entries; - if (noFolder || selectedFolder === 'All') return entries; - return entries.filter(e => (e.folder || 'All') === selectedFolder); -} - -function getGridCols() { - const c = document.getElementById('entriesContainer'); - if (!c || !c.firstElementChild) return 1; - const w = c.firstElementChild.offsetWidth; - const gap = parseInt(getComputedStyle(c).columnGap) || 0; - return Math.max(1, Math.round(c.offsetWidth / (w + gap))); -} - -// ==================== RENDER ==================== -function render() { - const c = document.getElementById('entriesContainer'); - c.className = ''; - if (showTrash) c.classList.add('trash-view'); - c.classList.add(view + '-view'); - const filtered = getFilteredEntries(); - if (!filtered.length) { c.innerHTML = '
' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '
'; return; } - if (view === 'table') { - let h = '' + (showMail ? '' : '') + '' + (!showTrash ? '' : '') + ''; - filtered.forEach(e => { - const sel = selectedIds.has(e.id); - h += '' + - '' + - '' + - (showMail ? '' : '') + - ''; - if (!showTrash) { - h += '' + - ''; + await api('/reauth', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ masterPassword: p }), + }); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + state.locked = false; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = ''; + toast('Unlocked'); + await enterApp(); + return true; + } catch (err) { + if (err.message === 'Invalid password') { + toast('Wrong master password', 'error'); } else { - h += '' + - ''; + // session expired — fall back to full login + sessionStorage.clear(); + state.token = ''; state.csrf = ''; state.salt = ''; + state.locked = false; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = 'Session expired, please sign in again'; + toast('Session expired', 'warning'); } - h += ''; - }); - h += '
SiteUserPasswordFolderDeletedActions
' + (e.favorite ? '⭐' : '') + '🌐 ' + highlightText(e.site, searchQuery) + '👤 ' + highlightText(e.username, searchQuery) + '••••••••📁 ' + esc(e.folder || 'All') + '' + - ' ' + - ' ' + - ' ' + - '' + - '🗑️ ' + timeAgo(e.deleted_at) + '' + - ' ' + - '' + - '
'; - c.innerHTML = h; - } else if (view === 'grouped') { - c.innerHTML = groupedC(getFilteredEntries(true)); - } else if (view === 'detail') { - c.innerHTML = detailC(getFilteredEntries(view === 'grouped')); - } else { - c.innerHTML = filtered.map(e => { - if (view === 'grid' || view === 'card') return gridC(e); - if (view === 'compact') return compC(e); - return listC(e); - }).join(''); - } - attachEvents(); - setupDrag(); -} - -function gridC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + '
'; - html += '
📁 ' + esc(e.folder || 'All') + '
'; - if (!showTrash) { - html += '
••••••••
' + - '
'; - } else { - html += '
🗑️ ' + timeAgo(e.deleted_at) + '
'; - } - html += '
'; - return html; -} -function listC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - return html; -} -function compC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '🌐 ' + highlightText(e.site, searchQuery) + ''; - if (showMail) html += '👤 ' + highlightText(e.username, searchQuery) + ''; - if (!showTrash) { - html += '📁 ' + esc(e.folder || 'All') + ''; - html += '••••••••'; - html += ''; - } else { - html += '🗑️ ' + timeAgo(e.deleted_at) + ''; - } - html += '
'; - return html; -} - -function groupedC(list) { - const groups = {}; - list.forEach(e => { - const f = e.folder || 'All'; - if (!groups[f]) groups[f] = []; - groups[f].push(e); - }); - let html = ''; - for (const [folder, items] of Object.entries(groups)) { - html += '
📁 ' + esc(folder) + ' ' + items.length + '
'; - items.forEach(e => { - const sel = selectedIds.has(e.id); - html += '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - }); - } - return html; -} - -function detailC(list) { - if (detailIndex >= list.length) detailIndex = 0; - if (detailIndex < 0) detailIndex = list.length - 1; - const e = list[detailIndex]; - const hasPrev = detailIndex > 0, hasNext = detailIndex < list.length - 1; - const sel = selectedIds.has(e.id); - let html = '
'; - html += ''; - html += '' + (detailIndex + 1) + ' of ' + list.length + ''; - html += ''; - html += '
'; - html += '
'; - html += '
Site🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
Username👤 ' + highlightText(e.username, searchQuery) + '
'; - if (!showTrash) { - html += '
Password••••••••
'; - html += '
Folder📁 ' + esc(e.folder || 'All') + '
'; - html += '
' + - '' + - '' + - '' + - '' + - '
'; - } else { - html += '
Deleted🗑️ ' + timeAgo(e.deleted_at) + '
'; - html += '
' + - '' + - '' + - '
'; - } - html += '
'; - return html; -} - -function goDetail(dir) { - const list = getFilteredEntries(); - detailIndex += dir; - if (detailIndex < 0) detailIndex = list.length - 1; - if (detailIndex >= list.length) detailIndex = 0; - render(); -} - -// ==================== EVENTS ==================== -function showConfirm(btn, message, callback) { - const id = btn.dataset.id; - const existing = document.querySelector('.custom-confirm'); - if (existing) existing.remove(); - - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - document.body.appendChild(confirm); - - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - confirm.offsetWidth + rect.width; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - - const cleanup = () => { - confirm.remove(); - document.removeEventListener('keydown', keyHandler); - }; - - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { - e.preventDefault(); - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - } else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { - e.preventDefault(); - cleanup(); - } - }; - - yesBtn.onclick = () => { - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - }; - noBtn.onclick = () => cleanup(); - - // Focus the confirm box so keyboard events are captured - confirm.tabIndex = 0; - confirm.focus(); - document.addEventListener('keydown', keyHandler); - - // Close if clicking outside - setTimeout(() => { - document.addEventListener('click', function closeConfirm(e) { - if (!confirm.contains(e.target) && e.target !== btn) { - cleanup(); - document.removeEventListener('click', closeConfirm); - } - }); - }, 10); -} -function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.password : ''; } -function showBatchConfirm(btn, message, callback) { - const existing = document.querySelector('.batch-confirm-overlay'); - if (existing) existing.remove(); - const overlay = document.createElement('div'); - overlay.className = 'batch-confirm-overlay'; - overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:transparent;'; - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.style.cssText = 'position:fixed;background:var(--bg2);border:1px solid var(--accent);border-radius:0.8rem;padding:0.7rem 1rem;z-index:10000;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-size:0.8rem;color:var(--text);white-space:nowrap;'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - 20; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - overlay.appendChild(confirm); - document.body.appendChild(overlay); - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - const cleanup = () => { overlay.remove(); document.removeEventListener('keydown', keyHandler); }; - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { e.preventDefault(); cleanup(); callback(); playSound('delete'); } - else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { e.preventDefault(); cleanup(); } - }; - yesBtn.onclick = () => { cleanup(); callback(); playSound('delete'); }; - noBtn.onclick = () => cleanup(); - overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; - confirm.tabIndex = 0; confirm.focus(); - document.addEventListener('keydown', keyHandler); -} -function attachEvents() { - document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const id = parseInt(this.dataset.id); if (showTrash) { permanentDelete(id); } else { showConfirm(this, 'Delete this entry?', async id2 => { await delEntry(id2, true); await loadEntries(); toast('📦 Moved to trash', 'success', { label: '↩ Undo', cb: async () => { await restoreEntry(id2, true); await loadEntries(); toast('↩ Restored'); playSound('success'); } }); playSound('delete'); }); } }); - document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); }); - document.querySelectorAll('.star-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); toggleFavorite(this.dataset.id); }); - document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const pw = entryPw(this.dataset.id); try { await navigator.clipboard.writeText(pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } }); - // Double-click entry to edit - document.querySelectorAll('[draggable="true"], .detail-card').forEach(el => { - el.addEventListener('dblclick', function(ev) { - const id = this.dataset.id; - if (id && !showTrash) { - ev.preventDefault(); - selectedIds.clear(); - selectedIds.add(parseInt(id)); - updateBatchBar(); - render(); - openEdit(id); - } - }); - }); - if (showView) { - document.querySelectorAll('.pw-display.pw-hover').forEach(el => { - el.addEventListener('mouseenter', function() { - const pw = entryPw(this.id.replace('p-', '')); - this.textContent = pw; - }); - el.addEventListener('mouseleave', function() { - this.textContent = '••••••••'; - }); - }); + return false; } } -function setupDrag() { - const c = document.getElementById('entriesContainer'); if (!c) return; - c.querySelectorAll('[draggable="true"]').forEach(el => { - el.ondragstart = function(e) { draggedId = this.dataset.id; const dragIds = selectedIds.has(parseInt(draggedId)) && selectedIds.size > 1 ? [...selectedIds] : [parseInt(draggedId)]; c.querySelectorAll('[draggable="true"]').forEach(card => { card.classList.toggle('drag-dim', dragIds.includes(parseInt(card.dataset.id))); }); e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; if (dragIds.length > 1) { const cv = document.createElement('canvas'); cv.width = 100; cv.height = 50; const g = cv.getContext('2d'); for (let i = dragIds.length - 1; i >= 0; i--) { const ox = i * 4, oy = i * 4; g.fillStyle = i === 0 ? 'rgba(30,40,55,0.9)' : 'rgba(59,130,246,0.15)'; g.fillRect(ox, oy, 80, 36); g.strokeStyle = 'rgba(255,255,255,0.15)'; g.strokeRect(ox, oy, 80, 36); } g.fillStyle = 'rgba(0,0,0,0.7)'; g.fillRect(0, 34, 100, 16); g.fillStyle = '#fff'; g.font = '11px sans-serif'; g.textAlign = 'center'; g.fillText(dragIds.length + ' items', 50, 46); cv.style.position = 'fixed'; cv.style.top = '-1000px'; document.body.appendChild(cv); e.dataTransfer.setDragImage(cv, 6, 10); setTimeout(() => cv.remove(), 50); } }; - el.ondragend = function(e) { c.querySelectorAll('.drag-dim').forEach(card => card.classList.remove('drag-dim')); draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); document.getElementById('trashBtn')?.classList.remove('drag-over'); }; - el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); }; - el.ondragleave = function(e) { this.classList.remove('drag-over'); }; - el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId) return; const ids = selectedIds.has(fromId) && selectedIds.size > 1 ? [...selectedIds] : [fromId]; if (ids.length === 1 && ids[0] === toId) return; const base = order.length > 0 ? order : entries.filter(e => e).map(e => e.id); const filtered = base.filter(id => !ids.includes(id)); const idx = filtered.indexOf(toId); idx > -1 ? filtered.splice(idx, 0, ...ids) : filtered.push(...ids); order = filtered; // Ensure no entries are lost from order -entries.forEach(e => { if (!order.includes(e.id)) order.push(e.id); }); localStorage.setItem('entryOrder', JSON.stringify(order)); const map = new Map(entries.filter(e => e).map(e => [e.id, e])); entries = order.map(id => map.get(id)).filter(e => e); entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); render(); }; - }); -} -// ==================== EDIT ==================== -function openEdit(id) { - let e = null; - for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } } - if (!e) return; - const folderSelect = document.getElementById('editFolder'); - folderSelect.innerHTML = ''; - folders.forEach(f => { - if (!f) return; - const option = document.createElement('option'); - option.value = f; - option.textContent = '📁 ' + f; - if (f === (e.folder || 'All')) option.selected = true; - folderSelect.appendChild(option); - }); - document.getElementById('editId').value = id; - document.getElementById('editSite').value = e.site; - document.getElementById('editUsername').value = e.username; - document.getElementById('editPassword').value = e.password; - document.getElementById('editPassword').type = 'password'; - document.getElementById('editModal').classList.add('show'); - document.getElementById('editSite').focus(); - playSound('open'); -} -function closeEdit() { document.getElementById('editModal').classList.remove('show'); render(); playSound('close'); } -function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; } -async function saveEdit() { - const id = document.getElementById('editId').value; - const site = document.getElementById('editSite').value.trim(); - const username = document.getElementById('editUsername').value.trim(); - const password = document.getElementById('editPassword').value; - const folder = document.getElementById('editFolder').value; - if (!site || !password) { toast('Site and password required', 'error'); return; } +// ============================================================ +// DATA LOADING +// ============================================================ + +async function loadFolders() { try { - const enc = await encryptPwd(password); - const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); - if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); } - else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Error', 'error'); } + const r = await api('/folders', { headers: authHeaders() }); + // 'All' is always implicit first + state.folders = ['All'].concat(r.filter(n => n !== 'All')); + } catch (e) { /* ignore */ } } -//======================== batch selection ========================== -function toggleSelectEntry(id, e) { - if (e?.shiftKey && lastSelectedId !== null) { - const ids = getFilteredEntries().map(x => x.id); - const i1 = ids.indexOf(lastSelectedId); - const i2 = ids.indexOf(id); - if (i1 > -1 && i2 > -1) { - const start = Math.min(i1, i2), end = Math.max(i1, i2); - for (let i = start; i <= end; i++) selectedIds.add(ids[i]); + +async function loadEntries() { + try { + const r = await api('/entries', { headers: authHeaders() }); + state.entries = Array.isArray(r) ? r : []; + } catch (e) { + if (e.message === 'Invalid session' || e.message === 'Session expired') { + return doLogout(); } - } else if (e?.ctrlKey || e?.metaKey) { - if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id); - } else { - if (selectedIds.size === 1 && selectedIds.has(id)) { selectedIds.clear(); } - else { selectedIds.clear(); selectedIds.add(id); } + toast(e.message, 'error'); } - lastSelectedId = id; - arrowAnchor = -1; - arrowFocus = -1; - updateBatchBar(); - render(); } -function clearSelection() { - selectedIds.clear(); - lastSelectedId = null; - arrowAnchor = -1; - arrowFocus = -1; - hideBatchBar(); - render(); +async function loadTrash() { + try { + const r = await api('/entries?deleted=1', { headers: authHeaders() }); + state.trashed = Array.isArray(r) ? r : []; + } catch (e) { state.trashed = []; } } -function updateBatchBar() { - const existing = document.getElementById('batchBar'); - if (existing) existing.remove(); - if (selectedIds.size === 0) return; - const bar = document.createElement('div'); - bar.id = 'batchBar'; - bar.className = 'batch-actions'; - bar.innerHTML = `${selectedIds.size} selected`; - if (showTrash) { - bar.innerHTML += ` - - - `; +// ============================================================ +// FILTERS / DERIVED +// ============================================================ + +function filteredEntries() { + // Trash view shows its own list (loaded separately) + let list; + if (state.view === 'trash') { + list = state.trashed; } else { - bar.innerHTML += ` - - - - `; + list = state.entries; + if (state.view === 'favorites') list = list.filter(e => e.favorite); + else if (state.view.startsWith('folder:')) { + const f = state.view.slice(7); + if (f !== 'All') list = list.filter(e => e.folder === f); + } else if (state.view.startsWith('tag:')) { + const t = state.view.slice(4); + list = list.filter(e => parseTags(e.tags).includes(t)); + } } - bar.innerHTML += ``; - document.body.appendChild(bar); + if (state.search) { + const q = state.search.toLowerCase(); + list = list.filter(e => + (e.site || '').toLowerCase().includes(q) || + (e.username || '').toLowerCase().includes(q) || + (e.tags || '').toLowerCase().includes(q) + ); + } + return list; } -function hideBatchBar() { - const bar = document.getElementById('batchBar'); - if (bar) bar.remove(); +function parseTags(s) { + if (!s) return []; + return s.split(',').map(t => t.trim()).filter(Boolean); +} + +function allTags() { + const set = new Set(); + state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t))); + return Array.from(set).sort(); +} + +function viewTitle() { + if (state.view === 'all') return 'All items'; + if (state.view === 'favorites') return 'Favorites'; + if (state.view === 'trash') return 'Trash'; + if (state.view.startsWith('folder:')) return state.view.slice(7); + if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); + return 'Items'; +} + +// ============================================================ +// RENDER +// ============================================================ + +function render() { + renderSidebar(); + renderGrid(); +} + +function renderSidebar() { + // counts + $('#countAll').textContent = state.entries.length; + $('#countFav').textContent = state.entries.filter(e => e.favorite).length; + $('#countTrash').textContent = state.trashed.length || ''; + + // active state for top-level items + $$('#appShell .nav-item[data-view]').forEach(n => { + n.classList.toggle('is-active', n.dataset.view === state.view); + }); + + // folders + const fList = $('#foldersList'); + fList.innerHTML = ''; + state.folders.forEach(name => { + const count = state.entries.filter(e => e.folder === name).length; + const key = 'folder:' + name; + const item = el('button', { + class: 'nav-item' + (state.view === key ? ' is-active' : ''), + 'data-folder': name, + on: { click: () => setView(key) }, + }); + item.appendChild(icon('i-folder')); + item.appendChild(el('span', null, name)); + item.appendChild(el('span', { class: 'nav-count' }, String(count))); + + // drag and drop target + item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); + item.addEventListener('dragleave', () => item.classList.remove('drag-over')); + item.addEventListener('drop', async e => { + e.preventDefault(); + item.classList.remove('drag-over'); + const id = e.dataTransfer.getData('text/plain'); + if (id) await moveEntryToFolder(parseInt(id), name); + }); + + fList.appendChild(item); + }); + + // tags + const tList = $('#tagsList'); + tList.innerHTML = ''; + const tags = allTags(); + if (tags.length === 0) { + tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet')); + } else { + tags.forEach(t => { + const key = 'tag:' + t; + const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length; + const item = el('button', { + class: 'nav-item' + (state.view === key ? ' is-active' : ''), + on: { click: () => setView(key) }, + }); + item.appendChild(icon('i-tag')); + item.appendChild(el('span', null, t)); + item.appendChild(el('span', { class: 'nav-count' }, String(count))); + + // Drop target: drag a card here to add this tag to that entry + item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); + item.addEventListener('dragleave', () => item.classList.remove('drag-over')); + item.addEventListener('drop', async e => { + e.preventDefault(); + item.classList.remove('drag-over'); + const id = parseInt(e.dataTransfer.getData('text/plain')); + if (id) await addTagToEntry(id, t); + }); + + tList.appendChild(item); + }); + } +} + +async function addTagToEntry(id, tag) { + const e = state.entries.find(x => x.id === id); + if (!e) return; + const tags = parseTags(e.tags); + if (tags.includes(tag)) { + toast('Already tagged with "' + tag + '"', 'warning'); + return; + } + tags.push(tag); + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder: e.folder, tags: tags.join(','), + }), + }); + e.tags = tags.join(','); + render(); + toast('Tagged "' + tag + '"'); + } catch (err) { toast(err.message, 'error'); } +} + +function renderGrid() { + $('#contentTitle').textContent = viewTitle(); + const list = filteredEntries(); + $('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items'); + + // Empty trash action button next to title (only in trash view) + const oldBtn = $('#emptyTrashBtn'); + if (oldBtn) oldBtn.remove(); + if (state.view === 'trash' && state.trashed.length > 0) { + const btn = el('button', { + class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn', + style: 'margin-left:auto', + on: { click: emptyTrash }, + }, withIcon('i-trash', 'Empty trash')); + $('.content-header').appendChild(btn); + } + + // Batch action bar (shown when selection is non-empty) + renderBatchBar(); + + const grid = $('#entryGrid'); + grid.className = 'entry-grid' + (state.viewMode === 'list' ? ' is-list' : ''); + grid.innerHTML = ''; + if (list.length === 0) { + showEmptyState(); + return; + } + $('#emptyState').classList.add('is-hidden'); + + list.forEach(e => grid.appendChild(renderCard(e))); +} + +function showEmptyState() { + const illustration = $('#emptyIllustration use'); + const title = $('#emptyTitle'); + const msg = $('#emptyMessage'); + + if (state.search) { + illustration.setAttribute('href', '#i-empty-search'); + title.textContent = 'No matches'; + msg.innerHTML = 'Try a different search term, or click + New to add a new entry.'; + } else if (state.view === 'trash') { + illustration.setAttribute('href', '#i-empty-trash'); + title.textContent = 'Trash is empty'; + msg.textContent = 'Deleted entries land here. They can be restored at any time.'; + } else if (state.view === 'favorites') { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'No favorites yet'; + msg.innerHTML = 'Click the on any entry to add it to favorites.'; + } else if (state.view.startsWith('folder:')) { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'Folder is empty'; + msg.innerHTML = 'Move entries here by drag & drop, or by setting their folder.'; + } else if (state.view.startsWith('tag:')) { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'No entries with this tag'; + msg.textContent = 'Drop a card on the tag to add this tag to that entry.'; + } else { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'Your vault is empty'; + msg.innerHTML = 'Click + New to add your first password. They\'re encrypted before they leave your machine.'; + } + $('#emptyState').classList.remove('is-hidden'); +} + +// Skeleton loaders shown during the initial fetch right after login/unlock +function showSkeletons(n) { + const grid = $('#entryGrid'); + grid.innerHTML = ''; + $('#emptyState').classList.add('is-hidden'); + for (let i = 0; i < n; i++) { + const card = el('div', { class: 'skeleton-card' }); + const row = el('div', { class: 'skeleton-row' }); + row.appendChild(el('div', { class: 'skeleton-circle' })); + const col = el('div', { style: 'flex:1' }); + col.appendChild(el('div', { class: 'skeleton-line w-60' })); + col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); + row.appendChild(col); + card.appendChild(row); + card.appendChild(el('div', { class: 'skeleton-line w-80' })); + card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); + grid.appendChild(card); + } +} + +function initials(s) { + return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'; +} + +// Compact-action kebab menu shown on each card when state.compactActions is on. +function buildKebabMenu(entry) { + const wrap = el('div', { class: 'entry-kebab-wrap' }); + const btn = el('button', { + class: 'entry-kebab', + title: 'More actions', + on: { click: ev => { + ev.stopPropagation(); + // Close any other open menu, then toggle this one + $$('.entry-kebab-menu.is-open').forEach(m => { + if (m !== menu) m.classList.remove('is-open'); + }); + menu.classList.toggle('is-open'); + } }, + }); + btn.appendChild(icon('i-more')); + wrap.appendChild(btn); + + const menu = el('div', { class: 'entry-kebab-menu' }); + const items = [ + { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, + { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, + { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, + { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, + { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, + ]; + items.forEach(it => { + const mi = el('button', { + class: 'kebab-item' + (it.danger ? ' is-danger' : ''), + on: { click: ev => { + ev.stopPropagation(); + menu.classList.remove('is-open'); + it.fn(); + } }, + }); + mi.appendChild(icon(it.ic)); + mi.appendChild(el('span', null, it.lbl)); + menu.appendChild(mi); + }); + wrap.appendChild(menu); + return wrap; +} + +function renderCard(e) { + const inTrash = state.view === 'trash'; + const checked = state.checked.has(e.id); + const card = el('article', { + class: 'entry-card' + + (state.selectedId === e.id ? ' is-selected' : '') + + (checked ? ' is-checked' : ''), + 'data-id': e.id, + draggable: inTrash ? 'false' : 'true', + on: { click: ev => handleCardClick(ev, e, inTrash) }, + }); + + if (!inTrash) { + card.addEventListener('dragstart', ev => { + ev.dataTransfer.setData('text/plain', String(e.id)); + ev.dataTransfer.effectAllowed = 'move'; + }); + } + + // head: avatar acts as a multi-select checkbox (click on avatar -> toggle) + const head = el('div', { class: 'entry-head' }); + const avatar = el('div', { + class: 'entry-avatar is-checkable', + title: 'Click to select', + on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, + }, checked ? '✓' : initials(e.site)); + head.appendChild(avatar); + const title = el('div', { class: 'entry-title' }); + title.appendChild(el('b', null, e.site)); + // Username row with inline copy button (visible on card hover) + const userRow = el('small', { class: 'entry-user-row' }); + userRow.appendChild(el('span', null, displayUsername(e.username))); + if (e.username) { + const copyUser = el('button', { + class: 'entry-copy-user', + title: 'Copy username', + on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, + }); + copyUser.appendChild(icon('i-copy')); + userRow.appendChild(copyUser); + } + title.appendChild(userRow); + head.appendChild(title); + if (inTrash) { + // In trash: show restore + permanent delete buttons + const restore = el('button', { + class: 'icon-btn icon-btn-sm', title: 'Restore', + on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } }, + }); + restore.appendChild(icon('i-rotate-ccw')); + const purge = el('button', { + class: 'icon-btn icon-btn-sm', title: 'Delete forever', + style: 'color:var(--danger)', + on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } }, + }); + purge.appendChild(icon('i-trash')); + head.appendChild(restore); + head.appendChild(purge); + } else if (state.compactActions) { + // Compact mode: single kebab menu replaces fav + del + head.appendChild(buildKebabMenu(e)); + } else { + const fav = el('button', { + class: 'entry-fav' + (e.favorite ? ' is-on' : ''), + title: 'Favorite', + on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } }, + }); + fav.appendChild(icon('i-star')); + head.appendChild(fav); + + // Quick-delete: small X visible on card hover. Always available + // without opening the slide-over. + const del = el('button', { + class: 'entry-del', + title: 'Move to trash', + on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } }, + }); + del.appendChild(icon('i-x')); + head.appendChild(del); + } + card.appendChild(head); + + // password row (placeholder dots, click reveals via slide-over) + const pwRow = el('div', { class: 'entry-pw-row' }); + pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); + const copyBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy password', + on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, + }); + copyBtn.appendChild(icon('i-copy')); + pwRow.appendChild(copyBtn); + card.appendChild(pwRow); + + // meta chips: folder + first 2 tags + const meta = el('div', { class: 'entry-meta' }); + if (e.folder) { + const chip = el('span', { class: 'entry-chip is-folder' }); + chip.appendChild(icon('i-folder')); + chip.appendChild(el('span', null, e.folder)); + meta.appendChild(chip); + } + parseTags(e.tags).slice(0, 3).forEach(t => { + const chip = el('span', { class: 'entry-chip' }); + chip.appendChild(icon('i-tag')); + chip.appendChild(el('span', null, t)); + meta.appendChild(chip); + }); + card.appendChild(meta); + + return card; +} + +// ============================================================ +// MARQUEE (rubber-band) SELECTION +// ============================================================ +// Click-drag on empty space in the entry grid draws a rectangle. +// Cards whose bounding box intersects the rectangle become selected. +// Shift/Ctrl held = add to existing selection (otherwise replace). + +let marqueeEl = null; +let marqueeStart = null; +let marqueeAdditive = false; +let marqueeInitialSet = null; + +function startMarquee(ev) { + // Only fire on left mouse button, and only when starting on grid background + if (ev.button !== 0) return; + if (ev.target.closest('.entry-card')) return; // ignore drags from cards + if (ev.target.closest('.batch-bar')) return; + if (!ev.target.closest('#entryGrid')) return; + + marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey; + marqueeInitialSet = new Set(state.checked); + if (!marqueeAdditive) state.checked.clear(); + + marqueeStart = { x: ev.clientX, y: ev.clientY }; + marqueeEl = el('div', { class: 'marquee' }); + Object.assign(marqueeEl.style, { + left: marqueeStart.x + 'px', + top: marqueeStart.y + 'px', + width: '0px', height: '0px', + }); + document.body.appendChild(marqueeEl); + ev.preventDefault(); + + document.addEventListener('mousemove', updateMarquee); + document.addEventListener('mouseup', endMarquee); +} + +function updateMarquee(ev) { + if (!marqueeEl) return; + const x1 = Math.min(marqueeStart.x, ev.clientX); + const y1 = Math.min(marqueeStart.y, ev.clientY); + const x2 = Math.max(marqueeStart.x, ev.clientX); + const y2 = Math.max(marqueeStart.y, ev.clientY); + Object.assign(marqueeEl.style, { + left: x1 + 'px', top: y1 + 'px', + width: (x2 - x1) + 'px', height: (y2 - y1) + 'px', + }); + + // Re-check intersections + const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 }; + state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []); + $$('#entryGrid .entry-card').forEach(card => { + const r = card.getBoundingClientRect(); + const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right || + r.bottom < marqueeRect.top || r.top > marqueeRect.bottom); + if (intersects) { + const id = parseInt(card.dataset.id); + state.checked.add(id); + card.classList.add('is-checked'); + } else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) { + card.classList.remove('is-checked'); + } + }); +} + +function endMarquee() { + document.removeEventListener('mousemove', updateMarquee); + document.removeEventListener('mouseup', endMarquee); + if (marqueeEl) marqueeEl.remove(); + marqueeEl = null; + marqueeStart = null; + marqueeInitialSet = null; + // Re-render so the batch bar appears with the new count + avatar states + renderGrid(); +} + +// ============================================================ +// MULTI-SELECTION + BATCH ACTIONS +// ============================================================ + +let selectionAnchor = null; // last single-clicked card, used for shift+click range + +function toggleChecked(id) { + if (state.checked.has(id)) state.checked.delete(id); + else state.checked.add(id); + renderGrid(); +} + +function handleCardClick(ev, entry, inTrash) { + // Ctrl/Cmd+Click: toggle this card in selection + if (ev.ctrlKey || ev.metaKey) { + toggleChecked(entry.id); + selectionAnchor = entry.id; + return; + } + // Shift+Click: select range from anchor to this card + if (ev.shiftKey && selectionAnchor !== null) { + const list = filteredEntries(); + const a = list.findIndex(x => x.id === selectionAnchor); + const b = list.findIndex(x => x.id === entry.id); + if (a >= 0 && b >= 0) { + const lo = Math.min(a, b), hi = Math.max(a, b); + for (let i = lo; i <= hi; i++) state.checked.add(list[i].id); + renderGrid(); + return; + } + } + // If any cards are already checked, a plain click toggles (sticky multi-select) + if (state.checked.size > 0) { + toggleChecked(entry.id); + selectionAnchor = entry.id; + return; + } + // Default: open slide-over (or trash actions) + selectionAnchor = entry.id; + if (inTrash) openTrashActions(entry.id); + else openSlideOver(entry.id); +} + +function clearChecked() { + state.checked.clear(); + renderGrid(); +} + +async function batchMoveToFolder(folder) { + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { + const e = state.entries.find(x => x.id === id); + if (!e || e.folder === folder) continue; + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder, tags: e.tags || '', + }), + }); + e.folder = folder; + } catch (err) { /* ignore individual failures */ } + } + toast(ids.length + ' moved to ' + folder); + clearChecked(); +} + +async function batchAddTag(tag) { + tag = (tag || '').trim(); + if (!tag) return; + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { + const e = state.entries.find(x => x.id === id); + if (!e) continue; + const tags = parseTags(e.tags); + if (tags.includes(tag)) continue; + tags.push(tag); + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder: e.folder, tags: tags.join(','), + }), + }); + e.tags = tags.join(','); + } catch (err) {} + } + toast('Tagged ' + ids.length + ' as "' + tag + '"'); + clearChecked(); } async function batchDelete() { - const count = selectedIds.size; - const ids = [...selectedIds]; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Move ' + count + ' entries to trash?', async () => { - for (const id of ids) await delEntry(id, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + count + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const id of ids) await restoreEntry(id, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); }, - onExpire: null - }); - playSound('delete'); - }); -} - -async function batchPermanentDelete() { - const count = selectedIds.size; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Permanently delete ' + count + ' entries?', async () => { - for (const id of selectedIds) await permanentDelete(id, true); - toast('🗑️ Permanently deleted ' + count + ' entries'); - playSound('error'); - clearSelection(); - await loadEntries(); + const ids = Array.from(state.checked); + if (!ids.length) return; + const ok = await confirmDialog({ + title: 'Move to trash', + message: '' + ids.length + ' entries will be moved to trash.', + okText: 'Move to trash', + danger: true, }); + if (!ok) return; + for (const id of ids) { + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' moved to trash'); + await loadEntries(); + await loadTrash(); + clearChecked(); } async function batchRestore() { - const count = selectedIds.size; - for (const id of selectedIds) await restoreEntry(id, true); - toast('✅ Restored ' + count + ' entries'); - playSound('success'); - clearSelection(); - await loadEntries(); -} - -async function batchMove() { - const folder = document.getElementById('batchFolder')?.value || 'All'; - for (const id of selectedIds) { - const e = entries.find(x => x.id == id); - if (e) { - e.folder = folder; - const enc = await encryptPwd(e.password); - await fetch(API + '/entries/' + id, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - } - } - clearSelection(); - loadEntries(); -} -// ==================== ADD / DELETE ==================== -async function addEntry() { - const site = document.getElementById('addSite').value.trim(); - const user = document.getElementById('addUsername').value.trim(); - const pass = document.getElementById('addPassword').value; - if (!site || !pass) { toast('❌ Site and password required', 'error'); return; } - if (user) localStorage.setItem('savedUsername', user); - const btn = document.getElementById('addEntryBtn'); - btn.disabled = true; - try { - const enc = await encryptPwd(pass); - const folder = document.getElementById('addFolder').value; - const r = await fetch(API + '/entries', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder }) - }); - if (r.ok) { - closeAdd(); - toast('✅ Saved!'); - loadEntries(); - playSound('success'); - } else { - const d = await r.json(); - toast('❌ ' + (d.error || 'Failed'), 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } - finally { btn.disabled = false; } -} -async function delEntry(id, noToast) { - try { - const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); - if (r.ok) { selectedIds.delete(id); order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!noToast) { toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } } - } catch (e) { if (!noToast) toast('Error', 'error'); } -} - -// ==================== UTILS ==================== -function searchEntries() { - const input = document.getElementById('searchInput'); - searchQuery = input.value.trim(); - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = searchQuery ? 'block' : 'none'; - loadEntries(searchQuery); -} -function showExportModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📤 Export Passwords

Re-enter master password to export plaintext passwords

`; - document.body.appendChild(overlay); - document.getElementById('cancelExport').onclick = () => overlay.remove(); - document.getElementById('confirmExport').onclick = async () => { - const pwd = document.getElementById('exportPassword').value; - if (!pwd) { toast('Enter your master password', 'error'); return; } + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { try { - const r = await fetch(API + '/reauth', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ masterPassword: pwd }) - }); - if (r.ok) { - overlay.remove(); - const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(b); - a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; - a.click(); - URL.revokeObjectURL(a.href); - toast('Exported!'); - playSound('success'); - } else { - toast('❌ Invalid password', 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } + await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' restored'); + await loadEntries(); + await loadTrash(); + clearChecked(); +} + +async function batchPermDelete() { + const ids = Array.from(state.checked); + if (!ids.length) return; + const ok = await confirmDialog({ + title: 'Delete forever', + message: '' + ids.length + ' entries will be permanently deleted. This cannot be undone.', + okText: 'Delete forever', + danger: true, + }); + if (!ok) return; + for (const id of ids) { + try { + await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' deleted permanently'); + await loadTrash(); + clearChecked(); +} + +function renderBatchBar() { + const existing = $('#batchBar'); + if (existing) existing.remove(); + if (state.checked.size === 0) return; + + const inTrash = state.view === 'trash'; + const bar = el('div', { class: 'batch-bar', id: 'batchBar' }); + bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected')); + + if (inTrash) { + // Trash view: Restore | Delete forever + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: batchRestore }, + }, withIcon('i-rotate-ccw', 'Restore'))); + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + style: 'color:var(--danger)', + on: { click: batchPermDelete }, + }, withIcon('i-trash', 'Delete forever'))); + } else { + // Normal view: Move to folder | Add tag | Delete (soft) + const moveSel = el('select'); + moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); + state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, f))); + moveSel.addEventListener('change', () => { + if (moveSel.value) batchMoveToFolder(moveSel.value); + }); + bar.appendChild(moveSel); + + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: async () => { + const t = await promptDialog({ + title: 'Add tag', + message: 'Add a tag to ' + state.checked.size + ' selected entries', + placeholder: 'tag name', + okText: 'Add', + }); + if (t) batchAddTag(t); + } }, + }, withIcon('i-tag', 'Add tag'))); + + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + style: 'color:var(--danger)', + on: { click: batchDelete }, + }, withIcon('i-trash', 'Delete'))); + } + + bar.appendChild(el('div', { class: 'grow' })); + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: clearChecked }, + }, withIcon('i-x', 'Clear'))); + + const content = $('.content'); + content.insertBefore(bar, $('#entryGrid')); +} + +// ============================================================ +// SLIDE-OVER +// ============================================================ + +// Edit-in-place state for the slide-over +let soState = null; + +async function openSlideOver(id) { + const e = state.entries.find(x => x.id === id); + if (!e) return; + state.selectedId = id; + + $('#slideoverTitle').textContent = e.site; + const body = $('#slideoverBody'); + body.innerHTML = ''; + + const plain = await decryptPwd(e.encrypted_password, e.iv); + + // Track original values so we can detect "dirty" + soState = { + id: e.id, + original: { + site: e.site, username: e.username || '', password: plain, + folder: e.folder || 'All', tags: parseTags(e.tags).join(','), + }, + tags: parseTags(e.tags), + originalEncrypted: e.encrypted_password, + originalIV: e.iv, }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); -} -function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } -function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function highlightText(text, query) { - if (!query || !query.trim()) return esc(text); - const re = new RegExp('(' + escRegex(query.trim()) + ')', 'gi'); - return esc(text).replace(re, '$1'); -} -function showShortcutsHelp() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

⌨️ Keyboard Shortcuts

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

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

`; - document.body.appendChild(overlay); - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); -} -function clearSearch() { - const input = document.getElementById('searchInput'); - input.value = ''; - searchQuery = ''; - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = 'none'; - loadEntries(); - input.focus(); -} -// ==================== STARTUP – session persistence ==================== -init(); -applyTheme(); -if (token && curUser) { - (async () => { - if (await restoreCryptoKey()) { - await loadFolders(); - showVault(); - loadEntries(); + body.appendChild(soEditableField('Site', 'soSite', e.site)); + body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); + body.appendChild(soPasswordField(plain)); + body.appendChild(soFolderField(e.folder || 'All')); + body.appendChild(soTagsField()); + + // Action row — Save button is hidden until dirty. No Delete here: + // the quick-X on each card handles deletion (avoids duplication). + const actions = el('div', { class: 'slideover-actions' }); + const saveBtn = el('button', { + class: 'btn btn-primary', + id: 'soSaveBtn', + style: 'display:none', + on: { click: soSave }, + }, withIcon('i-check', 'Save')); + actions.appendChild(saveBtn); + body.appendChild(actions); + + // Wire change detection + ['#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { + const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); + if (el) el.addEventListener('change', soDirtyCheck); + }); + + $('#slideover').classList.add('is-open'); + renderGrid(); +} + +function soEditableField(label, id, value) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); + const input = el('input', { type: 'text', id, value, class: 'so-input' }); + wrap.appendChild(input); + return wrap; +} + +function soPasswordField(plain) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); + const row = el('div', { class: 'so-pw-row' }); + const input = el('input', { + type: 'password', id: 'soPassword', value: plain, + class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace', + }); + const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' }); + toggle.appendChild(icon('i-eye')); + toggle.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + }); + const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); + copy.appendChild(icon('i-copy')); + copy.addEventListener('click', () => { + if (Bridge.copySecure(input.value, 30000)) { + toast('Copied · clears in 30s'); } else { - sessionStorage.clear(); - token = null; - curUser = null; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; + navigator.clipboard.writeText(input.value).then(() => { + toast('Copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); } - })(); + }); + const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' }); + gen.appendChild(icon('i-dice')); + gen.addEventListener('click', () => { + openGen('slideover'); + }); + row.appendChild(input); + row.appendChild(toggle); + row.appendChild(copy); + row.appendChild(gen); + wrap.appendChild(row); + return wrap; } -['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); })); -// Trash button as drop target (set up once, outside setupDrag to avoid duplicates) -(function() { - const trashBtn = document.getElementById('trashBtn'); - if (trashBtn) { - trashBtn.addEventListener('dragover', e => { if (!showTrash) { e.preventDefault(); trashBtn.classList.add('drag-over'); } }); - trashBtn.addEventListener('dragleave', () => trashBtn.classList.remove('drag-over')); - trashBtn.addEventListener('drop', async function(e) { +function soFolderField(current) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); + const sel = el('select', { id: 'soFolder', class: 'so-input' }); + state.folders.forEach(f => { + const opt = el('option', { value: f }, f); + if (f === current) opt.selected = true; + sel.appendChild(opt); + }); + wrap.appendChild(sel); + return wrap; +} + +function soTagsField() { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags')); + const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' }); + const input = el('input', { + type: 'text', id: 'soTagsField', + placeholder: 'add a tag…', autocomplete: 'off', + }); + cont.appendChild(input); + wrap.appendChild(cont); + // Render existing chips + renderSoChips(); + input.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - for (const sid of ids) await delEntry(sid, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + ids.length + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const sid of ids) await restoreEntry(sid, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); } + const v = input.value.trim().replace(/,/g, ''); + if (v && !soState.tags.includes(v)) { + soState.tags.push(v); + renderSoChips(); + soDirtyCheck(); + } + input.value = ''; + } else if (e.key === 'Backspace' && !input.value && soState.tags.length) { + soState.tags.pop(); + renderSoChips(); + soDirtyCheck(); + } + }); + return wrap; +} + +function renderSoChips() { + const cont = $('#soTagsContainer'); + if (!cont) return; + const input = $('#soTagsField'); + $$('.chip', cont).forEach(c => c.remove()); + soState.tags.forEach((t, i) => { + const chip = el('span', { class: 'chip' }); + chip.appendChild(el('span', null, t)); + const x = el('button', { + type: 'button', + on: { click: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, + }); + x.appendChild(icon('i-x')); + chip.appendChild(x); + cont.insertBefore(chip, input); + }); +} + +function soDirtyCheck() { + if (!soState) return; + const cur = { + site: ($('#soSite') || {}).value || '', + username: ($('#soUsername') || {}).value || '', + password: ($('#soPassword') || {}).value || '', + folder: ($('#soFolder') || {}).value || '', + tags: soState.tags.join(','), + }; + const dirty = + cur.site !== soState.original.site || + cur.username !== soState.original.username || + cur.password !== soState.original.password || + cur.folder !== soState.original.folder || + cur.tags !== soState.original.tags; + const btn = $('#soSaveBtn'); + if (btn) btn.style.display = dirty ? '' : 'none'; +} + +async function soSave() { + if (!soState) return; + const site = $('#soSite').value.trim(); + const user = $('#soUsername').value.trim(); + const pwd = $('#soPassword').value; + const fold = $('#soFolder').value; + if (!site || !pwd) return toast('Site and password required', 'error'); + + // Only re-encrypt if password changed; otherwise reuse stored ciphertext + let enc; + if (pwd === soState.original.password) { + enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV }; + } else { + enc = await encryptPwd(pwd); + } + try { + await api('/entries/' + soState.id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site, username: user, + encrypted_password: enc.encrypted, iv: enc.iv, + folder: fold, tags: soState.tags.join(','), + }), + }); + toast('Saved'); + await loadEntries(); + // Re-open with updated data + const updated = state.entries.find(x => x.id === soState.id); + if (updated) openSlideOver(updated.id); + else closeSlideOver(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +function withIcon(name, label) { + const frag = document.createDocumentFragment(); + frag.appendChild(icon(name)); + frag.appendChild(document.createTextNode(label)); + return frag; +} + +function field(label, value) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); + wrap.appendChild(el('div', { class: 'slideover-field-value' }, value)); + return wrap; +} + +function passwordField(plain) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); + let revealed = false; + const valueRow = el('div', { class: 'slideover-field-value' }); + const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••'); + const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' }); + toggle.appendChild(icon('i-eye')); + toggle.addEventListener('click', () => { + revealed = !revealed; + span.textContent = revealed ? plain : '••••••••'; + span.style.userSelect = revealed ? 'text' : 'none'; + }); + const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' }); + copy.appendChild(icon('i-copy')); + copy.addEventListener('click', () => { + if (Bridge.copySecure(plain, 0)) { + toast('Copied'); + } else { + navigator.clipboard.writeText(plain).then(() => toast('Copied')); + } + }); + valueRow.appendChild(span); + valueRow.appendChild(toggle); + valueRow.appendChild(copy); + wrap.appendChild(valueRow); + return wrap; +} + +function closeSlideOver() { + $('#slideover').classList.remove('is-open'); + state.selectedId = null; + renderGrid(); +} + +// ============================================================ +// TAG CHIP INPUT +// ============================================================ +// Local mutable list of tags currently in the entry modal. Synced to the +// hidden #entryTags field on every change so saveEntry can read it. + +let editingTags = []; +let chipSuggestEl = null; +let chipSuggestActive = -1; + +function syncTagsHidden() { + $('#entryTags').value = editingTags.join(','); +} + +function renderChips() { + const container = $('#entryTagsInput'); + // Wipe existing chips but keep the input element + $$('.chip', container).forEach(c => c.remove()); + const input = $('#entryTagsField'); + editingTags.forEach((t, i) => { + const chip = el('span', { class: 'chip' }); + chip.appendChild(el('span', null, t)); + const x = el('button', { + type: 'button', + on: { click: () => { editingTags.splice(i, 1); renderChips(); syncTagsHidden(); } }, + }); + x.appendChild(icon('i-x')); + chip.appendChild(x); + container.insertBefore(chip, input); + }); + syncTagsHidden(); +} + +function setEditingTags(arr) { + editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean); + renderChips(); +} + +function addTag(raw) { + const t = (raw || '').trim().replace(/,/g, ''); + if (!t) return; + if (editingTags.includes(t)) return; + editingTags.push(t); + renderChips(); +} + +function closeChipSuggest() { + if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; } + chipSuggestActive = -1; +} + +function openChipSuggest() { + closeChipSuggest(); + const field = $('#entryTagsField'); + const q = field.value.trim().toLowerCase(); + const existing = allTags(); + const candidates = existing + .filter(t => !editingTags.includes(t)) + .filter(t => !q || t.toLowerCase().includes(q)) + .slice(0, 8); + if (!q && candidates.length === 0) return; + + chipSuggestEl = el('div', { class: 'chip-suggestions' }); + if (candidates.length === 0) { + chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"')); + } else { + candidates.forEach((t, i) => { + const item = el('div', { + class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''), + on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } }, + }, t); + chipSuggestEl.appendChild(item); + }); + chipSuggestActive = 0; + } + // Position under the chip input + const rect = $('#entryTagsInput').getBoundingClientRect(); + chipSuggestEl.style.position = 'fixed'; + chipSuggestEl.style.top = (rect.bottom + 4) + 'px'; + chipSuggestEl.style.left = rect.left + 'px'; + chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px'; + document.body.appendChild(chipSuggestEl); +} + +function moveChipSuggest(dir) { + if (!chipSuggestEl) return; + const items = $$('.chip-suggestion', chipSuggestEl); + if (items.length === 0) return; + items.forEach(it => it.classList.remove('is-active')); + chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length; + items[chipSuggestActive].classList.add('is-active'); +} + +function selectActiveSuggestion() { + if (!chipSuggestEl || chipSuggestActive < 0) return false; + const items = $$('.chip-suggestion', chipSuggestEl); + if (items[chipSuggestActive]) { + addTag(items[chipSuggestActive].textContent); + $('#entryTagsField').value = ''; + closeChipSuggest(); + return true; + } + return false; +} + +// ============================================================ +// ENTRY MODAL (new / edit) +// ============================================================ + +async function openEntryModal(entry) { + populateFolderSelect(); + if (entry) { + $('#entryModalTitle').textContent = 'Edit entry'; + $('#entryId').value = entry.id; + $('#entrySite').value = entry.site; + $('#entryUsername').value = entry.username || ''; + $('#entryFolder').value = entry.folder || 'All'; + setEditingTags(parseTags(entry.tags)); + $('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv); + if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = ''; + } else { + $('#entryModalTitle').textContent = 'New entry'; + $('#entryId').value = ''; + $('#entryForm').reset(); + $('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All'; + setEditingTags([]); + } + $('#entryTagsField').value = ''; + closeChipSuggest(); + updateEntryStrength(); + $('#entryModal').classList.remove('is-hidden'); + $('#entrySite').focus(); +} + +function closeEntryModal() { + $('#entryModal').classList.add('is-hidden'); +} + +function populateFolderSelect() { + const sel = $('#entryFolder'); + sel.innerHTML = ''; + state.folders.forEach(f => sel.appendChild(el('option', { value: f }, f))); +} + +async function saveEntry(e) { + e && e.preventDefault(); + // Flush any pending text in the chip input as a final tag + const pending = $('#entryTagsField').value.trim(); + if (pending) { addTag(pending); $('#entryTagsField').value = ''; } + const id = $('#entryId').value; + const site = $('#entrySite').value.trim(); + const user = $('#entryUsername').value.trim(); + const pwd = $('#entryPassword').value; + const fold = $('#entryFolder').value; + const tags = editingTags.join(','); + if (!site || !pwd) return toast('Site and password required', 'error'); + + const enc = await encryptPwd(pwd); + const body = JSON.stringify({ + site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, + folder: fold, tags, + }); + try { + if (id) { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, }); - playSound('delete'); + toast('Updated'); + } else { + await api('/entries', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, + }); + toast('Saved'); + } + closeEntryModal(); + await loadEntries(); + render(); + } catch (err) { + toast(err.message, 'error'); + } +} + +async function restoreEntry(id) { + try { + await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); + toast('Restored'); + await loadEntries(); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function permanentDelete(id) { + const ok = await confirmDialog({ + title: 'Delete forever', + message: 'This entry will be permanently deleted. This cannot be undone.', + okText: 'Delete forever', + danger: true, + }); + if (!ok) return; + try { + await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); + toast('Deleted permanently'); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function emptyTrash() { + if (!state.trashed.length) return; + const ok = await confirmDialog({ + title: 'Empty trash', + message: '' + state.trashed.length + ' entries will be deleted forever. This cannot be undone.', + okText: 'Empty trash', + danger: true, + }); + if (!ok) return; + try { + await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); + toast('Trash emptied'); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +function openTrashActions(id) { + // For trash entries, we don't open the slide-over — actions are inline on the card. + // But user can click outside the buttons to no-op. Could open a read-only view later. +} + +async function deleteEntry(id) { + const e = state.entries.find(x => x.id === id); + if (state.askBeforeDelete) { + const ok = await confirmDialog({ + title: 'Move to trash', + message: 'Send ' + (e ? e.site : 'this entry') + ' to trash? You can restore it later.', + okText: 'Move to trash', + danger: true, + }); + if (!ok) return; + } + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + toast('Moved to trash'); + closeSlideOver(); + await loadEntries(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function toggleFavorite(id) { + try { + await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() }); + const entry = state.entries.find(e => e.id === id); + if (entry) entry.favorite = entry.favorite ? 0 : 1; + render(); + } catch (e) {} +} + +async function moveEntryToFolder(id, folder) { + const e = state.entries.find(x => x.id === id); + if (!e || e.folder === folder) return; + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder, tags: e.tags || '', + }), + }); + e.folder = folder; + render(); + toast('Moved to ' + folder); + } catch (err) { toast(err.message, 'error'); } +} + +async function copyPassword(entry) { + const p = await decryptPwd(entry.encrypted_password, entry.iv); + if (p === '[ERROR]') return toast('Cannot decrypt', 'error'); + if (Bridge.copySecure(p, 30000)) { + toast('Password copied · clears in 30s'); + } else { + navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s')); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + } +} + +function copyUsername(entry) { + const u = entry.username || ''; + if (!u) return toast('No username to copy', 'warning'); + if (Bridge.copySecure(u, 0)) { + toast('Username copied'); + } else { + navigator.clipboard.writeText(u).then(() => toast('Username copied')); + } +} + +// Display helper: when `maskUsernames` setting is on, show only the first 2 +// chars followed by '***'. Used in cards/list (but slide-over always reveals). +function displayUsername(u) { + if (!u) return '—'; + if (!state.maskUsernames) return u; + if (u.length <= 2) return u + '***'; + return u.slice(0, 2) + '***'; +} + +// ============================================================ +// FOLDERS CRUD +// ============================================================ + +async function addFolder() { + const name = await promptDialog({ + title: 'New folder', + message: 'Folder name', + placeholder: 'e.g. Work', + okText: 'Create', + }); + if (!name || !name.trim()) return; + try { + await api('/folders', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name: name.trim() }), + }); + await loadFolders(); + render(); + toast('Folder created'); + } catch (e) { toast(e.message, 'error'); } +} + +// ============================================================ +// PASSWORD GENERATOR +// ============================================================ + +let genCurrent = ''; + +function genPassword() { + const len = parseInt($('#genLen').value); + $('#genLenLabel').textContent = len; + let chars = ''; + if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz'; + if ($('#genNum').checked) chars += '0123456789'; + if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'; + if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; } + let p = ''; + const max = 256 - (256 % chars.length); + const buf = new Uint8Array(1); + for (let i = 0; i < len; i++) { + do { crypto.getRandomValues(buf); } while (buf[0] >= max); + p += chars.charAt(buf[0] % chars.length); + } + genCurrent = p; + $('#genPreview').textContent = p; +} + +// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss) +let genTarget = 'entry'; + +function openGen(target) { + genTarget = target || 'entry'; + // Show "Use" when targeting an editable field (entry modal or slide-over) + $('#genUse').style.display = (genTarget === 'standalone') ? 'none' : ''; + $('#genModal').classList.remove('is-hidden'); + genPassword(); +} +function closeGen() { $('#genModal').classList.add('is-hidden'); } + +// ============================================================ +// PASSWORD STRENGTH +// ============================================================ + +function computeStrength(p) { + let s = 0; + if (p.length >= 8) s += 25; + if (p.length >= 12) s += 15; + if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20; + if (/\d/.test(p)) s += 15; + if (/[^A-Za-z0-9]/.test(p)) s += 25; + return Math.min(100, s); +} + +function updateRegStrength() { + const p = $('#regPassword').value; + $('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); +} +function updateEntryStrength() { + const p = $('#entryPassword').value; + $('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); +} + +// ============================================================ +// COMMAND PALETTE +// ============================================================ + +function openPalette() { + $('#cmdPalette').classList.remove('is-hidden'); + $('#cmdInput').value = ''; + $('#cmdInput').focus(); + renderPaletteResults(''); +} +function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } + +function paletteCommands() { + return [ + { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } }, + { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, + { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, + { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, + { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, + { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, + { id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } }, + ]; +} + +function renderPaletteResults(q) { + const cmds = paletteCommands(); + const entries = state.entries.map(e => ({ + id: 'entry-' + e.id, label: e.site, sub: e.username || '', + icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); }, + })); + const all = cmds.concat(entries); + q = (q || '').toLowerCase(); + const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all; + const out = $('#cmdResults'); + out.innerHTML = ''; + filtered.slice(0, 12).forEach((c, i) => { + const it = el('div', { + class: 'cmd-item' + (i === 0 ? ' is-active' : ''), + on: { click: c.run }, + }); + it.appendChild(icon(c.icon)); + it.appendChild(el('span', null, c.label)); + if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub)); + out.appendChild(it); + }); +} + +// ============================================================ +// IN-APP CONFIRM / PROMPT (no native alerts) +// ============================================================ + +let confirmResolver = null; + +function confirmDialog(opts) { + // opts: { title, message, okText, cancelText, danger } + opts = opts || {}; + $('#confirmTitle').textContent = opts.title || 'Confirm'; + $('#confirmMessage').innerHTML = opts.message || 'Are you sure?'; + $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm'); + $('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel'; + $('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger); + $('#confirmInputField').classList.add('is-hidden'); + $('#confirmModal').classList.remove('is-hidden'); + setTimeout(() => $('#confirmOkBtn').focus(), 50); + return new Promise(res => { confirmResolver = res; }); +} + +function promptDialog(opts) { + // opts: { title, message, okText, placeholder, value } + opts = opts || {}; + $('#confirmTitle').textContent = opts.title || 'Enter value'; + $('#confirmMessage').innerHTML = opts.message || ''; + $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK'); + $('#confirmCancelBtn').textContent = 'Cancel'; + $('#confirmOkBtn').classList.remove('is-danger'); + $('#confirmInputField').classList.remove('is-hidden'); + $('#confirmInput').value = opts.value || ''; + $('#confirmInput').placeholder = opts.placeholder || ''; + $('#confirmModal').classList.remove('is-hidden'); + setTimeout(() => $('#confirmInput').focus(), 50); + return new Promise(res => { confirmResolver = res; }); +} + +function closeConfirm(value) { + $('#confirmModal').classList.add('is-hidden'); + if (confirmResolver) { + const cb = confirmResolver; + confirmResolver = null; + cb(value); + } +} + +// ============================================================ +// RE-AUTH MODAL + EXPORT +// ============================================================ + +let reauthResolve = null; + +function askReauth(message) { + return new Promise(resolve => { + reauthResolve = resolve; + $('#reauthMessage').textContent = message || 'This action requires your master password.'; + $('#reauthPassword').value = ''; + $('#reauthModal').classList.remove('is-hidden'); + setTimeout(() => $('#reauthPassword').focus(), 50); + }); +} + +function closeReauth(ok) { + $('#reauthModal').classList.add('is-hidden'); + if (reauthResolve) { + const pwd = ok ? $('#reauthPassword').value : null; + const cb = reauthResolve; + reauthResolve = null; + cb(pwd); + } +} + +async function doExport() { + const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.'); + if (!pwd) return; + try { + await api('/reauth', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ masterPassword: pwd }), + }); + } catch (err) { + toast('Wrong master password', 'error'); + return; + } + // Decrypt all entries + const out = { version: 1, exported_at: new Date().toISOString(), username: state.username, entries: [] }; + for (const e of state.entries) { + const plain = await decryptPwd(e.encrypted_password, e.iv); + out.entries.push({ + site: e.site, username: e.username, + password: plain, folder: e.folder, + tags: parseTags(e.tags), favorite: !!e.favorite, + created_at: e.created_at, updated_at: e.updated_at, }); } -})(); + const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = el('a', { + href: url, + download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json', + }); + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); + toast(out.entries.length + ' entries exported'); +} -// Prevent native drag on non-card elements (table headers, text, etc.) -document.getElementById('entriesContainer').addEventListener('dragstart', function(e) { - if (!e.target?.closest?.('[draggable="true"]')) e.preventDefault(); -}); +// ============================================================ +// VIEWS / NAV +// ============================================================ -// Document mousedown: rect selection on vault background, clear outside vault -document.addEventListener('mousedown', function(e) { - if (e.button !== 0 || rectState.active) return; - if (e.target?.closest?.('.entry-card,.entry-row,.entry-compact,.table-row-drag,.detail-card,.detail-nav,#batchBar,.custom-modal-overlay.show,.edit-modal.show,.modal-overlay.show,#genModal,#settingsMenu,.batch-confirm-overlay')) return; - if (e.target?.closest?.('button,input,select,.folders-bar,.toolbar,#trashActions,.settings-dropdown,.fab,.auth-section')) { - if (selectedIds.size > 0) clearSelection(); - return; +async function setView(v) { + state.view = v; + if (v === 'trash') { + await loadTrash(); } - if (e.target?.closest?.('.vault')) { - rectState.active = true; - rectState.startX = e.clientX; - rectState.startY = e.clientY; - rectState.started = false; - rectState.el = null; - selectedIds.clear(); - lastSelectedId = null; - hideBatchBar(); - document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); - } else if (selectedIds.size > 0) { - clearSelection(); + render(); +} + +function toggleTheme() { + setTheme(state.theme === 'dark' ? 'light' : 'dark'); +} +function setTheme(t) { + state.theme = t; + document.documentElement.setAttribute('data-theme', t); + localStorage.setItem('theme', t); + const sel = $('#settingTheme'); + if (sel) sel.value = t; +} + +function openSettings() { + $('#settingTheme').value = state.theme; + $('#settingAutoLock').value = String(state.autoLock); + $('#settingAskDelete').checked = state.askBeforeDelete; + $('#settingCompact').checked = state.compactActions; + $('#settingMaskUser').checked = state.maskUsernames; + $('#settingUser').textContent = state.username; + $('#settingsPanel').classList.add('is-open'); +} +function closeSettings() { + $('#settingsPanel').classList.remove('is-open'); +} + +// ---- Auto-lock with 30s warning countdown ------------------- +const WARNING_SECONDS = 30; +let autoLockTimer = null; +let warningTimer = null; +let countdownInterval = null; + +function hideIdleWarning() { + $('#idleWarning').classList.add('is-hidden'); + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } +} + +function showIdleWarning() { + $('#idleCountdown').textContent = WARNING_SECONDS; + $('#idleWarning').classList.remove('is-hidden'); + let s = WARNING_SECONDS; + countdownInterval = setInterval(() => { + s -= 1; + $('#idleCountdown').textContent = Math.max(0, s); + if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; } + }, 1000); +} + +function resetAutoLock() { + if (autoLockTimer) clearTimeout(autoLockTimer); + if (warningTimer) clearTimeout(warningTimer); + hideIdleWarning(); + if (!state.autoLock || !state.token || !state.cryptoKey) return; + + const totalMs = state.autoLock * 60 * 1000; + const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000); + + warningTimer = setTimeout(showIdleWarning, warningAt); + autoLockTimer = setTimeout(() => { + hideIdleWarning(); + toast('Auto-locked due to inactivity', 'warning'); + lockVault(); + }, totalMs); +} + +// Reset idle on user interaction — but ignore events that fire while the +// warning popup is visible (otherwise the popup would never auto-dismiss). +['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev => + document.addEventListener(ev, e => { + // Allow clicks on the "Stay unlocked" button to also reset + if ($('#idleWarning').classList.contains('is-hidden')) { + resetAutoLock(); + } + }, { passive: true }) +); + +function showAuth() { + $('#authScreen').classList.remove('is-hidden'); + $('#appShell').classList.add('is-hidden'); + if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } +} + +async function enterApp() { + $('#authScreen').classList.add('is-hidden'); + $('#appShell').classList.remove('is-hidden'); + $('#userName').textContent = state.username; + // Show skeleton cards immediately while the initial fetch runs + showSkeletons(6); + await loadFolders(); + await loadEntries(); + render(); + resetAutoLock(); +} + +// ============================================================ +// INIT +// ============================================================ + +async function init() { + document.documentElement.setAttribute('data-theme', state.theme); + + // Auth tabs + $$('.auth-tab').forEach(t => { + t.addEventListener('click', () => { + $$('.auth-tab').forEach(x => x.classList.remove('is-active')); + t.classList.add('is-active'); + const tab = t.dataset.tab; + $('#loginForm').classList.toggle('is-hidden', tab !== 'login'); + $('#registerForm').classList.toggle('is-hidden', tab !== 'register'); + }); + }); + + // Forms + $('#loginForm').addEventListener('submit', doLogin); + $('#registerForm').addEventListener('submit', doRegister); + $('#regPassword').addEventListener('input', updateRegStrength); + $('#entryPassword').addEventListener('input', updateEntryStrength); + + // Top-bar + function applyViewMode() { + $$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode)); + renderGrid(); } -}); -document.addEventListener('mousemove', function(e) { - if (!rectState.active) return; - const dx = e.clientX - rectState.startX; - const dy = e.clientY - rectState.startY; - if (!rectState.started && (dx > 5 || dx < -5 || dy > 5 || dy < -5)) { - rectState.started = true; - rectState.el = document.createElement('div'); - rectState.el.id = 'rectSelect'; - document.body.appendChild(rectState.el); + applyViewMode(); + $$('.view-btn').forEach(b => b.addEventListener('click', () => { + state.viewMode = b.dataset.view; + localStorage.setItem('viewMode', state.viewMode); + applyViewMode(); + })); + + $('#themeBtn').addEventListener('click', toggleTheme); + $('#newEntryBtn').addEventListener('click', () => openEntryModal()); + $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); + $('#lockBtn').addEventListener('click', lockVault); + $('#logoutBtn').addEventListener('click', doLogout); + document.addEventListener('click', e => { + if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden'); + // Close any open kebab menu when clicking outside it + if (!e.target.closest('.entry-kebab-wrap')) { + $$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open')); + } + }); + + // Sidebar nav + $$('#appShell .nav-item[data-view]').forEach(n => { + n.addEventListener('click', () => setView(n.dataset.view)); + }); + $('#addFolderBtn').addEventListener('click', addFolder); + + // Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it + const trashItem = $('#appShell .nav-item[data-view="trash"]'); + if (trashItem) { + trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); }); + trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over')); + trashItem.addEventListener('drop', async ev => { + ev.preventDefault(); + trashItem.classList.remove('drag-over'); + const id = parseInt(ev.dataTransfer.getData('text/plain')); + if (!id) return; + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + toast('Moved to trash'); + await loadEntries(); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } + }); } - if (rectState.el) { - const x = Math.min(rectState.startX, e.clientX); - const y = Math.min(rectState.startY, e.clientY); - const w = Math.abs(dx); - const h = Math.abs(dy); - rectState.el.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;display:block;position:fixed;pointer-events:none;z-index:999;border:1px solid var(--accent);background:rgba(59,130,246,0.1);`; - } -}); -document.addEventListener('mouseup', function(e) { - if (!rectState.active) return; - rectState.active = false; - if (rectState.el) { - rectState.el.remove(); - rectState.el = null; - if (rectState.started) { - const r = { - left: Math.min(rectState.startX, e.clientX), - top: Math.min(rectState.startY, e.clientY), - right: Math.max(rectState.startX, e.clientX), - bottom: Math.max(rectState.startY, e.clientY) - }; - const container = document.getElementById('entriesContainer'); - if (container) { - container.querySelectorAll('.entry-card,.entry-row,.entry-compact,.table-row-drag').forEach(el => { - const er = el.getBoundingClientRect(); - if (er.left < r.right && er.right > r.left && er.top < r.bottom && er.bottom > r.top) { - const id = parseInt(el.dataset.id); - if (id) selectedIds.add(id); - } - }); + + // Search + $('#searchInput').addEventListener('input', e => { + state.search = e.target.value; + renderGrid(); + }); + + // Marquee rubber-band selection on the entry grid + $('#entryGrid').addEventListener('mousedown', startMarquee); + + // Slide-over + $('#slideoverClose').addEventListener('click', closeSlideOver); + // Click outside the slide-over closes it. Clicks on cards re-open it for + // another entry (so we don't close in that case; the card's own handler + // will switch state.selectedId). + document.addEventListener('click', e => { + if (!$('#slideover').classList.contains('is-open')) return; + if (e.target.closest('.slideover')) return; + if (e.target.closest('.entry-card')) return; + if (e.target.closest('.modal')) return; + if (e.target.closest('.cmd-palette')) return; + if (e.target.closest('.idle-warning')) return; + closeSlideOver(); + }); + + // Entry modal + $('#entryForm').addEventListener('submit', saveEntry); + $('#entrySaveBtn').addEventListener('click', saveEntry); + $$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal)); + $('#entryPwToggle').addEventListener('click', () => { + const input = $('#entryPassword'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); + $('#entryPwGen').addEventListener('click', openGen); + + // Chip input (tags) + $('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus()); + $('#entryTagsField').addEventListener('keydown', e => { + const field = e.target; + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (!selectActiveSuggestion()) { + addTag(field.value); + field.value = ''; + closeChipSuggest(); } - if (selectedIds.size > 0) { updateBatchBar(); render(); } - } - } -}); -document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { const a = document.activeElement; if (!a || a.tagName === 'BUTTON') return; e.preventDefault(); if (document.getElementById('editModal').classList.contains('show') && a.closest('.edit-box')) saveEdit(); else if (document.getElementById('addModal').classList.contains('show') && a.closest('.modal-box')) addEntry(); else if (!document.getElementById('authSection').classList.contains('hidden')) { if (a.id === 'loginUsername' || a.id === 'loginPassword') login(); else if (a.id === 'regPassword') register(); } } }); -document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); }); -document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); }); -// ==================== KEYBOARD SHORTCUTS ==================== -document.addEventListener('keydown', function(e) { - const tag = document.activeElement?.tagName; - const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - - // Escape – close any open modal or settings - if (e.key === 'Escape') { - if (rectState.active || rectState.el) { rectState.active = false; if (rectState.el) { rectState.el.remove(); rectState.el = null; } clearSelection(); return; } - if (selectedIds.size > 0) { clearSelection(); return; } - if (!document.getElementById('settingsMenu').classList.contains('hidden')) { - document.getElementById('settingsMenu').classList.add('hidden'); - return; - } - if (document.getElementById('addModal').classList.contains('show')) { closeAdd(); return; } - if (document.getElementById('editModal').classList.contains('show')) { closeEdit(); return; } - if (document.getElementById('genModal').style.display === 'flex') { closeGen(); return; } - const confirm = document.querySelector('.custom-confirm.show'); - if (confirm) confirm.remove(); - return; - } + } else if (e.key === 'Backspace' && !field.value && editingTags.length) { + editingTags.pop(); + renderChips(); + } else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); } + else if (e.key === 'Escape') { closeChipSuggest(); } + }); + $('#entryTagsField').addEventListener('input', openChipSuggest); + $('#entryTagsField').addEventListener('focus', openChipSuggest); + $('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150)); - // Arrow keys for detail view navigation - if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && view === 'detail' && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - goDetail(e.key === 'ArrowLeft' ? -1 : 1); - return; - } - - // Arrow keys — navigate entries in vault - if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && document.getElementById('authSection').classList.contains('hidden') && view !== 'detail' && !document.getElementById('addModal').classList.contains('show') && !document.getElementById('editModal').classList.contains('show')) { - e.preventDefault(); - const filtered = getFilteredEntries(); - if (!filtered.length) return; - const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight'; - let idx = arrowFocus >= 0 ? arrowFocus : -1; - if (idx < 0 && selectedIds.size > 0) { - const firstId = [...selectedIds][0]; - idx = filtered.findIndex(e => e.id == firstId); - } - if (view === 'grid' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - if (idx < 0) idx = 0; - else { const cols = getGridCols(); if (e.key === 'ArrowDown') { const next = idx + cols; idx = next < filtered.length ? next : idx; } else { const prev = idx - cols; idx = prev >= 0 ? prev : idx; } } + // Generator + $('#genLen').addEventListener('input', genPassword); + $$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword)); + $('#genRegen').addEventListener('click', genPassword); + $('#genCopy').addEventListener('click', () => { + if (!genCurrent) return; + if (Bridge.copySecure(genCurrent, 30000)) { + toast('Copied · clears in 30s'); } else { - if (isNext) idx = idx < filtered.length - 1 ? idx + 1 : 0; - else idx = idx > 0 ? idx - 1 : filtered.length - 1; + navigator.clipboard.writeText(genCurrent).then(() => { + toast('Copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); } - if (e.shiftKey) { - if (arrowAnchor < 0) arrowAnchor = idx; - arrowFocus = idx; - const start = Math.min(arrowAnchor, arrowFocus), end = Math.max(arrowAnchor, arrowFocus); - selectedIds.clear(); - for (let i = start; i <= end; i++) selectedIds.add(filtered[i].id); + }); + $('#genUse').addEventListener('click', () => { + if (genTarget === 'slideover') { + const soPw = $('#soPassword'); + if (soPw) { soPw.value = genCurrent; soDirtyCheck(); } } else { - selectedIds.clear(); - selectedIds.add(filtered[idx].id); - arrowAnchor = idx; - arrowFocus = idx; + $('#entryPassword').value = genCurrent; + updateEntryStrength(); } - updateBatchBar(); render(); playSound('click'); - return; - } + closeGen(); + }); + $$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen)); - // Enter — open edit for single selected entry - if (e.key === 'Enter' && !isInput && selectedIds.size === 1 && document.getElementById('authSection').classList.contains('hidden') && !document.getElementById('editModal').classList.contains('show') && !document.getElementById('addModal').classList.contains('show')) { - e.preventDefault(); - openEdit([...selectedIds][0]); - return; - } + // Sidebar Generator tool + $('#sidebarGenBtn').addEventListener('click', () => openGen('standalone')); + $('#sidebarExportBtn').addEventListener('click', doExport); - // ? or / to show shortcuts help (only in vault) - if ((e.key === '?' || e.key === '/') && !isInput) { - if (!document.getElementById('authSection').classList.contains('hidden')) return; - e.preventDefault(); - showShortcutsHelp(); - return; - } + // Idle warning "Stay unlocked" + $('#idleStayBtn').addEventListener('click', resetAutoLock); - // Delete key — move to trash or permanently delete selected entries - if (e.key === 'Delete' && !isInput && selectedIds.size > 0 && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - if (showTrash) batchPermanentDelete(); - else batchDelete(); - return; - } - - // Alt+N — New entry (Ctrl+N intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'n' || e.key === 'N') && !isInput && !document.getElementById('addModal').classList.contains('show') && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - openAdd(); - return; - } - - // Alt+T — Toggle trash (Ctrl+T intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - toggleTrash(); - return; - } - - // Only handle Ctrl+[key], no Shift/Alt/Meta - if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; - - // Prevent browser defaults for ALL our shortcuts BEFORE dispatching - const code = e.code; - if (code === 'KeyF' || code === 'KeyL' || code === 'KeyS') { - e.preventDefault(); - } - - if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === 'a' || e.key === 'A') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id)); - updateBatchBar(); + // Settings slide-over + $('#settingsBtn').addEventListener('click', openSettings); + $('#settingsClose').addEventListener('click', closeSettings); + $('#settingTheme').addEventListener('change', e => setTheme(e.target.value)); + $('#settingAutoLock').addEventListener('change', e => { + state.autoLock = parseInt(e.target.value); + localStorage.setItem('autoLockMin', String(state.autoLock)); + resetAutoLock(); + toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled'); + }); + $('#settingAskDelete').addEventListener('change', e => { + state.askBeforeDelete = e.target.checked; + localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0'); + toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking'); + }); + $('#settingCompact').addEventListener('change', e => { + state.compactActions = e.target.checked; + localStorage.setItem('compactActions', state.compactActions ? '1' : '0'); render(); - playSound('click'); - return; - } + }); + $('#settingMaskUser').addEventListener('change', e => { + state.maskUsernames = e.target.checked; + localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); + render(); + }); + $('#openClipboardSettings').addEventListener('click', () => { + toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); + }); + $('#exportBtn').addEventListener('click', doExport); - if (code === 'KeyF') { - const el = document.getElementById('searchInput'); - if (el) { el.focus(); el.select(); } - } else if (code === 'KeyL') { - if (!isInput) doLogout(); - } else if (code === 'KeyS') { - if (document.getElementById('addModal').classList.contains('show')) addEntry(); - else if (document.getElementById('editModal').classList.contains('show')) saveEdit(); + // Re-auth modal + $('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); }); + $$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false))); + + // Custom confirm / prompt modal + $('#confirmForm').addEventListener('submit', e => { + e.preventDefault(); + // If input field visible -> resolve with its value, else -> true + const hasInput = !$('#confirmInputField').classList.contains('is-hidden'); + closeConfirm(hasInput ? $('#confirmInput').value : true); + }); + $$('#confirmModal [data-confirm-cancel]').forEach(b => + b.addEventListener('click', () => closeConfirm(false)) + ); + + // Command palette + document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + openPalette(); + } else if (e.key === 'Escape') { + // Close in priority order: confirm first (most modal-y) then others + if (!$('#confirmModal').classList.contains('is-hidden')) { + closeConfirm(false); + return; + } + closePalette(); + closeSlideOver(); + closeEntryModal(); + closeGen(); + } + }); + $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); + $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); + + // Restore session if any + if (state.token && state.salt) { + const ok = await restoreCryptoKey(); + if (ok) { + await enterApp(); + } else { + // session token exists but crypto key gone — user must re-enter master pw + showAuth(); + $('#loginUsername').value = state.username; + } + } else { + showAuth(); } -}); -document.getElementById('loginUsername').focus(); -document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); \ No newline at end of file +} + +document.addEventListener('DOMContentLoaded', init);